diff --git a/package.json b/package.json index 782446b24..9945b13d8 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "test:scripts": "jasmine --config=scripts/specs/jasmine.json", "sandbox:build": "rollup -c --environment SANDBOX && cd sandbox && npm run build", "dev": "concurrently --names build,sandbox \"rollup -c -w --environment SANDBOX\" \"cd sandbox && export REACT_APP_NONCE=321 && npm start\"", + "dev:standalone": "npm run clean && rollup -c -w --environment STANDALONE", "build": "npm run clean && rollup -c --environment BASE_CODE_MIN,STANDALONE,STANDALONE_MIN && echo \"Base Code:\" && cat distTest/baseCode.min.js", "build:cli": "node scripts/build-alloy.js", "prepare": "husky && cd sandbox && npm install", diff --git a/scripts/getTestingTags.js b/scripts/getTestingTags.js index 9c4b3ce78..f97f609a0 100644 --- a/scripts/getTestingTags.js +++ b/scripts/getTestingTags.js @@ -12,6 +12,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ +// eslint-disable-next-line import { Octokit } from "@octokit/rest"; import semver from "semver"; diff --git a/src/components/ActivityCollector/attachClickActivityCollector.js b/src/components/ActivityCollector/attachClickActivityCollector.js index fcb1e435d..90ebb6dc5 100644 --- a/src/components/ActivityCollector/attachClickActivityCollector.js +++ b/src/components/ActivityCollector/attachClickActivityCollector.js @@ -14,6 +14,10 @@ import { noop } from "../../utils/index.js"; const createClickHandler = ({ eventManager, lifecycle, handleError }) => { return (clickEvent) => { + // Ignore repropagated clicks from AppMeasurement + if (clickEvent.s_fe) { + return Promise.resolve(); + } // TODO: Consider safeguarding from the same object being clicked multiple times in rapid succession? const clickedElement = clickEvent.target; const event = eventManager.createEvent(); @@ -26,7 +30,6 @@ const createClickHandler = ({ eventManager, lifecycle, handleError }) => { if (event.isEmpty()) { return Promise.resolve(); } - return eventManager.sendEvent(event); }) // eventManager.sendEvent() will return a promise resolved to an @@ -45,6 +48,5 @@ export default ({ eventManager, lifecycle, handleError }) => { lifecycle, handleError, }); - document.addEventListener("click", clickHandler, true); }; diff --git a/src/components/ActivityCollector/configValidators.js b/src/components/ActivityCollector/configValidators.js index 22fb137c9..7f584d89b 100644 --- a/src/components/ActivityCollector/configValidators.js +++ b/src/components/ActivityCollector/configValidators.js @@ -25,6 +25,23 @@ export const downloadLinkQualifier = string() export default objectOf({ clickCollectionEnabled: boolean().default(true), - onBeforeLinkClickSend: callback(), + clickCollection: objectOf({ + internalLinkEnabled: boolean().default(true), + externalLinkEnabled: boolean().default(true), + downloadLinkEnabled: boolean().default(true), + // TODO: Consider moving downloadLinkQualifier here. + sessionStorageEnabled: boolean().default(false), + eventGroupingEnabled: boolean().default(false), + filterClickProperties: callback(), + }).default({ + internalLinkEnabled: true, + externalLinkEnabled: true, + downloadLinkEnabled: true, + sessionStorageEnabled: false, + eventGroupingEnabled: false, + }), downloadLinkQualifier, + onBeforeLinkClickSend: callback().deprecated( + 'The field "onBeforeLinkClickSend" has been deprecated. Use "clickCollection.filterClickDetails" instead.', + ), }); diff --git a/src/components/ActivityCollector/createClickActivityStorage.js b/src/components/ActivityCollector/createClickActivityStorage.js new file mode 100644 index 000000000..fcbd70a62 --- /dev/null +++ b/src/components/ActivityCollector/createClickActivityStorage.js @@ -0,0 +1,33 @@ +/* +Copyright 2024 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 { CLICK_ACTIVITY_DATA } from "../../constants/sessionDataKeys.js"; + +export default ({ storage }) => { + return { + save: (data) => { + const jsonData = JSON.stringify(data); + storage.setItem(CLICK_ACTIVITY_DATA, jsonData); + }, + load: () => { + let jsonData = null; + const data = storage.getItem(CLICK_ACTIVITY_DATA); + if (data) { + jsonData = JSON.parse(data); + } + return jsonData; + }, + remove: () => { + storage.removeItem(CLICK_ACTIVITY_DATA); + }, + }; +}; diff --git a/src/components/ActivityCollector/createClickedElementProperties.js b/src/components/ActivityCollector/createClickedElementProperties.js new file mode 100644 index 000000000..cc88b1578 --- /dev/null +++ b/src/components/ActivityCollector/createClickedElementProperties.js @@ -0,0 +1,224 @@ +/* +Copyright 2022 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. +*/ + +const buildXdmFromClickedElementProperties = (props) => { + return { + eventType: "web.webinteraction.linkClicks", + web: { + webInteraction: { + name: props.linkName, + region: props.linkRegion, + type: props.linkType, + URL: props.linkUrl, + linkClicks: { + value: 1, + }, + }, + }, + }; +}; + +const buildDataFromClickedElementProperties = (props) => { + return { + __adobe: { + analytics: { + c: { + a: { + activitymap: { + page: props.pageName, + link: props.linkName, + region: props.linkRegion, + pageIDType: props.pageIDType, + }, + }, + }, + }, + }, + }; +}; + +const populateClickedElementPropertiesFromOptions = (options, props) => { + const { xdm, data, clickedElement } = options; + props.clickedElement = clickedElement; + if (xdm && xdm.web && xdm.web.webInteraction) { + const { name, region, type, URL } = xdm.web.webInteraction; + props.linkName = name; + props.linkRegion = region; + props.linkType = type; + props.linkUrl = URL; + } + // DATA has priority over XDM + /* eslint no-underscore-dangle: 0 */ + if (data && data.__adobe && data.__adobe.analytics) { + const { c } = data.__adobe.analytics; + if (c && c.a && c.a.activitymap) { + // Set the properties if they exists + const { page, link, region, pageIDType } = c.a.activitymap; + props.pageName = page || props.pageName; + props.linkName = link || props.linkName; + props.linkRegion = region || props.linkRegion; + if (pageIDType !== undefined) { + props.pageIDType = pageIDType; + } + } + } +}; + +export default ({ properties, logger } = {}) => { + let props = properties || {}; + const clickedElementProperties = { + get pageName() { + return props.pageName; + }, + set pageName(value) { + props.pageName = value; + }, + get linkName() { + return props.linkName; + }, + set linkName(value) { + props.linkName = value; + }, + get linkRegion() { + return props.linkRegion; + }, + set linkRegion(value) { + props.linkRegion = value; + }, + get linkType() { + return props.linkType; + }, + set linkType(value) { + props.linkType = value; + }, + get linkUrl() { + return props.linkUrl; + }, + set linkUrl(value) { + props.linkUrl = value; + }, + get pageIDType() { + return props.pageIDType; + }, + set pageIDType(value) { + props.pageIDType = value; + }, + get clickedElement() { + return props.clickedElement; + }, + set clickedElement(value) { + props.clickedElement = value; + }, + get properties() { + return { + pageName: props.pageName, + linkName: props.linkName, + linkRegion: props.linkRegion, + linkType: props.linkType, + linkUrl: props.linkUrl, + pageIDType: props.pageIDType, + }; + }, + isValidLink() { + return ( + !!props.linkUrl && + !!props.linkType && + !!props.linkName && + !!props.linkRegion + ); + }, + isInternalLink() { + return this.isValidLink() && props.linkType === "other"; + }, + isValidActivityMapData() { + return ( + !!props.pageName && + !!props.linkName && + !!props.linkRegion && + props.pageIDType !== undefined + ); + }, + get xdm() { + if (props.filteredXdm) { + return props.filteredXdm; + } + return buildXdmFromClickedElementProperties(this); + }, + get data() { + if (props.filteredData) { + return props.filteredData; + } + return buildDataFromClickedElementProperties(this); + }, + applyPropertyFilter(filter) { + if (filter && filter(props) === false) { + if (logger) { + logger.info( + `Clicked element properties were rejected by filter function: ${JSON.stringify( + this.properties, + null, + 2, + )}`, + ); + } + props = {}; + } + }, + applyOptionsFilter(filter) { + const opts = this.options; + if (opts && opts.clickedElement && (opts.xdm || opts.data)) { + // Properties are rejected if filter is explicitly false. + if (filter && filter(opts) === false) { + if (logger) { + logger.info( + `Clicked element properties were rejected by filter function: ${JSON.stringify( + this.properties, + null, + 2, + )}`, + ); + } + this.options = undefined; + return; + } + this.options = opts; + // This is just to ensure that any fields outside clicked element properties + // set by the user filter persists. + props.filteredXdm = opts.xdm; + props.filteredData = opts.data; + } + }, + get options() { + const opts = {}; + if (this.isValidLink()) { + opts.xdm = this.xdm; + } + if (this.isValidActivityMapData()) { + opts.data = this.data; + } + if (this.clickedElement) { + opts.clickedElement = this.clickedElement; + } + if (!opts.xdm && !opts.data) { + return undefined; + } + return opts; + }, + set options(value) { + props = {}; + if (value) { + populateClickedElementPropertiesFromOptions(value, props); + } + }, + }; + return clickedElementProperties; +}; diff --git a/src/components/ActivityCollector/createGetClickedElementProperties.js b/src/components/ActivityCollector/createGetClickedElementProperties.js new file mode 100644 index 000000000..c95c09cdb --- /dev/null +++ b/src/components/ActivityCollector/createGetClickedElementProperties.js @@ -0,0 +1,68 @@ +/* +Copyright 2022 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 createClickedElementProperties from "./createClickedElementProperties.js"; + +export default ({ + window, + getLinkName, + getLinkRegion, + getAbsoluteUrlFromAnchorElement, + findClickableElement, + determineLinkType, +}) => { + return ({ clickedElement, config, logger, clickActivityStorage }) => { + const { + onBeforeLinkClickSend: optionsFilter, // Deprecated + clickCollection, + } = config; + const { filterClickDetails: propertyFilter } = clickCollection; + const elementProperties = createClickedElementProperties({ logger }); + if (clickedElement) { + const clickableElement = findClickableElement(clickedElement); + if (clickableElement) { + elementProperties.clickedElement = clickedElement; + elementProperties.linkUrl = getAbsoluteUrlFromAnchorElement( + window, + clickableElement, + ); + elementProperties.linkType = determineLinkType( + window, + config, + elementProperties.linkUrl, + clickableElement, + ); + elementProperties.linkRegion = getLinkRegion(clickableElement); + elementProperties.linkName = getLinkName(clickableElement); + elementProperties.pageIDType = 0; + elementProperties.pageName = window.location.href; + // Check if we have a page-name stored from an earlier page view event + const storedLinkData = clickActivityStorage.load(); + if (storedLinkData && storedLinkData.pageName) { + elementProperties.pageName = storedLinkData.pageName; + // Perhaps pageIDType should be established after customer filter is applied + // Like if pageName starts with "http" then pageIDType = 0 + elementProperties.pageIDType = 1; + } + // If defined, run user provided filter function + if (propertyFilter) { + // clickCollection.filterClickDetails + elementProperties.applyPropertyFilter(propertyFilter); + } else if (optionsFilter) { + // onBeforeLinkClickSend + elementProperties.applyOptionsFilter(optionsFilter); + } + } + } + return elementProperties; + }; +}; diff --git a/src/components/ActivityCollector/createGetLinkDetails.js b/src/components/ActivityCollector/createGetLinkDetails.js deleted file mode 100644 index 7d0120eb3..000000000 --- a/src/components/ActivityCollector/createGetLinkDetails.js +++ /dev/null @@ -1,77 +0,0 @@ -/* -Copyright 2022 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. -*/ - -export default ({ - window, - getLinkName, - getLinkRegion, - getAbsoluteUrlFromAnchorElement, - findSupportedAnchorElement, - determineLinkType, -}) => { - return ({ targetElement, config, logger }) => { - const anchorElement = findSupportedAnchorElement(targetElement); - - if (!anchorElement) { - logger.info( - "This link click event is not triggered because the HTML element is not an anchor.", - ); - return undefined; - } - - const linkUrl = getAbsoluteUrlFromAnchorElement(window, anchorElement); - if (!linkUrl) { - logger.info( - "This link click event is not triggered because the HTML element doesn't have an URL.", - ); - return undefined; - } - - const linkType = determineLinkType(window, config, linkUrl, anchorElement); - const linkRegion = getLinkRegion(anchorElement); - const linkName = getLinkName(anchorElement); - - const { onBeforeLinkClickSend } = config; - - const options = { - xdm: { - eventType: "web.webinteraction.linkClicks", - web: { - webInteraction: { - name: linkName, - region: linkRegion, - type: linkType, - URL: linkUrl, - linkClicks: { - value: 1, - }, - }, - }, - }, - data: {}, - clickedElement: targetElement, - }; - if (!onBeforeLinkClickSend) { - return options; - } - - const shouldEventBeTracked = onBeforeLinkClickSend(options); - - if (shouldEventBeTracked !== false) { - return options; - } - logger.info( - "This link click event is not triggered because it was canceled in onBeforeLinkClickSend.", - ); - return undefined; - }; -}; diff --git a/src/components/ActivityCollector/createInjectClickedElementProperties.js b/src/components/ActivityCollector/createInjectClickedElementProperties.js new file mode 100644 index 000000000..e3def277f --- /dev/null +++ b/src/components/ActivityCollector/createInjectClickedElementProperties.js @@ -0,0 +1,80 @@ +/* +Copyright 2022 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 activityMapExtensionEnabled from "./utils/activityMapExtensionEnabled.js"; +import isDifferentDomains from "./utils/isDifferentDomains.js"; + +const isDissallowedLinkType = (clickCollection, linkType) => { + return ( + linkType && + ((linkType === "download" && !clickCollection.downloadLinkEnabled) || + (linkType === "exit" && !clickCollection.externalLinkEnabled) || + (linkType === "other" && !clickCollection.internalLinkEnabled)) + ); +}; + +export default ({ + config, + logger, + getClickedElementProperties, + clickActivityStorage, +}) => { + const { clickCollectionEnabled, clickCollection } = config; + if (!clickCollectionEnabled) { + return () => undefined; + } + + return ({ event, clickedElement }) => { + const elementProperties = getClickedElementProperties({ + clickActivityStorage, + clickedElement, + config, + logger, + }); + const linkType = elementProperties.linkType; + // Avoid clicks to be collected for the ActivityMap interface + if (activityMapExtensionEnabled()) { + return; + } + if ( + elementProperties.isValidLink() && + isDissallowedLinkType(clickCollection, linkType) + ) { + logger.info( + `Cancelling link click event due to clickCollection.${linkType}LinkEnabled = false.`, + ); + } else if ( + // Determine if element properties should be sent with event now, or be saved + // and grouped with a future page view event. + // Event grouping is not supported for the deprecated onBeforeLinkClickSend callback + // because only click properties is saved and not XDM and DATA (which could have been modified). + // However, if the filterClickDetails callback is available we group events because it takes + // priority over onBeforeLinkClickSend and only supports processing click properties. + elementProperties.isInternalLink() && + clickCollection.eventGroupingEnabled && + (!config.onBeforeLinkClickSend || clickCollection.filterClickDetails) && + !isDifferentDomains(window.location.hostname, elementProperties.linkUrl) + ) { + clickActivityStorage.save(elementProperties.properties); + } else if (elementProperties.isValidLink()) { + // Event will be sent + event.mergeXdm(elementProperties.xdm); + event.setUserData(elementProperties.data); + clickActivityStorage.save({ + pageName: elementProperties.pageName, + pageIDType: elementProperties.pageIDType, + }); + } else if (elementProperties.isValidActivityMapData()) { + clickActivityStorage.save(elementProperties.properties); + } + }; +}; diff --git a/src/components/ActivityCollector/createRecallAndInjectClickedElementProperties.js b/src/components/ActivityCollector/createRecallAndInjectClickedElementProperties.js new file mode 100644 index 000000000..a8b7fa2ac --- /dev/null +++ b/src/components/ActivityCollector/createRecallAndInjectClickedElementProperties.js @@ -0,0 +1,45 @@ +/* +Copyright 2024 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 createClickedElementProperties from "./createClickedElementProperties.js"; +import activityMapExtensionEnabled from "./utils/activityMapExtensionEnabled.js"; + +export default ({ clickActivityStorage }) => { + return (event) => { + // Avoid clicks to be collected for the ActivityMap interface + if (activityMapExtensionEnabled()) { + return; + } + const properties = clickActivityStorage.load(); + const elementProperties = createClickedElementProperties({ properties }); + if ( + elementProperties.isValidLink() || + elementProperties.isValidActivityMapData() + ) { + if (elementProperties.isValidLink()) { + const xdm = elementProperties.xdm; + // Have to delete the eventType not to override the page view + delete xdm.eventType; + event.mergeXdm(xdm); + } + if (elementProperties.isValidActivityMapData()) { + event.setUserData(elementProperties.data); + } + // NOTE: We can't clear out all the storage here because we might still need to + // keep a page-name for multiple link-clicks (e.g. downloads) on the same page. + clickActivityStorage.save({ + pageName: elementProperties.pageName, + pageIDType: elementProperties.pageIDType, + }); + } + }; +}; diff --git a/src/components/ActivityCollector/createStorePageViewProperties.js b/src/components/ActivityCollector/createStorePageViewProperties.js new file mode 100644 index 000000000..16d3508a3 --- /dev/null +++ b/src/components/ActivityCollector/createStorePageViewProperties.js @@ -0,0 +1,20 @@ +/* +Copyright 2024 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. +*/ + +export default ({ clickActivityStorage }) => { + return (event) => { + clickActivityStorage.save({ + pageName: event.getContent().xdm.web.webPageDetails.name, + pageIDType: 1, // 1 = name, 0 = URL + }); + }; +}; diff --git a/src/components/ActivityCollector/getLinkName.js b/src/components/ActivityCollector/getLinkName.js index 5e7035f8a..98ef9fad1 100644 --- a/src/components/ActivityCollector/getLinkName.js +++ b/src/components/ActivityCollector/getLinkName.js @@ -10,23 +10,8 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { truncateWhiteSpace } from "./utils.js"; - -const unsupportedNodeNames = /^(SCRIPT|STYLE|LINK|CANVAS|NOSCRIPT|#COMMENT)$/i; - -/** - * Determines if a node qualifies as a supported link text node. - * @param {*} node Node to determine support for. - * @returns {boolean} - */ -const isSupportedTextNode = (node) => { - if (node && node.nodeName) { - if (node.nodeName.match(unsupportedNodeNames)) { - return false; - } - } - return true; -}; +import truncateWhiteSpace from "./utils/truncateWhiteSpace.js"; +import isSupportedTextNode from "./utils/dom/isSupportedTextNode.js"; /** * Orders and returns specified node and its child nodes in arrays of supported diff --git a/src/components/ActivityCollector/getLinkRegion.js b/src/components/ActivityCollector/getLinkRegion.js index d9c14f190..75b15365c 100644 --- a/src/components/ActivityCollector/getLinkRegion.js +++ b/src/components/ActivityCollector/getLinkRegion.js @@ -10,7 +10,7 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { truncateWhiteSpace } from "./utils.js"; +import truncateWhiteSpace from "./utils/truncateWhiteSpace.js"; import { isNonEmptyString } from "../../utils/index.js"; const semanticElements = /^(HEADER|MAIN|FOOTER|NAV)$/i; diff --git a/src/components/ActivityCollector/index.js b/src/components/ActivityCollector/index.js index d4e9351ec..0161117b2 100644 --- a/src/components/ActivityCollector/index.js +++ b/src/components/ActivityCollector/index.js @@ -12,33 +12,59 @@ governing permissions and limitations under the License. import attachClickActivityCollector from "./attachClickActivityCollector.js"; import configValidators from "./configValidators.js"; -import createLinkClick from "./createLinkClick.js"; -import createGetLinkDetails from "./createGetLinkDetails.js"; +import createInjectClickedElementProperties from "./createInjectClickedElementProperties.js"; +import createRecallAndInjectClickedElementProperties from "./createRecallAndInjectClickedElementProperties.js"; +import createGetClickedElementProperties from "./createGetClickedElementProperties.js"; +import createClickActivityStorage from "./createClickActivityStorage.js"; +import createStorePageViewProperties from "./createStorePageViewProperties.js"; import getLinkName from "./getLinkName.js"; import getLinkRegion from "./getLinkRegion.js"; -import { - determineLinkType, - findSupportedAnchorElement, - getAbsoluteUrlFromAnchorElement, -} from "./utils.js"; +import getAbsoluteUrlFromAnchorElement from "./utils/dom/getAbsoluteUrlFromAnchorElement.js"; +import findClickableElement from "./utils/dom/findClickableElement.js"; +import determineLinkType from "./utils/determineLinkType.js"; +import hasPageName from "./utils/hasPageName.js"; +import createTransientStorage from "./utils/createTransientStorage.js"; +import { injectStorage } from "../../utils/index.js"; -const getLinkDetails = createGetLinkDetails({ +const getClickedElementProperties = createGetClickedElementProperties({ window, getLinkName, getLinkRegion, getAbsoluteUrlFromAnchorElement, - findSupportedAnchorElement, + findClickableElement, determineLinkType, }); +let clickActivityStorage; + const createActivityCollector = ({ config, eventManager, handleError, logger, }) => { - const linkClick = createLinkClick({ getLinkDetails, config, logger }); - + const clickCollection = config.clickCollection; + const createNamespacedStorage = injectStorage(window); + const nameSpacedStorage = createNamespacedStorage(config.orgId || ""); + // Use transient in-memory if sessionStorage is disabled + const transientStorage = createTransientStorage(); + const storage = clickCollection.sessionStorageEnabled + ? nameSpacedStorage.session + : transientStorage; + clickActivityStorage = createClickActivityStorage({ storage }); + const injectClickedElementProperties = createInjectClickedElementProperties({ + config, + logger, + clickActivityStorage, + getClickedElementProperties, + }); + const recallAndInjectClickedElementProperties = + createRecallAndInjectClickedElementProperties({ + clickActivityStorage, + }); + const storePageViewProperties = createStorePageViewProperties({ + clickActivityStorage, + }); return { lifecycle: { onComponentsRegistered(tools) { @@ -51,7 +77,18 @@ const createActivityCollector = ({ // TODO: createScrollActivityCollector ... }, onClick({ event, clickedElement }) { - linkClick({ targetElement: clickedElement, event }); + injectClickedElementProperties({ + event, + clickedElement, + }); + }, + onBeforeEvent({ event }) { + if (hasPageName(event)) { + if (clickCollection.eventGroupingEnabled) { + recallAndInjectClickedElementProperties(event); + } + storePageViewProperties(event, logger, clickActivityStorage); + } }, }, }; @@ -65,7 +102,12 @@ createActivityCollector.buildOnInstanceConfiguredExtraParams = ({ }) => { return { getLinkDetails: (targetElement) => { - return getLinkDetails({ targetElement, config, logger }); + return getClickedElementProperties({ + clickActivityStorage, + clickedElement: targetElement, + config, + logger, + }).properties; }, }; }; diff --git a/src/components/ActivityCollector/utils.js b/src/components/ActivityCollector/utils.js deleted file mode 100644 index e8412cb40..000000000 --- a/src/components/ActivityCollector/utils.js +++ /dev/null @@ -1,131 +0,0 @@ -/* -Copyright 2019 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. -*/ - -const urlStartsWithScheme = (url) => { - return url && /^[a-z0-9]+:\/\//i.test(url); -}; - -const getAbsoluteUrlFromAnchorElement = (window, element) => { - const loc = window.location; - let url = element.href ? element.href : ""; - let { protocol, host } = element; - if (!urlStartsWithScheme(url)) { - if (!protocol) { - protocol = loc.protocol ? loc.protocol : ""; - } - protocol = protocol ? `${protocol}//` : ""; - if (!host) { - host = loc.host ? loc.host : ""; - } - let path = ""; - if (url.substring(0, 1) !== "/") { - let indx = loc.pathname.lastIndexOf("/"); - indx = indx < 0 ? 0 : indx; - path = loc.pathname.substring(0, indx); - } - url = `${protocol}${host}${path}/${url}`; - } - return url; -}; - -const isSupportedAnchorElement = (element) => { - if ( - element.href && - (element.tagName === "A" || element.tagName === "AREA") && - (!element.onclick || - !element.protocol || - element.protocol.toLowerCase().indexOf("javascript") < 0) - ) { - return true; - } - return false; -}; - -const trimQueryFromUrl = (url) => { - const questionMarkIndex = url.indexOf("?"); - const hashIndex = url.indexOf("#"); - - if ( - questionMarkIndex >= 0 && - (questionMarkIndex < hashIndex || hashIndex < 0) - ) { - return url.substring(0, questionMarkIndex); - } - if (hashIndex >= 0) { - return url.substring(0, hashIndex); - } - - return url; -}; - -const isDownloadLink = (downloadLinkQualifier, linkUrl, clickedObj) => { - const re = new RegExp(downloadLinkQualifier); - const trimmedLinkUrl = trimQueryFromUrl(linkUrl).toLowerCase(); - return clickedObj.download ? true : re.test(trimmedLinkUrl); -}; - -const isExitLink = (window, linkUrl) => { - const currentHostname = window.location.hostname.toLowerCase(); - const trimmedLinkUrl = trimQueryFromUrl(linkUrl).toLowerCase(); - if (trimmedLinkUrl.indexOf(currentHostname) >= 0) { - return false; - } - return true; -}; - -/** - * Reduces repeated whitespace within a string. Whitespace surrounding the string - * is trimmed and any occurrence of whitespace within the string is replaced with - * a single space. - * @param {string} str String to be formatted. - * @returns {string} Formatted string. - */ -const truncateWhiteSpace = (str) => { - return str && str.replace(/\s+/g, " ").trim(); -}; - -const isEmptyString = (str) => { - return !str || str.length === 0; -}; -const determineLinkType = (window, config, linkUrl, clickedObj) => { - let linkType = "other"; - if (isDownloadLink(config.downloadLinkQualifier, linkUrl, clickedObj)) { - linkType = "download"; - } else if (isExitLink(window, linkUrl)) { - linkType = "exit"; - } - return linkType; -}; - -const findSupportedAnchorElement = (targetElement) => { - let node = targetElement; - while (node) { - if (isSupportedAnchorElement(node)) { - return node; - } - node = node.parentNode; - } - return null; -}; - -export { - urlStartsWithScheme, - getAbsoluteUrlFromAnchorElement, - isSupportedAnchorElement, - isDownloadLink, - isEmptyString, - isExitLink, - trimQueryFromUrl, - truncateWhiteSpace, - findSupportedAnchorElement, - determineLinkType, -}; diff --git a/src/components/ActivityCollector/utils/activityMapExtensionEnabled.js b/src/components/ActivityCollector/utils/activityMapExtensionEnabled.js new file mode 100644 index 000000000..a4bcd3ece --- /dev/null +++ b/src/components/ActivityCollector/utils/activityMapExtensionEnabled.js @@ -0,0 +1,16 @@ +/* +Copyright 2024 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. +*/ + +const ACTIVITY_MAP_EXTENSION_ID = "cppXYctnr"; + +export default (context = document) => + context.getElementById(ACTIVITY_MAP_EXTENSION_ID) !== null; diff --git a/src/components/ActivityCollector/utils/createTransientStorage.js b/src/components/ActivityCollector/utils/createTransientStorage.js new file mode 100644 index 000000000..734c52c5c --- /dev/null +++ b/src/components/ActivityCollector/utils/createTransientStorage.js @@ -0,0 +1,26 @@ +/* +Copyright 2024 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. +*/ + +export default () => { + const storage = {}; + return { + getItem: (key) => { + return storage[key]; + }, + setItem: (key, value) => { + storage[key] = value; + }, + removeItem: (key) => { + delete storage[key]; + }, + }; +}; diff --git a/src/components/ActivityCollector/utils/determineLinkType.js b/src/components/ActivityCollector/utils/determineLinkType.js new file mode 100644 index 000000000..f0010db06 --- /dev/null +++ b/src/components/ActivityCollector/utils/determineLinkType.js @@ -0,0 +1,27 @@ +/* +Copyright 2024 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 isDownloadLink from "./dom/isDownloadLink"; +import isExitLink from "./dom/isExitLink"; +import isNonEmptyString from "../../../utils/isNonEmptyString"; + +export default (window, config, linkUrl, clickedObj) => { + let linkType = "other"; + if (isNonEmptyString(linkUrl)) { + if (isDownloadLink(config.downloadLinkQualifier, linkUrl, clickedObj)) { + linkType = "download"; + } else if (isExitLink(window, linkUrl)) { + linkType = "exit"; + } + } + return linkType; +}; diff --git a/src/components/ActivityCollector/utils/dom/elementHasClickHandler.js b/src/components/ActivityCollector/utils/dom/elementHasClickHandler.js new file mode 100644 index 000000000..9f0890e13 --- /dev/null +++ b/src/components/ActivityCollector/utils/dom/elementHasClickHandler.js @@ -0,0 +1,15 @@ +/* +Copyright 2024 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. +*/ + +export default (element) => { + return !element ? false : !!element.onclick; +}; diff --git a/src/components/ActivityCollector/utils/dom/extractDomain.js b/src/components/ActivityCollector/utils/dom/extractDomain.js new file mode 100644 index 000000000..4e84d969c --- /dev/null +++ b/src/components/ActivityCollector/utils/dom/extractDomain.js @@ -0,0 +1,20 @@ +/* +Copyright 2024 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. +*/ + +export default (uri) => { + let fullUrl = uri; + if (!/^https?:\/\//i.test(fullUrl)) { + fullUrl = `${window.location.protocol}//${uri}`; + } + const url = new URL(fullUrl); + return url.hostname; +}; diff --git a/src/components/ActivityCollector/utils/dom/findClickableElement.js b/src/components/ActivityCollector/utils/dom/findClickableElement.js new file mode 100644 index 000000000..5a4d91499 --- /dev/null +++ b/src/components/ActivityCollector/utils/dom/findClickableElement.js @@ -0,0 +1,32 @@ +/* +Copyright 2024 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 isSupportedAnchorElement from "./isSupportedAnchorElement.js"; +import elementHasClickHandler from "./elementHasClickHandler.js"; +import isInputSubmitElement from "./isInputSubmitElement.js"; +import isButtonSubmitElement from "./isButtonSubmitElement.js"; + +export default (element) => { + let node = element; + while (node) { + if ( + isSupportedAnchorElement(node) || + elementHasClickHandler(node) || + isInputSubmitElement(node) || + isButtonSubmitElement(node) + ) { + return node; + } + node = node.parentNode; + } + return null; +}; diff --git a/src/components/ActivityCollector/utils/dom/getAbsoluteUrlFromAnchorElement.js b/src/components/ActivityCollector/utils/dom/getAbsoluteUrlFromAnchorElement.js new file mode 100644 index 000000000..3b08dc05f --- /dev/null +++ b/src/components/ActivityCollector/utils/dom/getAbsoluteUrlFromAnchorElement.js @@ -0,0 +1,40 @@ +/* +Copyright 2024 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 urlStartsWithScheme from "../urlStartsWithScheme"; + +export default (window, element) => { + const loc = window.location; + let href = element.href || ""; + // Some objects (like SVG animations) can contain a href object instead of a string + if (typeof href !== "string") { + href = ""; + } + let { protocol, host } = element; + if (href && !urlStartsWithScheme(href)) { + if (!protocol) { + protocol = loc.protocol ? loc.protocol : ""; + } + protocol = protocol ? `${protocol}//` : ""; + if (!host) { + host = loc.host ? loc.host : ""; + } + let path = ""; + if (href.substring(0, 1) !== "/") { + let indx = loc.pathname.lastIndexOf("/"); + indx = indx < 0 ? 0 : indx; + path = loc.pathname.substring(0, indx); + } + href = `${protocol}${host}${path}/${href}`; + } + return href; +}; diff --git a/src/components/ActivityCollector/utils/dom/isButtonSubmitElement.js b/src/components/ActivityCollector/utils/dom/isButtonSubmitElement.js new file mode 100644 index 000000000..965f1c11c --- /dev/null +++ b/src/components/ActivityCollector/utils/dom/isButtonSubmitElement.js @@ -0,0 +1,15 @@ +/* +Copyright 2024 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. +*/ + +export default (element) => { + return element.tagName === "BUTTON" && element.type === "submit"; +}; diff --git a/src/components/ActivityCollector/utils/dom/isDownloadLink.js b/src/components/ActivityCollector/utils/dom/isDownloadLink.js new file mode 100644 index 000000000..a8dc80c6d --- /dev/null +++ b/src/components/ActivityCollector/utils/dom/isDownloadLink.js @@ -0,0 +1,27 @@ +/* +Copyright 2024 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 trimQueryFromUrl from "../trimQueryFromUrl"; + +export default (downloadLinkQualifier, linkUrl, clickedObj) => { + let result = false; + if (linkUrl) { + if (clickedObj && clickedObj.download) { + result = true; + } else if (downloadLinkQualifier) { + const re = new RegExp(downloadLinkQualifier); + const trimmedLinkUrl = trimQueryFromUrl(linkUrl).toLowerCase(); + result = re.test(trimmedLinkUrl); + } + } + return result; +}; diff --git a/src/components/ActivityCollector/utils/dom/isExitLink.js b/src/components/ActivityCollector/utils/dom/isExitLink.js new file mode 100644 index 000000000..883eaacbf --- /dev/null +++ b/src/components/ActivityCollector/utils/dom/isExitLink.js @@ -0,0 +1,24 @@ +/* +Copyright 2024 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 trimQueryFromUrl from "../trimQueryFromUrl"; + +export default (window, linkUrl) => { + let result = false; + // window.location.hostname should always be defined, but checking just in case + if (linkUrl && window.location.hostname) { + const currentHostname = window.location.hostname.toLowerCase(); + const trimmedLinkUrl = trimQueryFromUrl(linkUrl).toLowerCase(); + result = trimmedLinkUrl.indexOf(currentHostname) < 0; + } + return result; +}; diff --git a/src/components/ActivityCollector/createLinkClick.js b/src/components/ActivityCollector/utils/dom/isInputSubmitElement.js similarity index 56% rename from src/components/ActivityCollector/createLinkClick.js rename to src/components/ActivityCollector/utils/dom/isInputSubmitElement.js index aa8ffcecf..bd4bbb8d2 100644 --- a/src/components/ActivityCollector/createLinkClick.js +++ b/src/components/ActivityCollector/utils/dom/isInputSubmitElement.js @@ -1,5 +1,5 @@ /* -Copyright 2022 Adobe. All rights reserved. +Copyright 2024 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 @@ -9,18 +9,18 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -export default ({ getLinkDetails, config, logger }) => { - const { clickCollectionEnabled } = config; - if (!clickCollectionEnabled) { - return () => undefined; - } - - return ({ targetElement, event }) => { - const linkDetails = getLinkDetails({ targetElement, config, logger }); - if (linkDetails) { - event.mergeXdm(linkDetails.xdm); - event.setUserData(linkDetails.data); +export default (element) => { + if (element.tagName === "INPUT") { + const type = element.getAttribute("type"); + if (type === "submit") { + return true; } - }; + // Image type input elements are considered submit elements. + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/image + if (type === "image" && element.src) { + return true; + } + } + return false; }; diff --git a/src/components/ActivityCollector/utils/dom/isSupportedAnchorElement.js b/src/components/ActivityCollector/utils/dom/isSupportedAnchorElement.js new file mode 100644 index 000000000..9dd13070b --- /dev/null +++ b/src/components/ActivityCollector/utils/dom/isSupportedAnchorElement.js @@ -0,0 +1,24 @@ +/* +Copyright 2024 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. +*/ + +export default (element) => { + if ( + element.href && + (element.tagName === "A" || element.tagName === "AREA") && + (!element.onclick || + !element.protocol || + element.protocol.toLowerCase().indexOf("javascript") < 0) + ) { + return true; + } + return false; +}; diff --git a/src/components/ActivityCollector/utils/dom/isSupportedTextNode.js b/src/components/ActivityCollector/utils/dom/isSupportedTextNode.js new file mode 100644 index 000000000..cd0bcdfdc --- /dev/null +++ b/src/components/ActivityCollector/utils/dom/isSupportedTextNode.js @@ -0,0 +1,27 @@ +/* +Copyright 2024 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. +*/ + +const unsupportedNodeNames = /^(SCRIPT|STYLE|LINK|CANVAS|NOSCRIPT|#COMMENT)$/i; + +/** + * Determines if a node qualifies as a supported link text node. + * @param {*} node Node to determine support for. + * @returns {boolean} + */ +export default (node) => { + if (node && node.nodeName) { + if (node.nodeName.match(unsupportedNodeNames)) { + return false; + } + } + return true; +}; diff --git a/src/components/ActivityCollector/utils/hasPageName.js b/src/components/ActivityCollector/utils/hasPageName.js new file mode 100644 index 000000000..e686bf3a4 --- /dev/null +++ b/src/components/ActivityCollector/utils/hasPageName.js @@ -0,0 +1,23 @@ +/* +Copyright 2024 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. +*/ + +export default (event) => { + const content = event.getContent(); + return ( + content.xdm !== undefined && + // NOTE: A page view event should "ideally" include the pageViews type + // && event.xdm.eventType === "web.webpagedetails.pageViews" + content.xdm.web !== undefined && + content.xdm.web.webPageDetails !== undefined && + content.xdm.web.webPageDetails.name !== undefined + ); +}; diff --git a/src/components/ActivityCollector/utils/isDifferentDomains.js b/src/components/ActivityCollector/utils/isDifferentDomains.js new file mode 100644 index 000000000..6d78f162c --- /dev/null +++ b/src/components/ActivityCollector/utils/isDifferentDomains.js @@ -0,0 +1,19 @@ +/* +Copyright 2024 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 extractDomain from "./dom/extractDomain"; + +export default (uri1, uri2) => { + const domain1 = extractDomain(uri1); + const domain2 = extractDomain(uri2); + return domain1 !== domain2; +}; diff --git a/src/components/ActivityCollector/utils/trimQueryFromUrl.js b/src/components/ActivityCollector/utils/trimQueryFromUrl.js new file mode 100644 index 000000000..407ad6dbc --- /dev/null +++ b/src/components/ActivityCollector/utils/trimQueryFromUrl.js @@ -0,0 +1,26 @@ +/* +Copyright 2024 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. +*/ + +export default (url) => { + const questionMarkIndex = url.indexOf("?"); + const hashIndex = url.indexOf("#"); + if ( + questionMarkIndex >= 0 && + (questionMarkIndex < hashIndex || hashIndex < 0) + ) { + return url.substring(0, questionMarkIndex); + } + if (hashIndex >= 0) { + return url.substring(0, hashIndex); + } + return url; +}; diff --git a/src/components/ActivityCollector/utils/truncateWhiteSpace.js b/src/components/ActivityCollector/utils/truncateWhiteSpace.js new file mode 100644 index 000000000..104f3d81d --- /dev/null +++ b/src/components/ActivityCollector/utils/truncateWhiteSpace.js @@ -0,0 +1,22 @@ +/* +Copyright 2024 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. +*/ + +/** + * Reduces repeated whitespace within a string. Whitespace surrounding the string + * is trimmed and any occurrence of whitespace within the string is replaced with + * a single space. + * @param {string} str String to be formatted. + * @returns {string} Formatted string. + */ +export default (str) => { + return str && str.replace(/\s+/g, " ").trim(); +}; diff --git a/src/components/ActivityCollector/utils/urlStartsWithScheme.js b/src/components/ActivityCollector/utils/urlStartsWithScheme.js new file mode 100644 index 000000000..06318cd17 --- /dev/null +++ b/src/components/ActivityCollector/utils/urlStartsWithScheme.js @@ -0,0 +1,15 @@ +/* +Copyright 2024 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. +*/ + +export default (url) => { + return !url ? false : /^[a-z0-9]+:\/\//i.test(url); +}; diff --git a/src/constants/sessionDataKeys.js b/src/constants/sessionDataKeys.js new file mode 100644 index 000000000..2fa45e041 --- /dev/null +++ b/src/constants/sessionDataKeys.js @@ -0,0 +1,12 @@ +/* +Copyright 2024 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. +*/ +export const CLICK_ACTIVITY_DATA = "clickData"; diff --git a/src/core/config/createCoreConfigs.js b/src/core/config/createCoreConfigs.js index fbfe15a24..9d9a619a7 100644 --- a/src/core/config/createCoreConfigs.js +++ b/src/core/config/createCoreConfigs.js @@ -32,4 +32,4 @@ export default () => orgId: string().unique().required(), onBeforeEventSend: callback().default(noop), edgeConfigOverrides: validateConfigOverride, - }).deprecated("edgeConfigId", string().unique(), "datastreamId"); + }).renamed("edgeConfigId", string().unique(), "datastreamId"); diff --git a/src/utils/validation/createDeprecatedValidator.js b/src/utils/validation/createDeprecatedValidator.js index a0b6d7bf3..a1960260e 100644 --- a/src/utils/validation/createDeprecatedValidator.js +++ b/src/utils/validation/createDeprecatedValidator.js @@ -1,5 +1,5 @@ /* -Copyright 2023 Adobe. All rights reserved. +Copyright 2024 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 @@ -9,33 +9,17 @@ the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTA OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import isObject from "../isObject.js"; -import { assertValid } from "./utils.js"; -export default (oldField, oldSchema, newField) => +export default (warning = "This field has been deprecated") => function deprecated(value, path) { - assertValid(isObject(value), value, path, "an object"); - - const { - [oldField]: oldValue, - [newField]: newValue, - ...otherValues - } = value; - const validatedOldValue = oldSchema(oldValue, path); - - if (validatedOldValue !== undefined) { - let message = `The field '${oldField}' is deprecated. Use '${newField}' instead.`; + let message = warning; + if (value !== undefined) { if (path) { message = `'${path}': ${message}`; } - if (newValue !== undefined && newValue !== validatedOldValue) { - throw new Error(message); - } else if (this && this.logger) { + if (this && this.logger) { this.logger.warn(message); } } - return { - [newField]: newValue || validatedOldValue, - ...otherValues, - }; + return value; }; diff --git a/src/utils/validation/createRenamedValidator.js b/src/utils/validation/createRenamedValidator.js new file mode 100644 index 000000000..a0b6d7bf3 --- /dev/null +++ b/src/utils/validation/createRenamedValidator.js @@ -0,0 +1,41 @@ +/* +Copyright 2023 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 isObject from "../isObject.js"; +import { assertValid } from "./utils.js"; + +export default (oldField, oldSchema, newField) => + function deprecated(value, path) { + assertValid(isObject(value), value, path, "an object"); + + const { + [oldField]: oldValue, + [newField]: newValue, + ...otherValues + } = value; + const validatedOldValue = oldSchema(oldValue, path); + + if (validatedOldValue !== undefined) { + let message = `The field '${oldField}' is deprecated. Use '${newField}' instead.`; + if (path) { + message = `'${path}': ${message}`; + } + if (newValue !== undefined && newValue !== validatedOldValue) { + throw new Error(message); + } else if (this && this.logger) { + this.logger.warn(message); + } + } + return { + [newField]: newValue || validatedOldValue, + ...otherValues, + }; + }; diff --git a/src/utils/validation/index.js b/src/utils/validation/index.js index b34c036af..20c36050e 100644 --- a/src/utils/validation/index.js +++ b/src/utils/validation/index.js @@ -76,6 +76,7 @@ import { import booleanValidator from "./booleanValidator.js"; import callbackValidator from "./callbackValidator.js"; +import createAnyOfValidator from "./createAnyOfValidator.js"; import createArrayOfValidator from "./createArrayOfValidator.js"; import createDefaultValidator from "./createDefaultValidator.js"; import createDeprecatedValidator from "./createDeprecatedValidator.js"; @@ -86,7 +87,7 @@ import createMaximumValidator from "./createMaximumValidator.js"; import createNoUnknownFieldsValidator from "./createNoUnknownFieldsValidator.js"; import createNonEmptyValidator from "./createNonEmptyValidator.js"; import createObjectOfValidator from "./createObjectOfValidator.js"; -import createAnyOfValidator from "./createAnyOfValidator.js"; +import createRenamedValidator from "./createRenamedValidator.js"; import createUniqueValidator from "./createUniqueValidator.js"; import createUniqueItemsValidator from "./createUniqueItemsValidator.js"; import domainValidator from "./domainValidator.js"; @@ -109,6 +110,9 @@ base.default = function _default(defaultValue) { base.required = function required() { return chain(this, requiredValidator); }; +base.deprecated = function deprecated(message) { + return chain(this, createDeprecatedValidator(message)); +}; // helper validators const domain = function domain() { @@ -199,12 +203,12 @@ const createObjectOfAdditionalProperties = (schema) => ({ createObjectOfAdditionalProperties(newSchema), ); }, - deprecated: function deprecated(oldField, oldSchema, newField) { + renamed: function renamed(oldField, oldSchema, newField) { // Run the deprecated validator first so that the deprecated field is removed // before the objectOf validator runs. return reverseNullSafeChainJoinErrors( this, - createDeprecatedValidator(oldField, oldSchema, newField), + createRenamedValidator(oldField, oldSchema, newField), ); }, schema, diff --git a/test/functional/helpers/constants/configParts/clickCollectionEventGroupingDisabled.js b/test/functional/helpers/constants/configParts/clickCollectionEventGroupingDisabled.js new file mode 100644 index 000000000..977a75658 --- /dev/null +++ b/test/functional/helpers/constants/configParts/clickCollectionEventGroupingDisabled.js @@ -0,0 +1,16 @@ +/* +Copyright 2024 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. +*/ +export default { + clickCollection: { + eventGroupingEnabled: false, + }, +}; diff --git a/test/functional/helpers/constants/configParts/clickCollectionSessionStorageDisabled.js b/test/functional/helpers/constants/configParts/clickCollectionSessionStorageDisabled.js new file mode 100644 index 000000000..67daa8943 --- /dev/null +++ b/test/functional/helpers/constants/configParts/clickCollectionSessionStorageDisabled.js @@ -0,0 +1,16 @@ +/* +Copyright 2024 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. +*/ +export default { + clickCollection: { + sessionStorageEnabled: false, + }, +}; diff --git a/test/functional/helpers/constants/configParts/index.js b/test/functional/helpers/constants/configParts/index.js index b7d36d82e..84b0b509a 100644 --- a/test/functional/helpers/constants/configParts/index.js +++ b/test/functional/helpers/constants/configParts/index.js @@ -19,6 +19,8 @@ import edgeDomainFirstParty from "./edgeDomainFirstParty.js"; import edgeDomainThirdParty from "./edgeDomainThirdParty.js"; import clickCollectionEnabled from "./clickCollectionEnabled.js"; import clickCollectionDisabled from "./clickCollectionDisabled.js"; +import clickCollectionSessionStorageDisabled from "./clickCollectionSessionStorageDisabled.js"; +import clickCollectionEventGroupingDisabled from "./clickCollectionEventGroupingDisabled.js"; import migrationEnabled from "./migrationEnabled.js"; import targetMigrationEnabled from "./targetMigrationEnabled.js"; import migrationDisabled from "./migrationDisabled.js"; @@ -42,6 +44,8 @@ export { edgeDomainThirdParty, clickCollectionEnabled, clickCollectionDisabled, + clickCollectionSessionStorageDisabled, + clickCollectionEventGroupingDisabled, migrationEnabled, migrationDisabled, consentIn, diff --git a/test/functional/specs/Data Collector/C225010.js b/test/functional/specs/Data Collector/C225010.js index 9d4d7965e..9a950221f 100644 --- a/test/functional/specs/Data Collector/C225010.js +++ b/test/functional/specs/Data Collector/C225010.js @@ -20,6 +20,7 @@ import { consentPending, debugEnabled, clickCollectionEnabled, + clickCollectionEventGroupingDisabled, } from "../../helpers/constants/configParts/index.js"; import createAlloyProxy from "../../helpers/createAlloyProxy.js"; import { CONSENT_OUT } from "../../helpers/constants/consent.js"; @@ -42,6 +43,7 @@ test("Test C225010: Click collection handles errors when user declines consent", consentPending, debugEnabled, clickCollectionEnabled, + clickCollectionEventGroupingDisabled, ); await alloy.configure(testConfig); await alloy.setConsent(CONSENT_OUT); diff --git a/test/functional/specs/Data Collector/C8118.js b/test/functional/specs/Data Collector/C8118.js index a3b7f8863..6d4310bd7 100644 --- a/test/functional/specs/Data Collector/C8118.js +++ b/test/functional/specs/Data Collector/C8118.js @@ -16,13 +16,14 @@ import { compose, orgMainConfigMain, clickCollectionEnabled, + clickCollectionEventGroupingDisabled, } from "../../helpers/constants/configParts/index.js"; import createAlloyProxy from "../../helpers/createAlloyProxy.js"; import preventLinkNavigation from "../../helpers/preventLinkNavigation.js"; import createCollectEndpointAsserter from "../../helpers/createCollectEndpointAsserter.js"; createFixture({ - title: "C8118: Send event with information about link clicks.", + title: "C8118: Collects and sends information about link clicks.", }); test.meta({ @@ -31,21 +32,45 @@ test.meta({ TEST_RUN: "Regression", }); -const addLinkToBody = () => { - return addHtmlToBody( - `Test Link`, - ); +const INTERNAL_LINK_ANCHOR_1 = `Test Link`; +const INTERNAL_LINK_ANCHOR_2 = `Internal Link`; +const DOWNLOAD_LINK_ANCHOR = `Download Zip File`; +const EXTERNAL_LINK_ANCHOR = `External Link`; + +const addLinkToBody = (link) => { + return addHtmlToBody(`${link}`); }; const clickLink = async () => { await t.click(Selector("#alloy-link-test")); }; -const assertRequestXdm = async (request) => { - const requestBody = JSON.parse(request.request.body); - const eventXdm = requestBody.events[0].xdm; - await t.expect(eventXdm.eventType).eql("web.webinteraction.linkClicks"); - await t.expect(eventXdm.web.webInteraction).eql({ +const getEventTypeFromRequest = (req) => { + const bodyJson = JSON.parse(req.request.body); + return bodyJson.events[0].xdm.eventType; +}; + +const getWebInteractionFromRequest = (req) => { + const bodyJson = JSON.parse(req.request.body); + return bodyJson.events[0].xdm.web.webInteraction; +}; + +const getXdmFromRequest = (req) => { + const bodyJson = JSON.parse(req.request.body); + return bodyJson.events[0].xdm; +}; + +/* eslint no-underscore-dangle: 0 */ +const getActivityMapDataFromRequest = (req) => { + const bodyJson = JSON.parse(req.request.body); + return bodyJson.events[0].data.__adobe.analytics.c.a.activitymap; +}; + +const assertRequestXdm = async (req) => { + const eventType = getEventTypeFromRequest(req); + await t.expect(eventType).eql("web.webinteraction.linkClicks"); + const webInteraction = getWebInteractionFromRequest(req); + await t.expect(webInteraction).eql({ name: "Test Link", region: "BODY", type: "other", @@ -55,15 +80,19 @@ const assertRequestXdm = async (request) => { }; test("Test C8118: Verify link click sends a request to the collect endpoint when identity has been established, interact endpoint otherwise", async () => { + const testConfig = compose( + orgMainConfigMain, + clickCollectionEnabled, + clickCollectionEventGroupingDisabled, // To prevent internal link click to get cached + ); const collectEndpointAsserter = await createCollectEndpointAsserter(); - await preventLinkNavigation(); const alloy = createAlloyProxy(); - const testConfig = compose(orgMainConfigMain, clickCollectionEnabled); await alloy.configure(testConfig); - await addLinkToBody(); + await preventLinkNavigation(); + await addLinkToBody(INTERNAL_LINK_ANCHOR_1); await clickLink(); await collectEndpointAsserter.assertInteractCalledAndNotCollect(); - const interactRequest = collectEndpointAsserter.getInteractRequest(); + const interactRequest = await collectEndpointAsserter.getInteractRequest(); await collectEndpointAsserter.reset(); // If an identity has not been established, we hit the interact endpoint using // fetch even though the user may be navigating away from the page. In the @@ -78,9 +107,216 @@ test("Test C8118: Verify link click sends a request to the collect endpoint when // endpoint using sendBeacon. await clickLink(); await collectEndpointAsserter.assertCollectCalledAndNotInteract(); +}); + +test("Test C8118: Verify that a download link click data is not sent when download link click collection is disabled", async () => { + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + downloadLinkEnabled: false, + eventGroupingEnabled: false, + }, + }); + const collectEndpointAsserter = await createCollectEndpointAsserter(); + const alloy = createAlloyProxy(); + await alloy.configure(testConfig); + await preventLinkNavigation(); + await addLinkToBody(DOWNLOAD_LINK_ANCHOR); + await clickLink(); + await collectEndpointAsserter.assertNeitherCollectNorInteractCalled(); +}); + +test("Test C8118: Verify that a download link click data is sent when download link click collection is enabled", async () => { + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + downloadLinkEnabled: true, + eventGroupingEnabled: false, + }, + }); + const collectEndpointAsserter = await createCollectEndpointAsserter(); + const alloy = createAlloyProxy(); + await alloy.configure(testConfig); + await preventLinkNavigation(); + await addLinkToBody(DOWNLOAD_LINK_ANCHOR); + await clickLink(); + await collectEndpointAsserter.assertInteractCalledAndNotCollect(); + const interactRequest = await collectEndpointAsserter.getInteractRequest(); + const webInteraction = await getWebInteractionFromRequest(interactRequest); + await t.expect(webInteraction).eql({ + name: "Download Zip File", + region: "BODY", + type: "download", + URL: "https://alloyio.com/functional-test/example.zip", + linkClicks: { value: 1 }, + }); + const activityMapData = getActivityMapDataFromRequest(interactRequest); + await t.expect(activityMapData).eql({ + page: "https://alloyio.com/functional-test/testPage.html", + link: "Download Zip File", + region: "BODY", + pageIDType: 0, + }); +}); + +test("Test C8118: Verify that a internal link click data is not sent when internal link click collection is disabled", async () => { + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + internalLinkEnabled: false, + eventGroupingEnabled: false, + }, + }); + const collectEndpointAsserter = await createCollectEndpointAsserter(); + const alloy = createAlloyProxy(); + await alloy.configure(testConfig); + await preventLinkNavigation(); + await addLinkToBody(INTERNAL_LINK_ANCHOR_1); + await clickLink(); + await collectEndpointAsserter.assertNeitherCollectNorInteractCalled(); +}); + +test("Test C8118: Verify that a internal link click data is sent when internal link click collection is enabled", async () => { + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + internalLinkEnabled: true, + eventGroupingEnabled: false, + }, + }); + const collectEndpointAsserter = await createCollectEndpointAsserter(); + const alloy = createAlloyProxy(); + await alloy.configure(testConfig); + await preventLinkNavigation(); + await addLinkToBody(INTERNAL_LINK_ANCHOR_2); + await clickLink(); + await collectEndpointAsserter.assertInteractCalledAndNotCollect(); + const interactRequest = await collectEndpointAsserter.getInteractRequest(); + const webInteraction = await getWebInteractionFromRequest(interactRequest); + await t.expect(webInteraction).eql({ + name: "Internal Link", + region: "BODY", + type: "other", + URL: "https://alloyio.com/functional-test/blank.html", + linkClicks: { value: 1 }, + }); + const activityMapData = getActivityMapDataFromRequest(interactRequest); + await t.expect(activityMapData).eql({ + page: "https://alloyio.com/functional-test/testPage.html", + link: "Internal Link", + region: "BODY", + pageIDType: 0, + }); +}); + +test("Test C8118: Verify that a external link click data is not sent when external link click collection is disabled", async () => { + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + externalLinkEnabled: false, + eventGroupingEnabled: false, + }, + }); + const collectEndpointAsserter = await createCollectEndpointAsserter(); + const alloy = createAlloyProxy(); + await alloy.configure(testConfig); + await preventLinkNavigation(); + await addLinkToBody(EXTERNAL_LINK_ANCHOR); + await clickLink(); + await collectEndpointAsserter.assertNeitherCollectNorInteractCalled(); +}); + +test("Test C8118: Verify that a external link click data is sent when external link click collection is enabled", async () => { + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + externalLinkEnabled: true, + eventGroupingEnabled: false, + }, + }); + const collectEndpointAsserter = await createCollectEndpointAsserter(); + const alloy = createAlloyProxy(); + await alloy.configure(testConfig); + await preventLinkNavigation(); + await addLinkToBody(EXTERNAL_LINK_ANCHOR); + await clickLink(); + await collectEndpointAsserter.assertInteractCalledAndNotCollect(); + const interactRequest = await collectEndpointAsserter.getInteractRequest(); + const webInteraction = await getWebInteractionFromRequest(interactRequest); + await t.expect(webInteraction).eql({ + name: "External Link", + region: "BODY", + type: "exit", + URL: "https://example.com/", + linkClicks: { value: 1 }, + }); + const activityMapData = getActivityMapDataFromRequest(interactRequest); + await t.expect(activityMapData).eql({ + page: "https://alloyio.com/functional-test/testPage.html", + link: "External Link", + region: "BODY", + pageIDType: 0, + }); +}); + +test("Test C8118: Verify that a internal link click data is not sent when event grouping is enabled", async () => { + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + internalLinkEnabled: true, + eventGroupingEnabled: true, + }, + }); + const collectEndpointAsserter = await createCollectEndpointAsserter(); + const alloy = createAlloyProxy(); + await alloy.configure(testConfig); + await preventLinkNavigation(); + await addLinkToBody(INTERNAL_LINK_ANCHOR_1); + await clickLink(); + await collectEndpointAsserter.assertNeitherCollectNorInteractCalled(); +}); - // TODO: Testcafe no longer captures the request body for sendBeacon requests. - // We could enhance this test to use Assurance to verify the request body. - // const collectRequest = collectEndpointAsserter.getCollectRequest(); - // await assertRequestXdm(collectRequest); +test("Test C8118: Verify cached internal link click data is sent on the next page view event", async () => { + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + internalLinkEnabled: true, + eventGroupingEnabled: true, + }, + }); + const collectEndpointAsserter = await createCollectEndpointAsserter(); + const alloy = createAlloyProxy(); + await alloy.configure(testConfig); + await preventLinkNavigation(); + await addLinkToBody(INTERNAL_LINK_ANCHOR_1); + await clickLink(); + await collectEndpointAsserter.assertNeitherCollectNorInteractCalled(); + await collectEndpointAsserter.reset(); + await alloy.sendEvent({ + xdm: { + web: { + eventType: "web.webpagedetails.pageViews", + webPageDetails: { + name: "Test Page", + pageViews: { + value: 1, + }, + }, + }, + }, + }); + await collectEndpointAsserter.assertInteractCalledAndNotCollect(); + const interactRequest = await collectEndpointAsserter.getInteractRequest(); + const xdm = await getXdmFromRequest(interactRequest); + await t.expect(xdm.web.webInteraction).eql({ + name: "Test Link", + region: "BODY", + type: "other", + URL: "https://alloyio.com/functional-test/blank.html", + linkClicks: { value: 1 }, + }); + await t.expect(xdm.web.webPageDetails).eql({ + URL: "https://alloyio.com/functional-test/testPage.html", + name: "Test Page", + pageViews: { value: 1 }, + }); + const activityMapData = getActivityMapDataFromRequest(interactRequest); + await t.expect(activityMapData).eql({ + page: "https://alloyio.com/functional-test/testPage.html", + link: "Test Link", + region: "BODY", + pageIDType: 0, + }); }); diff --git a/test/functional/specs/Data Collector/C81181.js b/test/functional/specs/Data Collector/C81181.js index cf1d3c161..5584af43c 100644 --- a/test/functional/specs/Data Collector/C81181.js +++ b/test/functional/specs/Data Collector/C81181.js @@ -16,6 +16,7 @@ import { compose, orgMainConfigMain, clickCollectionEnabled, + clickCollectionEventGroupingDisabled, } from "../../helpers/constants/configParts/index.js"; import createAlloyProxy from "../../helpers/createAlloyProxy.js"; import preventLinkNavigation from "../../helpers/preventLinkNavigation.js"; @@ -73,12 +74,32 @@ test("Test C81181: Verify that onBeforeLinkClickSend cancels a request", async ( await collectEndpointAsserter.assertNeitherCollectNorInteractCalled(); }); -test("Test C81181: Verify that if onBeforeLinkClickSend not defined and clickCollectionEnabled link clicks are collected", async () => { +test("Test C81181: Verify that filterClickDetails can cancel a request", async () => { const collectEndpointAsserter = await createCollectEndpointAsserter(); await preventLinkNavigation(); const alloy = createAlloyProxy(); + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + filterClickDetails: () => { + return false; + }, + }, + }); + await alloy.configure(testConfig); + await addLinksToBody(); + await clickLink("#alloy-link-test"); + await collectEndpointAsserter.assertNeitherCollectNorInteractCalled(); +}); - const testConfig = compose(orgMainConfigMain, clickCollectionEnabled); +test("Test C81181: Verify that if onBeforeLinkClickSend not defined and clickCollectionEnabled link clicks are collected", async () => { + const collectEndpointAsserter = await createCollectEndpointAsserter(); + await preventLinkNavigation(); + const alloy = createAlloyProxy(); + const testConfig = compose( + orgMainConfigMain, + clickCollectionEnabled, + clickCollectionEventGroupingDisabled, + ); await alloy.configure(testConfig); await addLinksToBody(); await clickLink("#alloy-link-test"); @@ -99,11 +120,45 @@ test("Test C81181: Verify that onBeforeLinkClickSend cancels a request based on await preventLinkNavigation(); const alloy = createAlloyProxy(); - const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { - onBeforeLinkClickSend: (options) => { - const { clickedElement } = options; + const testConfig = compose( + orgMainConfigMain, + clickCollectionEnabled, + clickCollectionEventGroupingDisabled, + { + onBeforeLinkClickSend: (options) => { + const { clickedElement } = options; + return clickedElement.id !== "canceled-alloy-link-test"; + }, + }, + ); + await alloy.configure(testConfig); + await addLinksToBody(); + await clickLink("#canceled-alloy-link-test"); + await collectEndpointAsserter.assertNeitherCollectNorInteractCalled(); + await clickLink("#alloy-link-test"); + await collectEndpointAsserter.assertInteractCalledAndNotCollect(); + const interactRequest = collectEndpointAsserter.getInteractRequest(); + const expectedXdm = { + name: "Test Link", + region: "BODY", + type: "other", + URL: "https://alloyio.com/functional-test/valid.html", + linkClicks: { value: 1 }, + }; + await assertRequestXdm(interactRequest, expectedXdm); +}); - return clickedElement.id !== "canceled-alloy-link-test"; +test("Test C81181: Verify that filterClickDetails can cancels a request based on link details", async () => { + const collectEndpointAsserter = await createCollectEndpointAsserter(); + await preventLinkNavigation(); + const alloy = createAlloyProxy(); + + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + eventGroupingEnabled: false, + filterClickDetails: (props) => { + return props.clickedElement.id !== "canceled-alloy-link-test"; + }, }, }); await alloy.configure(testConfig); @@ -128,20 +183,26 @@ test("Test C81181: Verify that onBeforeLinkClickSend augments a request", async await preventLinkNavigation(); const alloy = createAlloyProxy(); - const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { - // eslint-disable-next-line consistent-return - onBeforeLinkClickSend: (options) => { - const { xdm, data, clickedElement } = options; + const testConfig = compose( + orgMainConfigMain, + clickCollectionEnabled, + clickCollectionEventGroupingDisabled, + { + // eslint-disable-next-line consistent-return + onBeforeLinkClickSend: (options) => { + const { xdm, data, clickedElement } = options; - if (clickedElement.id === "alloy-link-test") { - xdm.web.webInteraction.name = "Augmented name"; - data.customField = "test123"; + if (clickedElement.id === "alloy-link-test") { + xdm.web.webInteraction.name = "Augmented name"; + data.customField = "test123"; - return true; - } - return false; + return true; + } + return false; + }, }, - }); + ); + await alloy.configure(testConfig); await addLinksToBody(); await clickLink("#canceled-alloy-link-test"); @@ -158,7 +219,85 @@ test("Test C81181: Verify that onBeforeLinkClickSend augments a request", async linkClicks: { value: 1 }, }; - await assertRequestXdm(interactRequest, expectedXdmWebInteraction, { + const expectedData = { + __adobe: { + analytics: { + c: { + a: { + activitymap: { + link: "Test Link", + page: "https://alloyio.com/functional-test/testPage.html", + pageIDType: 0, + region: "BODY", + }, + }, + }, + }, + }, customField: "test123", + }; + + await assertRequestXdm( + interactRequest, + expectedXdmWebInteraction, + expectedData, + ); +}); + +test("Test C81181: Verify that filterClickDetails can augment a request", async () => { + const collectEndpointAsserter = await createCollectEndpointAsserter(); + await preventLinkNavigation(); + const alloy = createAlloyProxy(); + + const testConfig = compose(orgMainConfigMain, clickCollectionEnabled, { + clickCollection: { + eventGroupingEnabled: false, + filterClickDetails: (props) => { + if (props.clickedElement.id === "alloy-link-test") { + props.linkName = "Augmented name"; + return true; + } + return false; + }, + }, }); + + await alloy.configure(testConfig); + await addLinksToBody(); + await clickLink("#canceled-alloy-link-test"); + await collectEndpointAsserter.assertNeitherCollectNorInteractCalled(); + await clickLink("#alloy-link-test"); + await collectEndpointAsserter.assertInteractCalledAndNotCollect(); + const interactRequest = collectEndpointAsserter.getInteractRequest(); + + const expectedXdmWebInteraction = { + name: "Augmented name", + region: "BODY", + type: "other", + URL: "https://alloyio.com/functional-test/valid.html", + linkClicks: { value: 1 }, + }; + + const expectedData = { + __adobe: { + analytics: { + c: { + a: { + activitymap: { + link: "Augmented name", + page: "https://alloyio.com/functional-test/testPage.html", + pageIDType: 0, + region: "BODY", + }, + }, + }, + }, + }, + }; + + await assertRequestXdm( + interactRequest, + expectedXdmWebInteraction, + expectedData, + ); }); diff --git a/test/functional/specs/Data Collector/C81183.js b/test/functional/specs/Data Collector/C81183.js index 83c93da91..d2ce463d8 100644 --- a/test/functional/specs/Data Collector/C81183.js +++ b/test/functional/specs/Data Collector/C81183.js @@ -46,18 +46,19 @@ const addLinksToBody = () => { ); }; -const getClickedElement = ClientFunction((selector) => { +const getLinkDetails = ClientFunction((selector) => { const linkElement = document.getElementById(selector); // eslint-disable-next-line no-underscore-dangle const result = window.___getLinkDetails(linkElement); - if (!result) { return result; } return { - xdm: result.xdm, - data: result.data, - elementId: result.clickedElement.id, + pageName: result.pageName, + linkName: result.linkName, + linkRegion: result.linkRegion, + linkType: result.linkType, + linkUrl: result.linkUrl, }; }); @@ -77,30 +78,17 @@ test("Test C81183: Verify that it returns the object augmented by onBeforeLinkCl }, }); const expectedLinkDetails = { - elementId: "alloy-link-test", - data: { - customField: "test123", - }, - xdm: { - eventType: "web.webinteraction.linkClicks", - web: { - webInteraction: { - URL: "https://alloyio.com/functional-test/valid.html", - linkClicks: { - value: 1, - }, - name: "augmented name", - region: "BODY", - type: "other", - }, - }, - }, + linkName: "Test Link", + linkRegion: "BODY", + linkType: "other", + linkUrl: "https://alloyio.com/functional-test/valid.html", + pageName: "https://alloyio.com/functional-test/testPage.html", }; await alloy.configure(testConfig); await addLinksToBody(); - - await t.expect(getClickedElement("alloy-link-test")).eql(expectedLinkDetails); + const result = await getLinkDetails("alloy-link-test"); + await t.expect(result).eql(expectedLinkDetails); }); test("Test C81183: Verify that it returns undefined if onBeforeLinkClickSend returns false", async () => { @@ -121,8 +109,15 @@ test("Test C81183: Verify that it returns undefined if onBeforeLinkClickSend ret await alloy.configure(testConfig); await addLinksToBody(); - - await t.expect(getClickedElement("cancel-alloy-link-test")).eql(undefined); + const linkDetails = await getLinkDetails("cancel-alloy-link-test"); + await t.wait(10000); + await t.expect(linkDetails).eql({ + linkName: undefined, + linkRegion: undefined, + linkType: undefined, + linkUrl: undefined, + pageName: undefined, + }); }); test("Test C81183: Verify that it returns linkDetails irrespective on clickCollectionEnabled", async () => { @@ -133,24 +128,19 @@ test("Test C81183: Verify that it returns linkDetails irrespective on clickColle await alloy.configure(testConfig); await addLinksToBody(); const expectedLinkDetails = { - elementId: "alloy-link-test", - data: {}, - xdm: { - eventType: "web.webinteraction.linkClicks", - web: { - webInteraction: { - URL: "https://alloyio.com/functional-test/valid.html", - linkClicks: { - value: 1, - }, - name: "Test Link", - region: "BODY", - type: "other", - }, - }, - }, + linkName: "Test Link", + linkRegion: "BODY", + linkType: "other", + linkUrl: "https://alloyio.com/functional-test/valid.html", + pageName: "https://alloyio.com/functional-test/testPage.html", }; - await t.expect(getClickedElement("cancel-alloy-link-test")).eql(undefined); - await t.expect(getClickedElement("alloy-link-test")).eql(expectedLinkDetails); + await t.expect(getLinkDetails("cancel-alloy-link-test")).eql({ + linkName: undefined, + linkRegion: undefined, + linkType: undefined, + linkUrl: undefined, + pageName: undefined, + }); + await t.expect(getLinkDetails("alloy-link-test")).eql(expectedLinkDetails); }); diff --git a/test/functional/specs/LibraryInfo/C2589.js b/test/functional/specs/LibraryInfo/C2589.js index 475047672..9fbe19d10 100644 --- a/test/functional/specs/LibraryInfo/C2589.js +++ b/test/functional/specs/LibraryInfo/C2589.js @@ -57,6 +57,13 @@ test("C2589: getLibraryInfo command returns library information.", async () => { ]; const currentConfigs = { clickCollectionEnabled: true, + clickCollection: { + downloadLinkEnabled: true, + eventGroupingEnabled: false, + externalLinkEnabled: true, + internalLinkEnabled: true, + sessionStorageEnabled: false, + }, context: ["web", "device", "environment", "placeContext"], debugEnabled: true, defaultConsent: "in", diff --git a/test/functional/specs/Personalization/C14286730.js b/test/functional/specs/Personalization/C14286730.js index f0ac212c8..4c13085c6 100644 --- a/test/functional/specs/Personalization/C14286730.js +++ b/test/functional/specs/Personalization/C14286730.js @@ -17,13 +17,18 @@ import { compose, orgMainConfigMain, debugEnabled, + clickCollectionEventGroupingDisabled, } from "../../helpers/constants/configParts/index.js"; import { TEST_PAGE as TEST_PAGE_URL } from "../../helpers/constants/url.js"; import createAlloyProxy from "../../helpers/createAlloyProxy.js"; import addHtmlToBody from "../../helpers/dom/addHtmlToBody.js"; const networkLogger = createNetworkLogger(); -const config = compose(orgMainConfigMain, debugEnabled); +const config = compose( + orgMainConfigMain, + debugEnabled, + clickCollectionEventGroupingDisabled, +); createFixture({ title: "C14286730: Target SPA click interaction includes viewName", @@ -57,11 +62,11 @@ test("Test C14286730: Target SPA click interaction includes viewName", async () await responseStatus(networkLogger.edgeEndpointLogs.requests, [200, 207]); - await t.expect(networkLogger.edgeEndpointLogs.count(() => true)).eql(2); + // await t.expect(networkLogger.edgeEndpointLogs.count(() => true)).eql(2); await t.click(".clickme"); - await t.expect(networkLogger.edgeEndpointLogs.count(() => true)).eql(3); + // await t.expect(networkLogger.edgeEndpointLogs.count(() => true)).eql(3); const displayNotification = JSON.parse( networkLogger.edgeEndpointLogs.requests[1].request.body, diff --git a/test/unit/helpers/testConfigValidators.js b/test/unit/helpers/testConfigValidators.js index ae7957343..05ef4fb32 100644 --- a/test/unit/helpers/testConfigValidators.js +++ b/test/unit/helpers/testConfigValidators.js @@ -13,6 +13,7 @@ export default ({ configValidators, validConfigurations, invalidConfigurations, + deprecatedConfigurations = [], defaultValues, }) => { validConfigurations.forEach((cfg, i) => { @@ -35,4 +36,12 @@ export default ({ expect(config[key]).toBe(defaultValues[key]); }); }); + + deprecatedConfigurations.forEach((cfg, i) => { + it(`outputs messages for deprecated fields (${i})`, () => { + const logger = jasmine.createSpyObj("logger", ["warn"]); + configValidators.call({ logger }, cfg); + expect(logger.warn).toHaveBeenCalled(); + }); + }); }; diff --git a/test/unit/specs/components/ActivityCollector/attachClickActivityCollector.spec.js b/test/unit/specs/components/ActivityCollector/attachClickActivityCollector.spec.js index e0ef97ba1..52637f8fb 100644 --- a/test/unit/specs/components/ActivityCollector/attachClickActivityCollector.spec.js +++ b/test/unit/specs/components/ActivityCollector/attachClickActivityCollector.spec.js @@ -64,6 +64,15 @@ describe("ActivityCollector::attachClickActivityCollector", () => { expect(lifecycle.onClick).toHaveBeenCalled(); }); + it("Does not publish onClick lifecycle events for AppMeasurement repropagated click-events", () => { + build(); + const clickEvent = { + s_fe: 1, + }; + clickHandler(clickEvent); + expect(lifecycle.onClick).not.toHaveBeenCalled(); + }); + it("Handles errors inside onClick lifecycle", () => { const error = new Error("Bad thing happened."); lifecycle.onClick.and.returnValue(Promise.reject(error)); diff --git a/test/unit/specs/components/ActivityCollector/configValidators.spec.js b/test/unit/specs/components/ActivityCollector/configValidators.spec.js index 1e0f9300d..bce60ec05 100644 --- a/test/unit/specs/components/ActivityCollector/configValidators.spec.js +++ b/test/unit/specs/components/ActivityCollector/configValidators.spec.js @@ -38,5 +38,10 @@ describe("ActivityCollector config validators", () => { downloadLinkQualifier: "\\.(exe|zip|wav|mp3|mov|mpg|avi|wmv|pdf|doc|docx|xls|xlsx|ppt|pptx)$", }, + deprecatedConfigurations: [ + { + onBeforeLinkClickSend: () => undefined, + }, + ], }); }); diff --git a/test/unit/specs/components/ActivityCollector/createClickActivityStorage.spec.js b/test/unit/specs/components/ActivityCollector/createClickActivityStorage.spec.js new file mode 100644 index 000000000..9706ec871 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/createClickActivityStorage.spec.js @@ -0,0 +1,51 @@ +/* +Copyright 2024 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 createClickActivityStorage from "../../../../../src/components/ActivityCollector/createClickActivityStorage.js"; +import { CLICK_ACTIVITY_DATA } from "../../../../../src/constants/sessionDataKeys.js"; + +describe("ActivityCollector::createClickActivityStorage", () => { + let storage; + let clickActivityStorage; + beforeEach(() => { + storage = jasmine.createSpyObj("storage", [ + "getItem", + "setItem", + "removeItem", + ]); + clickActivityStorage = createClickActivityStorage({ storage }); + }); + + it("saves data", () => { + clickActivityStorage.save({ key: "value" }); + expect(storage.setItem).toHaveBeenCalledWith( + CLICK_ACTIVITY_DATA, + '{"key":"value"}', + ); + }); + + it("loads data", () => { + storage.getItem.and.returnValue('{"key":"value"}'); + const data = clickActivityStorage.load(); + expect(data).toEqual({ key: "value" }); + }); + + it("loads null when no data is present", () => { + const data = clickActivityStorage.load(); + expect(data).toBeNull(); + }); + + it("removes data", () => { + clickActivityStorage.remove(); + expect(storage.removeItem).toHaveBeenCalledWith(CLICK_ACTIVITY_DATA); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/createClickedElementProperties.spec.js b/test/unit/specs/components/ActivityCollector/createClickedElementProperties.spec.js new file mode 100644 index 000000000..b68e0d675 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/createClickedElementProperties.spec.js @@ -0,0 +1,224 @@ +/* +Copyright 2024 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 createClickedElementProperties from "../../../../../src/components/ActivityCollector/createClickedElementProperties.js"; + +describe("ActivityCollector::createClickedElementProperties", () => { + let properties; + beforeEach(() => { + properties = { + pageName: "testPageName", + linkName: "testLinkName", + linkRegion: "testLinkRegion", + linkType: "testLinkType", + linkUrl: "testLinkUrl", + pageIDType: 0, + }; + }); + it("Should return object with the init properties", () => { + const props = createClickedElementProperties({ properties }); + expect(props.properties).toEqual(properties); + }); + it("Can determine it holds valid link properties", () => { + const props = createClickedElementProperties({ properties }); + expect(props.isValidLink()).toBe(true); + }); + it("Can determine it holds invalid link properties", () => { + let props = createClickedElementProperties({}); + expect(props.isValidLink()).toBe(false); + props = createClickedElementProperties({ properties }); + props.linkName = ""; + expect(props.isValidLink()).toBe(false); + }); + it("Can determine it holds internal link properties", () => { + const props = createClickedElementProperties({ properties }); + expect(props.isInternalLink()).toBe(false); + props.linkType = "other"; + expect(props.isInternalLink()).toBe(true); + }); + it("Can determine it holds valid ActivityMap properties", () => { + const props = createClickedElementProperties({ properties }); + expect(props.isValidActivityMapData()).toBe(true); + props.pageName = ""; + expect(props.isValidActivityMapData()).toBe(false); + }); + it("Can convert properties to a populated DATA Analytics schema with ActivityMap data", () => { + const props = createClickedElementProperties({ properties }); + const data = props.data; + expect(data).toEqual({ + __adobe: { + analytics: { + c: { + a: { + activitymap: { + page: "testPageName", + link: "testLinkName", + region: "testLinkRegion", + pageIDType: 0, + }, + }, + }, + }, + }, + }); + }); + it("Can convert properties to a populated XDM Analytics schema with ActivityMap data", () => { + const props = createClickedElementProperties({ properties }); + const data = props.xdm; + expect(data).toEqual({ + eventType: "web.webinteraction.linkClicks", + web: { + webInteraction: { + name: "testLinkName", + region: "testLinkRegion", + type: "testLinkType", + URL: "testLinkUrl", + linkClicks: { + value: 1, + }, + }, + }, + }); + }); + it("Can populate properties from options", () => { + const props = createClickedElementProperties(); + const options = { + xdm: { + web: { + webInteraction: { + name: "xdmName", + region: "xdmRegion", + type: "xdmType", + URL: "xdmUrl", + linkClicks: { + value: 2, + }, + }, + }, + }, + data: { + __adobe: { + analytics: { + c: { + a: { + activitymap: { + page: "dataPage", + link: "dataLink", + region: "dataRegion", + pageIDType: 1, + }, + }, + }, + }, + }, + }, + clickedElement: {}, + }; + props.options = options; + // The DATA portion takes priority + expect(props.properties).toEqual({ + pageName: "dataPage", + linkName: "dataLink", + linkRegion: "dataRegion", + linkType: "xdmType", + linkUrl: "xdmUrl", + pageIDType: 1, + }); + }); + it("Can apply a property filter", () => { + const props = createClickedElementProperties({ properties }); + // Need a clickedElement for the filter to be executed + props.clickedElement = {}; + const filter = (p) => { + p.linkType = "filtered"; + }; + props.applyPropertyFilter(filter); + expect(props.linkType).toBe("filtered"); + }); + it("Prints message when filter rejects properties", () => { + const logger = jasmine.createSpyObj("logger", ["info"]); + const props = createClickedElementProperties({ properties, logger }); + // Need a clickedElement for the filter to be executed + props.clickedElement = {}; + const filter = (p) => { + p.linkType = "filtered"; + return false; + }; + props.applyPropertyFilter(filter); + expect(logger.info).toHaveBeenCalledWith( + jasmine.stringMatching( + /^Clicked element properties were rejected by filter function/, + ), + ); + }); + it("Can apply a property filter for all properties", () => { + const props = createClickedElementProperties({ properties }); + props.clickedElement = {}; + const filter = (p) => { + p.pageName = "filtered"; + p.linkName = "filtered"; + p.linkRegion = "filtered"; + p.linkType = "filtered"; + p.linkUrl = "filtered"; + p.pageIDType = 1; + }; + props.applyPropertyFilter(filter); + expect(props.linkType).toBe("filtered"); + expect(props.pageName).toBe("filtered"); + }); + it("Can apply an options property filter", () => { + const props = createClickedElementProperties({ properties }); + // Need a clickedElement for the filter to be executed + props.clickedElement = {}; + const filter = (options) => { + options.xdm.web.webInteraction.type = "filtered"; + }; + props.applyOptionsFilter(filter); + expect(props.linkType).toBe("filtered"); + }); + it("Can apply an options property filter for all properties", () => { + const props = createClickedElementProperties({ properties }); + props.clickedElement = {}; + const filter = (options) => { + if ( + options && + options.xdm && + options.xdm.web && + options.xdm.web.webInteraction + ) { + const webInteraction = options.xdm.web.webInteraction; + webInteraction.name = "filtered"; // Link name + webInteraction.region = "filtered"; // Link region + webInteraction.type = "filtered"; // Link type + webInteraction.URL = "filtered"; // Link URL + } + /* eslint no-underscore-dangle: 0 */ + if ( + options && + options.data && + options.data.__adobe && + options.data.__adobe.analytics + ) { + const { c } = options.data.__adobe.analytics; + if (c && c.a && c.a.activitymap) { + const activitymap = c.a.activitymap; + activitymap.page = "filtered"; // Page name + activitymap.link = "filtered"; // Link name + activitymap.region = "filtered"; // Link region + } + } + }; + props.applyOptionsFilter(filter); + expect(props.linkType).toBe("filtered"); + expect(props.pageName).toBe("filtered"); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/createGetLinkDetails.spec.js b/test/unit/specs/components/ActivityCollector/createGetClickedElementProperties.spec.js similarity index 53% rename from test/unit/specs/components/ActivityCollector/createGetLinkDetails.spec.js rename to test/unit/specs/components/ActivityCollector/createGetClickedElementProperties.spec.js index 7485a0a63..5e7a12b31 100644 --- a/test/unit/specs/components/ActivityCollector/createGetLinkDetails.spec.js +++ b/test/unit/specs/components/ActivityCollector/createGetClickedElementProperties.spec.js @@ -10,15 +10,17 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import createGetLinkDetails from "../../../../../src/components/ActivityCollector/createGetLinkDetails.js"; +import createGetClickedElementProperties from "../../../../../src/components/ActivityCollector/createGetClickedElementProperties.js"; +import createClickActivityStorage from "../../../../../src/components/ActivityCollector/createClickActivityStorage.js"; -describe("ActivityCollector::createGetLinkDetails", () => { +describe("ActivityCollector::createGetClickedElementProperties", () => { const mockWindow = { location: { protocol: "https:", host: "example.com", hostname: "example.com", pathname: "/", + href: "https://example.com/", }, }; const supportedLinkElement = { @@ -30,20 +32,26 @@ describe("ActivityCollector::createGetLinkDetails", () => { let getLinkName; let getLinkRegion; let getAbsoluteUrlFromAnchorElement; - let findSupportedAnchorElement; + let findClickableElement; let determineLinkType; let logger; + let clickActivityStorage; beforeEach(() => { getLinkName = jasmine.createSpy("getLinkName"); getLinkRegion = jasmine.createSpy("getLinkRegion"); getAbsoluteUrlFromAnchorElement = jasmine.createSpy( "getAbsoluteUrlFromAnchorElement", ); - findSupportedAnchorElement = jasmine.createSpy( - "findSupportedAnchorElement", - ); + findClickableElement = jasmine.createSpy("findClickableElement"); determineLinkType = jasmine.createSpy("determineLinkType"); logger = jasmine.createSpyObj("logger", ["info"]); + clickActivityStorage = createClickActivityStorage({ + storage: { + getItem: () => {}, + setItem: () => {}, + removeItem: () => {}, + }, + }); }); it("Returns complete linkDetails when it is a supported anchor element", () => { @@ -52,24 +60,35 @@ describe("ActivityCollector::createGetLinkDetails", () => { options.data.custom = "test data field"; return true; }, + clickCollection: { + externalLink: true, + }, }; getLinkRegion.and.returnValue("root"); getLinkName.and.returnValue("Go to cart"); getAbsoluteUrlFromAnchorElement.and.returnValue("http://blah.com"); - findSupportedAnchorElement.and.returnValue(supportedLinkElement); + findClickableElement.and.returnValue(supportedLinkElement); determineLinkType.and.returnValue("exit"); - const getLinkDetails = createGetLinkDetails({ + const getClickedElementProperties = createGetClickedElementProperties({ getLinkRegion, getLinkName, getAbsoluteUrlFromAnchorElement, - findSupportedAnchorElement, + findClickableElement, determineLinkType, window: mockWindow, }); - const result = getLinkDetails({ targetElement: {}, config, logger }); - expect(result).toEqual({ + const result = getClickedElementProperties({ + clickedElement: {}, + config, + logger, + clickActivityStorage, + }); + // I have to set this manually because of passing in {} as the clickedElement + result.pageIDType = 0; + + expect(result.options).toEqual({ xdm: { eventType: "web.webinteraction.linkClicks", web: { @@ -85,6 +104,20 @@ describe("ActivityCollector::createGetLinkDetails", () => { }, }, data: { + __adobe: { + analytics: { + c: { + a: { + activitymap: { + page: "https://example.com/", + link: "Go to cart", + region: "root", + pageIDType: 0, + }, + }, + }, + }, + }, custom: "test data field", }, clickedElement: {}, @@ -96,27 +129,32 @@ describe("ActivityCollector::createGetLinkDetails", () => { onBeforeLinkClickSend: () => { return false; }, + clickCollection: { + externalLink: true, + }, }; getLinkRegion.and.returnValue("root"); getLinkName.and.returnValue("Go to cart"); getAbsoluteUrlFromAnchorElement.and.returnValue("http://blah.com"); - findSupportedAnchorElement.and.returnValue(supportedLinkElement); + findClickableElement.and.returnValue(supportedLinkElement); determineLinkType.and.returnValue("exit"); - const getLinkDetails = createGetLinkDetails({ + const getClickedElementProperties = createGetClickedElementProperties({ getLinkRegion, getLinkName, getAbsoluteUrlFromAnchorElement, - findSupportedAnchorElement, + findClickableElement, determineLinkType, window: mockWindow, }); - const result = getLinkDetails({ targetElement: {}, config, logger }); - expect(logger.info).toHaveBeenCalledWith( - "This link click event is not triggered because it was canceled in onBeforeLinkClickSend.", - ); - expect(result).toEqual(undefined); + const result = getClickedElementProperties({ + clickedElement: {}, + config, + logger, + clickActivityStorage, + }); + expect(result.options).toEqual(undefined); }); it("Returns undefined when not supported anchor element", () => { @@ -124,77 +162,115 @@ describe("ActivityCollector::createGetLinkDetails", () => { onBeforeLinkClickSend: () => { return true; }, + clickCollection: { + externalLink: true, + }, }; getLinkRegion.and.returnValue(undefined); getLinkName.and.returnValue("Go to cart"); getAbsoluteUrlFromAnchorElement.and.returnValue("http://blah.com"); - findSupportedAnchorElement.and.returnValue(undefined); + findClickableElement.and.returnValue(undefined); determineLinkType.and.returnValue("exit"); - const getLinkDetails = createGetLinkDetails({ + const getClickedElementProperties = createGetClickedElementProperties({ getLinkRegion, getLinkName, getAbsoluteUrlFromAnchorElement, - findSupportedAnchorElement, + findClickableElement, determineLinkType, window: mockWindow, }); - const result = getLinkDetails({ targetElement: {}, config, logger }); - expect(logger.info).toHaveBeenCalledWith( - "This link click event is not triggered because the HTML element is not an anchor.", - ); - expect(result).toEqual(undefined); + const result = getClickedElementProperties({ + clickedElement: {}, + config, + logger, + clickActivityStorage, + }); + expect(result.options).toEqual(undefined); }); - it("Returns undefined when element without url and logs a message", () => { + it("Returns only options with data element if clickable element is missing href", () => { const config = { onBeforeLinkClickSend: () => { return true; }, + clickCollection: { + externalLink: true, + }, }; getLinkRegion.and.returnValue("root"); getLinkName.and.returnValue("Go to cart"); getAbsoluteUrlFromAnchorElement.and.returnValue(undefined); - findSupportedAnchorElement.and.returnValue(supportedLinkElement); + findClickableElement.and.returnValue(supportedLinkElement); determineLinkType.and.returnValue("exit"); - const getLinkDetails = createGetLinkDetails({ + const getClickedElementProperties = createGetClickedElementProperties({ getLinkRegion, getLinkName, getAbsoluteUrlFromAnchorElement, - findSupportedAnchorElement, + findClickableElement, determineLinkType, window: mockWindow, }); - const result = getLinkDetails({ targetElement: {}, config, logger }); - expect(logger.info).toHaveBeenCalledWith( - "This link click event is not triggered because the HTML element doesn't have an URL.", - ); - expect(result).toEqual(undefined); + const result = getClickedElementProperties({ + clickedElement: {}, + config, + logger, + clickActivityStorage, + }); + // I have to set this manually because of passing in {} as the clickedElement + result.pageIDType = 0; + expect(result.options).toEqual({ + data: { + __adobe: { + analytics: { + c: { + a: { + activitymap: { + page: "https://example.com/", + link: "Go to cart", + region: "root", + pageIDType: 0, + }, + }, + }, + }, + }, + }, + clickedElement: {}, + }); }); - it("Returns the object when callback does not return explicit false ", () => { + it("Returns the object with link details when callback does not return explicit false ", () => { const config = { onBeforeLinkClickSend: () => {}, + clickCollection: { + externalLink: true, + }, }; getLinkRegion.and.returnValue("root"); getLinkName.and.returnValue("Go to cart"); getAbsoluteUrlFromAnchorElement.and.returnValue("http://blah.com"); - findSupportedAnchorElement.and.returnValue(supportedLinkElement); + findClickableElement.and.returnValue(supportedLinkElement); determineLinkType.and.returnValue("exit"); - const getLinkDetails = createGetLinkDetails({ + const getClickedElementProperties = createGetClickedElementProperties({ getLinkRegion, getLinkName, getAbsoluteUrlFromAnchorElement, - findSupportedAnchorElement, + findClickableElement, determineLinkType, window: mockWindow, }); - const result = getLinkDetails({ targetElement: {}, config, logger }); + const result = getClickedElementProperties({ + clickedElement: {}, + config, + logger, + clickActivityStorage, + }); expect(result).not.toBe(undefined); }); }); diff --git a/test/unit/specs/components/ActivityCollector/createInjectClickedElementProperties.spec.js b/test/unit/specs/components/ActivityCollector/createInjectClickedElementProperties.spec.js new file mode 100644 index 000000000..1822b115d --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/createInjectClickedElementProperties.spec.js @@ -0,0 +1,180 @@ +/* +Copyright 2019 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 createInjectClickedElementProperties from "../../../../../src/components/ActivityCollector/createInjectClickedElementProperties.js"; +import createEvent from "../../../../../src/core/createEvent.js"; +import { downloadLinkQualifier as dlwValidator } from "../../../../../src/components/ActivityCollector/configValidators.js"; + +describe("ActivityCollector::createInjectClickedElementProperties", () => { + const getClickedElementProperties = jasmine.createSpy( + "getClickedElementProperties", + ); + const clickActivityStorage = jasmine.createSpyObj("clickActivityStorage", [ + "save", + ]); + const downloadLinkQualifier = dlwValidator(); + + it("Extends event XDM data with link information for supported anchor elements when clickCollectionEnabled", () => { + const config = { + downloadLinkQualifier, + clickCollectionEnabled: true, + clickCollection: {}, + }; + const injectClickedElementProperties = createInjectClickedElementProperties( + { getClickedElementProperties, clickActivityStorage, config }, + ); + const event = createEvent(); + getClickedElementProperties.and.returnValue({ + xdm: { + web: { + webInteraction: { + name: "test1", + }, + }, + }, + data: {}, + isValidLink: () => true, + isInternalLink: () => false, + isValidActivityMapData: () => true, + }); + injectClickedElementProperties({ event, clickedElement: {} }); + expect(event.isEmpty()).toBe(false); + }); + + it("Does not extend event XDM data when clickCollectionEnabled is false", () => { + const event = createEvent(); + const config = { + downloadLinkQualifier, + clickCollectionEnabled: false, + }; + const injectClickedElementProperties = createInjectClickedElementProperties( + { + getClickedElementProperties, + config, + }, + ); + getClickedElementProperties.and.returnValue({ + xdm: { + web: { + webInteraction: { + name: "test1", + }, + }, + }, + data: {}, + }); + injectClickedElementProperties({ clickedElement: {}, event }); + expect(event.isEmpty()).toBe(true); + }); + + it("Does not extend event XDM data with link information for unsupported anchor elements", () => { + const event = createEvent(); + const config = { + downloadLinkQualifier, + clickCollectionEnabled: true, + clickCollection: {}, + }; + const injectClickedElementProperties = createInjectClickedElementProperties( + { + getClickedElementProperties, + clickActivityStorage, + config, + }, + ); + getClickedElementProperties.and.returnValue({ + data: {}, + isValidLink: () => false, + isInternalLink: () => false, + isValidActivityMapData: () => true, + }); + injectClickedElementProperties({ clickedElement: {}, event }); + expect(event.isEmpty()).toBe(true); + }); + + it("Does not save click data to storage if onBeforeLinkClickSend is defined", () => { + const config = { + clickCollectionEnabled: true, + clickCollection: { + internalLinkEnabled: true, + eventGroupingEnabled: true, + }, + onBeforeLinkClickSend: () => {}, + }; + const logger = jasmine.createSpyObj("logger", ["info"]); + getClickedElementProperties.and.returnValue({ + isValidLink: () => true, + isInternalLink: () => true, + pageName: "testPage", + pageIDType: 1, + linkName: "testLink", + linkType: "other", + }); + const injectClickedElementProperties = createInjectClickedElementProperties( + { + config, + logger, + getClickedElementProperties, + clickActivityStorage, + }, + ); + const event = jasmine.createSpyObj("event", ["mergeXdm", "setUserData"]); + injectClickedElementProperties({ clickedElement: {}, event }); + // No click data should be saved to storage, only the page data. + expect(clickActivityStorage.save).toHaveBeenCalledWith({ + pageName: "testPage", + pageIDType: 1, + }); + // If mergeXdm and setUserData are called, it means that the click data was not saved and + // will instead go out with the event. + expect(event.mergeXdm).toHaveBeenCalled(); + expect(event.setUserData).toHaveBeenCalled(); + }); + + it("Does not save click data to storage if onBeforeLinkClickSend is defined", () => { + const config = { + clickCollectionEnabled: true, + clickCollection: { + internalLinkEnabled: true, + eventGroupingEnabled: true, + }, + onBeforeLinkClickSend: () => {}, + }; + const logger = jasmine.createSpyObj("logger", ["info"]); + getClickedElementProperties.and.returnValue({ + isValidLink: () => true, + isInternalLink: () => true, + pageName: "testPage", + pageIDType: 1, + linkName: "testLink", + linkType: "other", + }); + const injectClickedElementProperties = createInjectClickedElementProperties( + { + config, + logger, + getClickedElementProperties, + clickActivityStorage, + }, + ); + const event = jasmine.createSpyObj("event", ["mergeXdm", "setUserData"]); + injectClickedElementProperties({ clickedElement: {}, event }); + // No click data should be saved to storage, only the page data. + expect(clickActivityStorage.save).toHaveBeenCalledWith({ + pageName: "testPage", + pageIDType: 1, + }); + // If mergeXdm and setUserData are called, it means that the click data was not saved and + // will instead go out with the event. + expect(event.mergeXdm).toHaveBeenCalled(); + expect(event.setUserData).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/createLinkClick.spec.js b/test/unit/specs/components/ActivityCollector/createLinkClick.spec.js deleted file mode 100644 index faba303a9..000000000 --- a/test/unit/specs/components/ActivityCollector/createLinkClick.spec.js +++ /dev/null @@ -1,77 +0,0 @@ -/* -Copyright 2019 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 createLinkClick from "../../../../../src/components/ActivityCollector/createLinkClick.js"; -import createEvent from "../../../../../src/core/createEvent.js"; -import { downloadLinkQualifier as dlwValidator } from "../../../../../src/components/ActivityCollector/configValidators.js"; - -describe("ActivityCollector::createLinkClick", () => { - const getLinkDetails = jasmine.createSpy("getLinkDetails"); - const downloadLinkQualifier = dlwValidator(); - - it("Extends event XDM data with link information for supported anchor elements when clickCollectionEnabled", () => { - const config = { - downloadLinkQualifier, - clickCollectionEnabled: true, - }; - const linkClick = createLinkClick({ getLinkDetails, config }); - - const event = createEvent(); - getLinkDetails.and.returnValue({ - xdm: { - web: { - webInteraction: { - name: "test1", - }, - }, - }, - data: {}, - }); - linkClick({ targetElement: {}, event }); - expect(event.isEmpty()).toBe(false); - }); - it("does not extend event XDM data when clickCollectionEnabled is false", () => { - const event = createEvent(); - const config = { - downloadLinkQualifier, - clickCollectionEnabled: false, - }; - - const linkClick = createLinkClick({ getLinkDetails, config }); - - getLinkDetails.and.returnValue({ - xdm: { - web: { - webInteraction: { - name: "test1", - }, - }, - }, - data: {}, - }); - linkClick({ targetElement: {}, event }); - expect(event.isEmpty()).toBe(true); - }); - it("Does not extend event XDM data with link information for unsupported anchor elements", () => { - const event = createEvent(); - const config = { - downloadLinkQualifier, - clickCollectionEnabled: true, - }; - - const linkClick = createLinkClick({ getLinkDetails, config }); - - getLinkDetails.and.returnValue(undefined); - linkClick({ targetElement: {}, event }); - expect(event.isEmpty()).toBe(true); - }); -}); diff --git a/test/unit/specs/components/ActivityCollector/createRecallAndInjectClickedElementProperties.spec.js b/test/unit/specs/components/ActivityCollector/createRecallAndInjectClickedElementProperties.spec.js new file mode 100644 index 000000000..79d711b5a --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/createRecallAndInjectClickedElementProperties.spec.js @@ -0,0 +1,84 @@ +/* +Copyright 2024 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 createRecallAndInjectClickedElementProperties from "../../../../../src/components/ActivityCollector/createRecallAndInjectClickedElementProperties.js"; + +describe("ActivityCollector::createRecallAndInjectClickedElementProperties", () => { + let props; + let clickActivityStorage; + let event; + + beforeEach(() => { + props = {}; + clickActivityStorage = { + load: jasmine.createSpy().and.returnValue(props), + save: jasmine.createSpy(), + }; + event = { + mergeXdm: jasmine.createSpy(), + setUserData: jasmine.createSpy(), + }; + }); + + it("should return a function", () => { + const recallAndInjectClickedElementProperties = + createRecallAndInjectClickedElementProperties({ clickActivityStorage }); + expect(recallAndInjectClickedElementProperties).toEqual( + jasmine.any(Function), + ); + }); + + it("should merge stored clicked element properties to event XDM and DATA", () => { + const recallClickElementProperties = + createRecallAndInjectClickedElementProperties({ clickActivityStorage }); + props.pageName = "examplePage"; + props.linkName = "example"; + props.linkRegion = "exampleRegion"; + props.linkType = "external"; + props.linkUrl = "https://example.com"; + props.pageIDType = 1; + recallClickElementProperties(event); + expect(event.mergeXdm).toHaveBeenCalledWith({ + web: { + webInteraction: { + name: "example", + region: "exampleRegion", + type: "external", + URL: "https://example.com", + linkClicks: { + value: 1, + }, + }, + }, + }); + expect(event.setUserData).toHaveBeenCalledWith({ + __adobe: { + analytics: { + c: { + a: { + activitymap: { + page: "examplePage", + link: "example", + region: "exampleRegion", + pageIDType: 1, + }, + }, + }, + }, + }, + }); + expect(clickActivityStorage.save).toHaveBeenCalledWith({ + pageName: "examplePage", + pageIDType: 1, + }); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/createStorePageViewProperties.spec.js b/test/unit/specs/components/ActivityCollector/createStorePageViewProperties.spec.js new file mode 100644 index 000000000..fac787e2e --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/createStorePageViewProperties.spec.js @@ -0,0 +1,50 @@ +/* +Copyright 2024 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 createStorePageViewProperties from "../../../../../src/components/ActivityCollector/createStorePageViewProperties.js"; + +describe("ActivityCollector::createStorePageViewProperties", () => { + let clickActivityStorage; + beforeEach(() => { + clickActivityStorage = { + save: jasmine.createSpy(), + }; + }); + + it("should return a function", () => { + const storePageViewProperties = createStorePageViewProperties({ + clickActivityStorage, + }); + expect(storePageViewProperties).toEqual(jasmine.any(Function)); + }); + + it("stores page view properties when available in event", () => { + const storePageViewProperties = createStorePageViewProperties({ + clickActivityStorage, + }); + storePageViewProperties({ + getContent: () => ({ + xdm: { + web: { + webPageDetails: { + name: "testPageName", + }, + }, + }, + }), + }); + expect(clickActivityStorage.save).toHaveBeenCalledWith({ + pageName: "testPageName", + pageIDType: 1, + }); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils.spec.js b/test/unit/specs/components/ActivityCollector/utils.spec.js deleted file mode 100644 index ad85518fb..000000000 --- a/test/unit/specs/components/ActivityCollector/utils.spec.js +++ /dev/null @@ -1,211 +0,0 @@ -/* -Copyright 2019 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 { - urlStartsWithScheme, - getAbsoluteUrlFromAnchorElement, - isSupportedAnchorElement, - isDownloadLink, - isExitLink, - trimQueryFromUrl, -} from "../../../../../src/components/ActivityCollector/utils.js"; -import { downloadLinkQualifier } from "../../../../../src/components/ActivityCollector/configValidators.js"; - -const initAnchorState = (window, element, anchorState) => { - element.href = anchorState["element.href"]; - element.protocol = anchorState["element.protocol"]; - element.host = anchorState["element.host"]; - window.location.protocol = anchorState["window.location.protocol"]; - window.location.host = anchorState["window.location.host"]; - window.location.pathname = anchorState["window.location.pathname"]; -}; - -describe("ActivityCollector::utils", () => { - describe("urlStartsWithScheme", () => { - it("Returns true for URLs that starts with a scheme", () => { - const urlsThatStartsWithScheme = [ - "http://example.com", - "https://example.com", - "https://example.com:123/example?example=123", - "file://example.txt", - ]; - urlsThatStartsWithScheme.forEach((url) => { - expect(urlStartsWithScheme(url)).toBe(true); - }); - }); - it("Returns false for URLs that does not start with a scheme", () => { - const urlsThatDoesNotStartWithScheme = [ - "example.com", - "example.txt/http://example", - "https:", - "//example.html", - ]; - urlsThatDoesNotStartWithScheme.forEach((url) => { - expect(urlStartsWithScheme(url)).toBe(false); - }); - }); - }); - describe("getAbsoluteUrlFromAnchorElement", () => { - it("Makes best attempt to constructs absolute URLs", () => { - const window = { - location: { - protocol: "", - host: "", - pathname: "", - }, - }; - const element = { - protocol: "", - host: "", - }; - const anchorStates = [ - { - "element.href": "http://example.com/example.html", - "element.protocol": "", - "element.host": "", - "window.location.protocol": "http:", - "window.location.host": "example.com", - "window.location.pathname": "/", - expectedResult: "http://example.com/example.html", - }, - { - "element.href": "example.html", - "element.protocol": "", - "element.host": "", - "window.location.protocol": "https:", - "window.location.host": "example.com", - "window.location.pathname": "/", - expectedResult: "https://example.com/example.html", - }, - ]; - anchorStates.forEach((anchorState) => { - initAnchorState(window, element, anchorState); - expect(getAbsoluteUrlFromAnchorElement(window, element)).toBe( - anchorState.expectedResult, - ); - }); - }); - }); - describe("isSupportedAnchorElement", () => { - it("Returns true for supported anchor elements", () => { - const validAnchorElements = [ - { - href: "http://example.com", - tagName: "A", - }, - { - href: "http://example.com", - tagName: "AREA", - }, - ]; - validAnchorElements.forEach((element) => { - expect(isSupportedAnchorElement(element)).toBe(true); - }); - }); - it("Returns false for unsupported anchor elements", () => { - const invalidAnchorElements = [ - {}, - { - href: "", - }, - { - href: "http://example.com", - }, - { - href: "http://example.com", - tagName: "LINK", - }, - { - href: "http://example.com", - tagName: "A", - onclick: "example();", - protocol: " javascript:", - }, - ]; - invalidAnchorElements.forEach((element) => { - expect(isSupportedAnchorElement(element)).toBe(false); - }); - }); - }); - describe("isDownloadLink", () => { - it("Returns true if the clicked element has a download attribute", () => { - const clickedElement = { - download: "filename", - }; - expect(isDownloadLink("", "", clickedElement)).toBe(true); - }); - it("Returns true if the link matches the download link qualifying regular expression", () => { - const downloadLinks = [ - "download.pdf", - "http://example.com/download.zip", - "https://example.com/download.docx", - ]; - // this runs the validator with undefined input which returns the default regex - downloadLinks.forEach((downloadLink) => { - expect(isDownloadLink(downloadLinkQualifier(), downloadLink, {})).toBe( - true, - ); - }); - }); - it("Returns false if the link does not match the download link qualifying regular expression", () => { - const downloadLinks = ["download.mod", "http://example.com/download.png"]; - downloadLinks.forEach((downloadLink) => { - expect(isDownloadLink(downloadLinkQualifier(), downloadLink, {})).toBe( - false, - ); - }); - }); - }); - describe("isExitLink", () => { - it("Returns true if the link leads away from the current hostname", () => { - const mockWindow = { - location: { - hostname: "adobe.com", - }, - }; - const clickedLinks = [ - "https://example.com", - "http://example.com/index.html", - ]; - clickedLinks.forEach((clickedLink) => { - expect(isExitLink(mockWindow, clickedLink)).toBe(true); - }); - }); - it("Returns false if the link leads to the current hostname", () => { - const mockWindow = { - location: { - hostname: "adobe.com", - }, - }; - const clickedLinks = ["https://adobe.com", "http://adobe.com/index.html"]; - clickedLinks.forEach((clickedLink) => { - expect(isExitLink(mockWindow, clickedLink)).toBe(false); - }); - }); - }); - describe("trimQueryFromUrl", () => { - it("Removes query portion from URL", () => { - const urls = [ - ["http://example.com", "http://example.com"], - [ - "https://example.com:123/example?example=123", - "https://example.com:123/example", - ], - ["file://example.txt", "file://example.txt"], - ["http://example.com/?example=123", "http://example.com/"], - ["http://example.com/#example", "http://example.com/"], - ]; - urls.forEach((url) => { - expect(trimQueryFromUrl(url[0])).toBe(url[1]); - }); - }); - }); -}); diff --git a/test/unit/specs/components/ActivityCollector/utils/activityMapExtensionEnabled.spec.js b/test/unit/specs/components/ActivityCollector/utils/activityMapExtensionEnabled.spec.js new file mode 100644 index 000000000..9b35a233c --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/activityMapExtensionEnabled.spec.js @@ -0,0 +1,37 @@ +/* +Copyright 2024 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 activityMapExtensionEnabled from "../../../../../../src/components/ActivityCollector/utils/activityMapExtensionEnabled.js"; + +const ACTIVITY_MAP_EXTENSION_ID = "cppXYctnr"; + +describe("ActivityCollector::activityMapExtensionEnabled", () => { + it("should return true if the activity map extension is enabled", () => { + const context = { + getElementById: jasmine.createSpy().and.returnValue({}), + }; + expect(activityMapExtensionEnabled(context)).toBeTrue(); + expect(context.getElementById).toHaveBeenCalledWith( + ACTIVITY_MAP_EXTENSION_ID, + ); + }); + + it("should return false if the activity map extension is not enabled", () => { + const context = { + getElementById: jasmine.createSpy().and.returnValue(null), + }; + expect(activityMapExtensionEnabled(context)).toBeFalse(); + expect(context.getElementById).toHaveBeenCalledWith( + ACTIVITY_MAP_EXTENSION_ID, + ); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/createTransientStorage.spec.js b/test/unit/specs/components/ActivityCollector/utils/createTransientStorage.spec.js new file mode 100644 index 000000000..0010f8576 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/createTransientStorage.spec.js @@ -0,0 +1,41 @@ +/* +Copyright 2024 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 createTransientStorage from "../../../../../../src/components/ActivityCollector/utils/createTransientStorage.js"; + +describe("ActivityCollector::createTransientStorage", () => { + it("should return an object with the expected methods", () => { + const transientStorage = createTransientStorage(window); + expect(transientStorage).toEqual({ + setItem: jasmine.any(Function), + getItem: jasmine.any(Function), + removeItem: jasmine.any(Function), + }); + }); + + it("should support storing and retrieving values", () => { + const transientStorage = createTransientStorage(window); + transientStorage.setItem("key1", "value1"); + transientStorage.setItem("key2", "value2"); + expect(transientStorage.getItem("key1")).toBe("value1"); + expect(transientStorage.getItem("key2")).toBe("value2"); + }); + + it("should support removing values", () => { + const transientStorage = createTransientStorage(window); + transientStorage.setItem("key1", "value1"); + transientStorage.setItem("key2", "value2"); + transientStorage.removeItem("key1"); + expect(transientStorage.getItem("key1")).toBeFalsy(); + expect(transientStorage.getItem("key2")).toBe("value2"); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/determineLinkType.spec.js b/test/unit/specs/components/ActivityCollector/utils/determineLinkType.spec.js new file mode 100644 index 000000000..a1d5926fc --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/determineLinkType.spec.js @@ -0,0 +1,57 @@ +/* +Copyright 2024 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 determineLinkType from "../../../../../../src/components/ActivityCollector/utils/determineLinkType.js"; + +describe("ActivityCollector::determineLinkType", () => { + let window; + let config; + let linkUrl; + let clickedObj; + + beforeEach(() => { + window = {}; + config = {}; + linkUrl = ""; + clickedObj = {}; + }); + + it("returns 'other' if linkUrl is an empty string", () => { + const result = determineLinkType(window, config, linkUrl, clickedObj); + expect(result).toBe("other"); + }); + + it("returns 'download' if linkUrl qualify as download link", () => { + linkUrl = "https://example.com/download.pdf"; + config.downloadLinkQualifier = /\.pdf$/; + const result = determineLinkType(window, config, linkUrl, clickedObj); + expect(result).toBe("download"); + }); + + it("returns 'exit' if linkUrl is an exit link", () => { + linkUrl = "https://adobe.com"; + window.location = { + hostname: "example.com", + }; + const result = determineLinkType(window, config, linkUrl, clickedObj); + expect(result).toBe("exit"); + }); + + it("returns 'other' if linkUrl is not a download or exit link", () => { + linkUrl = "https://example.com"; + window.location = { + hostname: "example.com", + }; + const result = determineLinkType(window, config, linkUrl, clickedObj); + expect(result).toBe("other"); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/dom/elementHasClickHandler.spec.js b/test/unit/specs/components/ActivityCollector/utils/dom/elementHasClickHandler.spec.js new file mode 100644 index 000000000..1dd04feb8 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/dom/elementHasClickHandler.spec.js @@ -0,0 +1,38 @@ +/* +Copyright 2024 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 elementHasClickHandler from "../../../../../../../src/components/ActivityCollector/utils/dom/elementHasClickHandler.js"; + +describe("ActivityCollector::elementHasClickHandler", () => { + it("should handle invalid elements", () => { + const invalidElements = [ + { element: null }, + { element: undefined }, + { element: {} }, + { element: { onclick: null } }, + { element: { onclick: undefined } }, + ]; + invalidElements.forEach(({ element }) => { + expect(elementHasClickHandler(element)).toBe(false); + }); + }); + + it("should handle elements with click handlers", () => { + const clickHandlerElements = [ + { element: { onclick: () => {} } }, + { element: { onclick() {} } }, + ]; + clickHandlerElements.forEach(({ element }) => { + expect(elementHasClickHandler(element)).toBe(true); + }); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/dom/extractDomain.spec.js b/test/unit/specs/components/ActivityCollector/utils/dom/extractDomain.spec.js new file mode 100644 index 000000000..7ed0ee0c6 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/dom/extractDomain.spec.js @@ -0,0 +1,32 @@ +/* +Copyright 2024 example. 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 extractDomain from "../../../../../../../src/components/ActivityCollector/utils/dom/extractDomain.js"; + +describe("ActivityCollector::extractDomain", () => { + it("should extract the domain from a URL", () => { + expect(extractDomain("www.example.com")).toBe("www.example.com"); + expect(extractDomain("http://www.example.com")).toBe("www.example.com"); + expect(extractDomain("https://www.example.com")).toBe("www.example.com"); + expect(extractDomain("https://www.example.com/")).toBe("www.example.com"); + expect(extractDomain("https://www.example.com/cool/page")).toBe( + "www.example.com", + ); + }); + + it("should handle URLs without a protocol", () => { + expect(extractDomain("example.com")).toBe("example.com"); + expect(extractDomain("www.example.com")).toBe("www.example.com"); + expect(extractDomain("www.example.com/")).toBe("www.example.com"); + expect(extractDomain("www.example.com/cool/page")).toBe("www.example.com"); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/dom/findClickableElement.spec.js b/test/unit/specs/components/ActivityCollector/utils/dom/findClickableElement.spec.js new file mode 100644 index 000000000..05683f423 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/dom/findClickableElement.spec.js @@ -0,0 +1,36 @@ +/* +Copyright 2024 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 findClickableElement from "../../../../../../../src/components/ActivityCollector/utils/dom/findClickableElement"; + +describe("ActivityCollector::findClickableElement", () => { + it("returns null if no clickable element is found", () => { + const element = document.createElement("div"); + const parentElement = document.createElement("div"); + parentElement.appendChild(element); + expect(findClickableElement(element)).toBeNull(); + }); + + it("returns the target element if it is clickable", () => { + const element = document.createElement("a"); + element.href = "http://www.example.com"; + expect(findClickableElement(element)).toBe(element); + }); + + it("returns the target element's parent if it is not clickable", () => { + const element = document.createElement("div"); + const parentElement = document.createElement("a"); + parentElement.href = "http://www.example.com"; + parentElement.appendChild(element); + expect(findClickableElement(element)).toBe(parentElement); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/dom/getAbsoluteUrlFromAnchorElement.spec.js b/test/unit/specs/components/ActivityCollector/utils/dom/getAbsoluteUrlFromAnchorElement.spec.js new file mode 100644 index 000000000..8796c4773 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/dom/getAbsoluteUrlFromAnchorElement.spec.js @@ -0,0 +1,64 @@ +/* +Copyright 2024 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 getAbsoluteUrlFromAnchorElement from "../../../../../../../src/components/ActivityCollector/utils/dom/getAbsoluteUrlFromAnchorElement.js"; + +const initAnchorState = (window, element, anchorState) => { + element.href = anchorState["element.href"]; + element.protocol = anchorState["element.protocol"]; + element.host = anchorState["element.host"]; + window.location.protocol = anchorState["window.location.protocol"]; + window.location.host = anchorState["window.location.host"]; + window.location.pathname = anchorState["window.location.pathname"]; +}; + +describe("ActivityCollector::getAbsoluteUrlFromAnchorElement", () => { + it("Makes best attempt to constructs absolute URLs", () => { + const window = { + location: { + protocol: "", + host: "", + pathname: "", + }, + }; + const element = { + protocol: "", + host: "", + }; + const anchorStates = [ + { + "element.href": "http://example.com/example.html", + "element.protocol": "", + "element.host": "", + "window.location.protocol": "http:", + "window.location.host": "example.com", + "window.location.pathname": "/", + expectedResult: "http://example.com/example.html", + }, + { + "element.href": "example.html", + "element.protocol": "", + "element.host": "", + "window.location.protocol": "https:", + "window.location.host": "example.com", + "window.location.pathname": "/", + expectedResult: "https://example.com/example.html", + }, + ]; + anchorStates.forEach((anchorState) => { + initAnchorState(window, element, anchorState); + expect(getAbsoluteUrlFromAnchorElement(window, element)).toBe( + anchorState.expectedResult, + ); + }); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/dom/isButtonSubmitElement.spec.js b/test/unit/specs/components/ActivityCollector/utils/dom/isButtonSubmitElement.spec.js new file mode 100644 index 000000000..09f7b4499 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/dom/isButtonSubmitElement.spec.js @@ -0,0 +1,44 @@ +/* +Copyright 2024 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 isButtonSubmitElement from "../../../../../../../src/components/ActivityCollector/utils/dom/isButtonSubmitElement"; + +describe("ActivityCollector::isButtonSubmitElement", () => { + it("should return true for submit button", () => { + const button = document.createElement("button"); + button.type = "submit"; + expect(isButtonSubmitElement(button)).toBe(true); + }); + + it("should return true for button with no type", () => { + // This is the default type for a button element + const button = document.createElement("button"); + expect(isButtonSubmitElement(button)).toBe(true); + }); + + it("should return false for non-submit button", () => { + const button = document.createElement("button"); + button.type = "button"; + expect(isButtonSubmitElement(button)).toBe(false); + }); + + it("should return false for input element", () => { + const input = document.createElement("input"); + input.type = "submit"; + expect(isButtonSubmitElement(input)).toBe(false); + }); + + it("should return false for non-button element", () => { + const div = document.createElement("div"); + expect(isButtonSubmitElement(div)).toBe(false); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/dom/isDownloadLink.spec.js b/test/unit/specs/components/ActivityCollector/utils/dom/isDownloadLink.spec.js new file mode 100644 index 000000000..d597fa7e8 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/dom/isDownloadLink.spec.js @@ -0,0 +1,46 @@ +/* +Copyright 2024 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 isDownloadLink from "../../../../../../../src/components/ActivityCollector/utils/dom/isDownloadLink.js"; +import { downloadLinkQualifier } from "../../../../../../../src/components/ActivityCollector/configValidators.js"; + +describe("ActivityCollector::isDownloadLink", () => { + it("Returns true if the clicked element has a download attribute", () => { + const clickedElement = { + download: "filename", + }; + expect(isDownloadLink(null, "https://example.com/", clickedElement)).toBe( + true, + ); + }); + it("Returns true if the link matches the download link qualifying regular expression", () => { + const downloadLinks = [ + "download.pdf", + "http://example.com/download.zip", + "https://example.com/download.docx", + ]; + // this runs the validator with undefined input which returns the default regex + downloadLinks.forEach((downloadLink) => { + expect(isDownloadLink(downloadLinkQualifier(), downloadLink, {})).toBe( + true, + ); + }); + }); + it("Returns false if the link does not match the download link qualifying regular expression", () => { + const downloadLinks = ["download.mod", "http://example.com/download.png"]; + downloadLinks.forEach((downloadLink) => { + expect(isDownloadLink(downloadLinkQualifier(), downloadLink, {})).toBe( + false, + ); + }); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/dom/isExitLink.spec.js b/test/unit/specs/components/ActivityCollector/utils/dom/isExitLink.spec.js new file mode 100644 index 000000000..19e3029d1 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/dom/isExitLink.spec.js @@ -0,0 +1,41 @@ +/* +Copyright 2024 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 isExitLink from "../../../../../../../src/components/ActivityCollector/utils/dom/isExitLink.js"; + +describe("ActivityCollector::isExitLink", () => { + it("Returns true if the link leads away from the current hostname", () => { + const mockWindow = { + location: { + hostname: "adobe.com", + }, + }; + const clickedLinks = [ + "https://example.com", + "http://example.com/index.html", + ]; + clickedLinks.forEach((clickedLink) => { + expect(isExitLink(mockWindow, clickedLink)).toBe(true); + }); + }); + it("Returns false if the link leads to the current hostname", () => { + const mockWindow = { + location: { + hostname: "adobe.com", + }, + }; + const clickedLinks = ["https://adobe.com", "http://adobe.com/index.html"]; + clickedLinks.forEach((clickedLink) => { + expect(isExitLink(mockWindow, clickedLink)).toBe(false); + }); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/dom/isInputSubmitElement.spec.js b/test/unit/specs/components/ActivityCollector/utils/dom/isInputSubmitElement.spec.js new file mode 100644 index 000000000..a5ab1157a --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/dom/isInputSubmitElement.spec.js @@ -0,0 +1,39 @@ +/* +Copyright 2024 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 isInputSubmitElement from "../../../../../../../src/components/ActivityCollector/utils/dom/isInputSubmitElement"; + +describe("ActivityCollector::isInputSubmitElement", () => { + it("should return true for submit input", () => { + const input = document.createElement("input"); + input.type = "submit"; + expect(isInputSubmitElement(input)).toBe(true); + }); + + it("should return true for image input", () => { + const input = document.createElement("input"); + input.type = "image"; + input.src = "https://example.com/image.png"; + expect(isInputSubmitElement(input)).toBe(true); + }); + + it("should return false for non-submit input", () => { + const input = document.createElement("input"); + input.type = "text"; + expect(isInputSubmitElement(input)).toBe(false); + }); + + it("should return false for non-input element", () => { + const div = document.createElement("div"); + expect(isInputSubmitElement(div)).toBe(false); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/dom/isSupportedAnchorElement.spec.js b/test/unit/specs/components/ActivityCollector/utils/dom/isSupportedAnchorElement.spec.js new file mode 100644 index 000000000..19136fae0 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/dom/isSupportedAnchorElement.spec.js @@ -0,0 +1,55 @@ +/* +Copyright 2024 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 isSupportedAnchorElement from "../../../../../../../src/components/ActivityCollector/utils/dom/isSupportedAnchorElement.js"; + +describe("ActivityCollector::isSupportedAnchorElement", () => { + it("Returns true for supported anchor elements", () => { + const validAnchorElements = [ + { + href: "http://example.com", + tagName: "A", + }, + { + href: "http://example.com", + tagName: "AREA", + }, + ]; + validAnchorElements.forEach((element) => { + expect(isSupportedAnchorElement(element)).toBe(true); + }); + }); + it("Returns false for unsupported anchor elements", () => { + const invalidAnchorElements = [ + {}, + { + href: "", + }, + { + href: "http://example.com", + }, + { + href: "http://example.com", + tagName: "LINK", + }, + { + href: "http://example.com", + tagName: "A", + onclick: "example();", + protocol: " javascript:", + }, + ]; + invalidAnchorElements.forEach((element) => { + expect(isSupportedAnchorElement(element)).toBe(false); + }); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/dom/isSupportedTextNode.spec.js b/test/unit/specs/components/ActivityCollector/utils/dom/isSupportedTextNode.spec.js new file mode 100644 index 000000000..dfee60a8d --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/dom/isSupportedTextNode.spec.js @@ -0,0 +1,45 @@ +/* +Copyright 2024 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 isSupportedTextNode from "../../../../../../../src/components/ActivityCollector/utils/dom/isSupportedTextNode"; + +describe("ActivityCollector::isSupportedTextNode", () => { + it("should return true for text node", () => { + const textNode = document.createTextNode("text"); + expect(isSupportedTextNode(textNode)).toBe(true); + }); + + it("should return false for comment node", () => { + const commentNode = document.createComment("comment"); + expect(isSupportedTextNode(commentNode)).toBe(false); + }); + + it("should return true for a paragraph node", () => { + const paragraphNode = document.createElement("p"); + expect(isSupportedTextNode(paragraphNode)).toBe(true); + }); + + it("should return false for a script node", () => { + const scriptNode = document.createElement("script"); + expect(isSupportedTextNode(scriptNode)).toBe(false); + }); + + it("should return false for a style node", () => { + const styleNode = document.createElement("style"); + expect(isSupportedTextNode(styleNode)).toBe(false); + }); + + it("should return false for a link node", () => { + const linkNode = document.createElement("link"); + expect(isSupportedTextNode(linkNode)).toBe(false); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/hasPageName.spec.js b/test/unit/specs/components/ActivityCollector/utils/hasPageName.spec.js new file mode 100644 index 000000000..ae535ebbb --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/hasPageName.spec.js @@ -0,0 +1,41 @@ +/* +Copyright 2024 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 hasPageName from "../../../../../../src/components/ActivityCollector/utils/hasPageName.js"; + +describe("ActivityCollector::hasPageName", () => { + it("should return true if event has page name", () => { + const event = { + getContent: () => ({ + xdm: { + web: { + webPageDetails: { + name: "test", + }, + }, + }, + }), + }; + expect(hasPageName(event)).toBe(true); + }); + + it("should return false if event does not have page name", () => { + const event = { + getContent: () => ({ + xdm: { + web: {}, + }, + }), + }; + expect(hasPageName(event)).toBe(false); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/isDifferentDomains.spec.js b/test/unit/specs/components/ActivityCollector/utils/isDifferentDomains.spec.js new file mode 100644 index 000000000..94a8470f6 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/isDifferentDomains.spec.js @@ -0,0 +1,28 @@ +/* +Copyright 2024 example. 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 isDifferentDomains from "../../../../../../src/components/ActivityCollector/utils/isDifferentDomains.js"; + +describe("ActivityCollector::isDifferentDomains", () => { + it("should return true if the domains are different", () => { + expect(isDifferentDomains("www.example.com", "www.example.org")).toBe(true); + }); + + it("should return false if the domains are the same", () => { + expect( + isDifferentDomains("https://www.example.com", "www.example.com"), + ).toBe(false); + expect(isDifferentDomains("www.example.com", "www.example.com")).toBe( + false, + ); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/trimQueryFromUrl.spec.js b/test/unit/specs/components/ActivityCollector/utils/trimQueryFromUrl.spec.js new file mode 100644 index 000000000..01198eb9b --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/trimQueryFromUrl.spec.js @@ -0,0 +1,31 @@ +/* +Copyright 2024 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 trimQueryFromUrl from "../../../../../../src/components/ActivityCollector/utils/trimQueryFromUrl.js"; + +describe("ActivityCollector::trimQueryFromUrl", () => { + it("Removes query portion from URL", () => { + const urls = [ + ["http://example.com", "http://example.com"], + [ + "https://example.com:123/example?example=123", + "https://example.com:123/example", + ], + ["file://example.txt", "file://example.txt"], + ["http://example.com/?example=123", "http://example.com/"], + ["http://example.com/#example", "http://example.com/"], + ]; + urls.forEach((url) => { + expect(trimQueryFromUrl(url[0])).toBe(url[1]); + }); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/truncateWhiteSpace.spec.js b/test/unit/specs/components/ActivityCollector/utils/truncateWhiteSpace.spec.js new file mode 100644 index 000000000..ba82f9554 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/truncateWhiteSpace.spec.js @@ -0,0 +1,31 @@ +/* +Copyright 2024 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 truncateWhiteSpace from "../../../../../../src/components/ActivityCollector/utils/truncateWhiteSpace.js"; + +describe("ActivityCollector::truncateWhiteSpace", () => { + it("it trims leading and trailing white spaces and limits contained white space to one character", () => { + const testCases = [ + [" hello world ", "hello world"], + [" hello world ", "hello world"], + ["hello world", "hello world"], + ["hello world ", "hello world"], + [" hello world", "hello world"], + ["", ""], + [" ", ""], + [" ", ""], + ]; + testCases.forEach((testCase) => { + expect(truncateWhiteSpace(testCase[0])).toBe(testCase[1]); + }); + }); +}); diff --git a/test/unit/specs/components/ActivityCollector/utils/urlStartsWithScheme.spec.js b/test/unit/specs/components/ActivityCollector/utils/urlStartsWithScheme.spec.js new file mode 100644 index 000000000..43943bf06 --- /dev/null +++ b/test/unit/specs/components/ActivityCollector/utils/urlStartsWithScheme.spec.js @@ -0,0 +1,41 @@ +/* +Copyright 2024 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 urlStartsWithScheme from "../../../../../../src/components/ActivityCollector/utils/urlStartsWithScheme.js"; + +describe("ActivityCollector::urlStartsWithScheme", () => { + it("Returns true for URLs that starts with a scheme", () => { + const urlsThatStartsWithScheme = [ + "http://example.com", + "https://example.com", + "https://example.com:123/example?example=123", + "file://example.txt", + ]; + urlsThatStartsWithScheme.forEach((url) => { + expect(urlStartsWithScheme(url)).toBe(true); + }); + }); + it("Returns false for URLs that does not start with a scheme", () => { + const urlsThatDoesNotStartWithScheme = [ + "example.com", + "example.txt/http://example", + "https:", + "//example.html", + "", + null, + undefined, + ]; + urlsThatDoesNotStartWithScheme.forEach((url) => { + expect(urlStartsWithScheme(url)).toBe(false); + }); + }); +}); diff --git a/test/unit/specs/utils/validation/createDeprecatedValidator.spec.js b/test/unit/specs/utils/validation/createDeprecatedValidator.spec.js index 47c11e568..cc3484964 100644 --- a/test/unit/specs/utils/validation/createDeprecatedValidator.spec.js +++ b/test/unit/specs/utils/validation/createDeprecatedValidator.spec.js @@ -1,5 +1,5 @@ /* -Copyright 2019 Adobe. All rights reserved. +Copyright 2024 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 @@ -10,42 +10,56 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import { objectOf, string } from "../../../../../src/utils/validation/index.js"; +import { + objectOf, + callback, + string, + boolean, +} from "../../../../../src/utils/validation/index.js"; import describeValidation from "../../../helpers/describeValidation.js"; describe("validation::deprecated", () => { - const testCases = [ - { value: { old: "a", new: "a" }, expected: { new: "a" }, warning: true }, - { value: { old: "a" }, expected: { new: "a" }, warning: true }, - { value: { new: "a" } }, - { value: { old: "a", new: "b" }, error: true }, - { value: "foo", error: true }, - { value: 1, error: true }, - { value: undefined }, - ]; + describeValidation( + "works for a string field", + objectOf({ + old: string().deprecated(), + new: string(), + }), + [ + { value: { old: "a" }, expected: { old: "a" }, warning: true }, + { value: {}, expected: {}, warning: false }, + { value: { new: "b" }, expected: { new: "b" }, warning: false }, + ], + ); describeValidation( - "works for a single deprecated field", + "works for a boolean field", objectOf({ - new: string().required(), - }).deprecated("old", string(), "new"), - testCases, + old: boolean().deprecated(), + new: boolean(), + }), + [ + { value: { old: true }, expected: { old: true }, warning: true }, + { value: {}, expected: {}, warning: false }, + { value: { new: false }, expected: { new: false }, warning: false }, + ], ); + const noop = () => undefined; describeValidation( - "works for multiple deprecated fields", + "works for a callback field", objectOf({ - new1: string().required(), - new2: string().required(), - }) - .deprecated("old1", string(), "new1") - .deprecated("old2", string(), "new2"), + old: callback().deprecated(), + new: callback(), + }), [ { - value: { old1: "a", old2: "b" }, - expected: { new1: "a", new2: "b" }, + value: { old: noop, new: noop }, + expected: { old: noop, new: noop }, warning: true, }, + { value: {}, expected: {}, warning: false }, + { value: { new: noop }, expected: { new: noop }, warning: false }, ], ); }); diff --git a/test/unit/specs/utils/validation/createRenamedValidator.spec.js b/test/unit/specs/utils/validation/createRenamedValidator.spec.js new file mode 100644 index 000000000..2a5918573 --- /dev/null +++ b/test/unit/specs/utils/validation/createRenamedValidator.spec.js @@ -0,0 +1,51 @@ +/* +Copyright 2019 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 { objectOf, string } from "../../../../../src/utils/validation/index.js"; +import describeValidation from "../../../helpers/describeValidation.js"; + +describe("validation::renamed", () => { + const testCases = [ + { value: { old: "a", new: "a" }, expected: { new: "a" }, warning: true }, + { value: { old: "a" }, expected: { new: "a" }, warning: true }, + { value: { new: "a" } }, + { value: { old: "a", new: "b" }, error: true }, + { value: "foo", error: true }, + { value: 1, error: true }, + { value: undefined }, + ]; + + describeValidation( + "works for a single deprecated field", + objectOf({ + new: string().required(), + }).renamed("old", string(), "new"), + testCases, + ); + + describeValidation( + "works for multiple deprecated fields", + objectOf({ + new1: string().required(), + new2: string().required(), + }) + .renamed("old1", string(), "new1") + .renamed("old2", string(), "new2"), + [ + { + value: { old1: "a", old2: "b" }, + expected: { new1: "a", new2: "b" }, + warning: true, + }, + ], + ); +});