diff --git a/rollup.test.config.js b/rollup.test.config.js index 79ae3600b..33abab56f 100644 --- a/rollup.test.config.js +++ b/rollup.test.config.js @@ -48,6 +48,7 @@ if (argv.reporters && argv.reporters.split(",").includes("coverage")) { module.exports = { output: { + sourcemap: true, format: "iife", // Allow non-IE browsers and IE11 // document.documentMode was added in IE8, and is specific to IE. diff --git a/src/components/Personalization/createApplyPropositions.js b/src/components/Personalization/createApplyPropositions.js index dfc840a6d..15b00afb1 100644 --- a/src/components/Personalization/createApplyPropositions.js +++ b/src/components/Personalization/createApplyPropositions.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 composePersonalizationResultingObject from "./utils/composePersonalizationResultingObject"; import { isNonEmptyArray, isObject } from "../../utils"; import { DOM_ACTION, HTML_CONTENT_ITEM } from "./constants/schema"; import PAGE_WIDE_SCOPE from "../../constants/pageWideScope"; -import { EMPTY_PROPOSITIONS } from "./validateApplyPropositionsOptions"; +import { + buildReturnedPropositions, + createProposition +} from "./handlers/proposition"; -export const SUPPORTED_SCHEMAS = [DOM_ACTION, HTML_CONTENT_ITEM]; +const SUPPORTED_SCHEMAS = [DOM_ACTION, HTML_CONTENT_ITEM]; -export default ({ executeDecisions }) => { +export default ({ render }) => { const filterItemsPredicate = item => SUPPORTED_SCHEMAS.indexOf(item.schema) > -1; @@ -71,20 +73,16 @@ export default ({ executeDecisions }) => { .filter(proposition => isNonEmptyArray(proposition.items)); }; - const applyPropositions = ({ propositions, metadata }) => { + return ({ propositions, metadata = {} }) => { const propositionsToExecute = preparePropositions({ propositions, metadata - }); - return executeDecisions(propositionsToExecute).then(() => { - return composePersonalizationResultingObject(propositionsToExecute, true); - }); - }; + }).map(proposition => createProposition(proposition, true)); - return ({ propositions, metadata = {} }) => { - if (isNonEmptyArray(propositions)) { - return applyPropositions({ propositions, metadata }); - } - return Promise.resolve(EMPTY_PROPOSITIONS); + render(propositionsToExecute); + + return { + propositions: buildReturnedPropositions(propositionsToExecute) + }; }; }; diff --git a/src/components/Personalization/createAutoRenderingHandler.js b/src/components/Personalization/createAutoRenderingHandler.js deleted file mode 100644 index 0e21c0954..000000000 --- a/src/components/Personalization/createAutoRenderingHandler.js +++ /dev/null @@ -1,77 +0,0 @@ -/* -Copyright 2021 Adobe. All rights reserved. -This file is licensed to you under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. You may obtain a copy -of the License at http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -import addRenderAttemptedToDecisions from "./utils/addRenderAttemptedToDecisions"; -import isNonEmptyArray from "../../utils/isNonEmptyArray"; - -const getPropositions = ({ viewCache, viewName, pageWideScopeDecisions }) => { - if (!viewName) { - return { pageWideScopeDecisions, viewPropositions: [] }; - } - - return viewCache.getView(viewName).then(viewPropositions => { - return { pageWideScopeDecisions, viewPropositions }; - }); -}; - -export default ({ viewCache, executeDecisions, showContainers, collect }) => { - return ({ viewName, pageWideScopeDecisions, nonAutoRenderableDecisions }) => { - return Promise.resolve(pageWideScopeDecisions) - .then(propositions => - getPropositions({ - viewCache, - viewName, - executeDecisions, - pageWideScopeDecisions: propositions - }) - ) - .then(propositions => { - executeDecisions(propositions.pageWideScopeDecisions).then( - decisionsMeta => { - if (isNonEmptyArray(decisionsMeta)) { - collect({ decisionsMeta }); - } - } - ); - - if (viewName) { - executeDecisions(propositions.viewPropositions).then( - decisionsMeta => { - collect({ decisionsMeta, viewName }); - } - ); - } - - showContainers(); - - return [ - ...propositions.pageWideScopeDecisions, - ...propositions.viewPropositions - ]; - }) - .then(renderablePropositions => { - return { - decisions: [...nonAutoRenderableDecisions], - propositions: [ - ...addRenderAttemptedToDecisions({ - decisions: renderablePropositions, - renderAttempted: true - }), - ...addRenderAttemptedToDecisions({ - decisions: nonAutoRenderableDecisions, - renderAttempted: false - }) - ] - }; - }); - }; -}; diff --git a/src/components/Personalization/createComponent.js b/src/components/Personalization/createComponent.js index 9abd6110c..226db3def 100644 --- a/src/components/Personalization/createComponent.js +++ b/src/components/Personalization/createComponent.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 { noop, defer } from "../../utils"; +import { noop } from "../../utils"; import createPersonalizationDetails from "./createPersonalizationDetails"; import { AUTHORING_ENABLED } from "./constants/loggerMessage"; import validateApplyPropositionsOptions from "./validateApplyPropositionsOptions"; @@ -60,17 +60,18 @@ export default ({ decisionScopes, personalization, event, - viewCache, + isCacheInitialized: viewCache.isInitialized(), logger }); if (personalizationDetails.shouldFetchData()) { - const decisionsDeferred = defer(); + const cacheUpdate = viewCache.createCacheUpdate( + personalizationDetails.getViewName() + ); + onRequestFailure(() => cacheUpdate.cancel()); - viewCache.storeViews(decisionsDeferred.promise); - onRequestFailure(() => decisionsDeferred.reject()); fetchDataHandler({ - decisionsDeferred, + cacheUpdate, personalizationDetails, event, onResponse diff --git a/src/components/Personalization/createExecuteDecisions.js b/src/components/Personalization/createExecuteDecisions.js deleted file mode 100644 index fff2ee8f2..000000000 --- a/src/components/Personalization/createExecuteDecisions.js +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright 2020 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 { assign, flatMap } from "../../utils"; - -const DEFAULT_ACTION_TYPE = "defaultContent"; - -const identity = item => item; - -const getItemMeta = (item, decisionMeta) => - item.characteristics && item.characteristics.trackingLabel - ? assign( - { trackingLabel: item.characteristics.trackingLabel }, - decisionMeta - ) - : decisionMeta; - -const buildActions = decision => { - const decisionMeta = { - id: decision.id, - scope: decision.scope, - scopeDetails: decision.scopeDetails - }; - - return decision.items.map(item => - assign({ type: DEFAULT_ACTION_TYPE }, item.data, { - meta: getItemMeta(item, decisionMeta) - }) - ); -}; - -const processMetas = (logger, actionResults) => { - const results = flatMap(actionResults, identity); - const finalMetas = []; - const set = new Set(); - - results.forEach(item => { - // for click actions we don't return an item - if (!item) { - return; - } - if (item.error) { - logger.warn(item); - return; - } - - const { meta } = item; - - if (set.has(meta.id)) { - return; - } - - set.add(meta.id); - finalMetas.push(meta); - }); - - return finalMetas; -}; - -export default ({ modules, logger, executeActions }) => { - return decisions => { - const actionResultsPromises = decisions.map(decision => { - const actions = buildActions(decision); - - return executeActions(actions, modules, logger); - }); - return Promise.all(actionResultsPromises) - .then(results => processMetas(logger, results)) - .catch(error => { - logger.error(error); - }); - }; -}; diff --git a/src/components/Personalization/createFetchDataHandler.js b/src/components/Personalization/createFetchDataHandler.js index df45aafc6..8bb1a0ef8 100644 --- a/src/components/Personalization/createFetchDataHandler.js +++ b/src/components/Personalization/createFetchDataHandler.js @@ -1,5 +1,5 @@ /* -Copyright 2020 Adobe. All rights reserved. +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 @@ -9,21 +9,46 @@ 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 { + buildReturnedPropositions, + buildReturnedDecisions +} from "./handlers/proposition"; + +const DECISIONS_HANDLE = "personalization:decisions"; export default ({ prehidingStyle, - responseHandler, + showContainers, hideContainers, - mergeQuery + mergeQuery, + collect, + render }) => { - return ({ decisionsDeferred, personalizationDetails, event, onResponse }) => { + return ({ cacheUpdate, personalizationDetails, event, onResponse }) => { if (personalizationDetails.isRenderDecisions()) { hideContainers(prehidingStyle); } mergeQuery(event, personalizationDetails.createQueryDetails()); - onResponse(({ response }) => - responseHandler({ decisionsDeferred, personalizationDetails, response }) - ); + onResponse(({ response }) => { + const handles = response.getPayloadsByType(DECISIONS_HANDLE); + const propositions = cacheUpdate.update(handles); + if (personalizationDetails.isRenderDecisions()) { + render(propositions).then(decisionsMeta => { + showContainers(); + if (decisionsMeta.length > 0) { + collect({ + decisionsMeta, + viewName: personalizationDetails.getViewName() + }); + } + }); + } + + return { + propositions: buildReturnedPropositions(propositions), + decisions: buildReturnedDecisions(propositions) + }; + }); }; }; diff --git a/src/components/Personalization/createNonRenderingHandler.js b/src/components/Personalization/createNonRenderingHandler.js deleted file mode 100644 index 3c9c61fb5..000000000 --- a/src/components/Personalization/createNonRenderingHandler.js +++ /dev/null @@ -1,54 +0,0 @@ -/* -Copyright 2021 Adobe. All rights reserved. -This file is licensed to you under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. You may obtain a copy -of the License at http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -import addRenderAttemptedToDecisions from "./utils/addRenderAttemptedToDecisions"; - -const getViewPropositions = ({ viewCache, viewName, propositions }) => { - if (!viewName) { - return propositions; - } - - return viewCache - .getView(viewName) - .then(viewPropositions => [...viewPropositions, ...propositions]); -}; - -const buildFinalResult = ({ propositions }) => { - return { - decisions: propositions, - propositions: addRenderAttemptedToDecisions({ - decisions: propositions, - renderAttempted: false - }) - }; -}; - -export default ({ viewCache }) => { - return ({ - viewName, - redirectDecisions, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }) => { - const propositions = [ - ...redirectDecisions, - ...pageWideScopeDecisions, - ...nonAutoRenderableDecisions - ]; - - return Promise.resolve(propositions) - .then(items => - getViewPropositions({ viewCache, viewName, propositions: items }) - ) - .then(items => buildFinalResult({ propositions: items })); - }; -}; diff --git a/src/components/Personalization/createOnResponseHandler.js b/src/components/Personalization/createOnResponseHandler.js deleted file mode 100644 index 29901c0af..000000000 --- a/src/components/Personalization/createOnResponseHandler.js +++ /dev/null @@ -1,68 +0,0 @@ -/* -Copyright 2020 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 isNonEmptyArray from "../../utils/isNonEmptyArray"; - -const DECISIONS_HANDLE = "personalization:decisions"; - -export default ({ - autoRenderingHandler, - nonRenderingHandler, - groupDecisions, - handleRedirectDecisions, - showContainers -}) => { - return ({ decisionsDeferred, personalizationDetails, response }) => { - const unprocessedDecisions = response.getPayloadsByType(DECISIONS_HANDLE); - const viewName = personalizationDetails.getViewName(); - - // if personalization payload is empty return empty decisions array - if (unprocessedDecisions.length === 0) { - showContainers(); - decisionsDeferred.resolve({}); - return { - decisions: [], - propositions: [] - }; - } - - const { - redirectDecisions, - pageWideScopeDecisions, - viewDecisions, - nonAutoRenderableDecisions - } = groupDecisions(unprocessedDecisions); - - if ( - personalizationDetails.isRenderDecisions() && - isNonEmptyArray(redirectDecisions) - ) { - decisionsDeferred.resolve({}); - return handleRedirectDecisions(redirectDecisions); - } - // save decisions for views in local cache - decisionsDeferred.resolve(viewDecisions); - - if (personalizationDetails.isRenderDecisions()) { - return autoRenderingHandler({ - viewName, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }); - } - return nonRenderingHandler({ - viewName, - redirectDecisions, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }); - }; -}; diff --git a/src/components/Personalization/createPersonalizationDetails.js b/src/components/Personalization/createPersonalizationDetails.js index c62402bff..b43e05440 100644 --- a/src/components/Personalization/createPersonalizationDetails.js +++ b/src/components/Personalization/createPersonalizationDetails.js @@ -43,7 +43,7 @@ export default ({ decisionScopes, personalization, event, - viewCache, + isCacheInitialized, logger }) => { const viewName = event.getViewName(); @@ -51,6 +51,9 @@ export default ({ isRenderDecisions() { return renderDecisions; }, + isSendDisplayNotifications() { + return !!personalization.sendDisplayNotifications; + }, getViewName() { return viewName; }, @@ -100,7 +103,7 @@ export default ({ }; }, isCacheInitialized() { - return viewCache.isInitialized(); + return isCacheInitialized; }, shouldFetchData() { return ( diff --git a/src/components/Personalization/createRedirectHandler.js b/src/components/Personalization/createRedirectHandler.js deleted file mode 100644 index e4eca0376..000000000 --- a/src/components/Personalization/createRedirectHandler.js +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2021 Adobe. All rights reserved. -This file is licensed to you under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. You may obtain a copy -of the License at http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -import { REDIRECT_EXECUTION_ERROR } from "./constants/loggerMessage"; - -const getRedirectDetails = redirectDecisions => { - const decision = redirectDecisions[0]; - const { items, id, scope, scopeDetails } = decision; - const { content } = items[0].data; - - return { content, decisions: [{ id, scope, scopeDetails }] }; -}; - -export default ({ collect, window, logger, showContainers }) => { - return redirectDecisions => { - const { content, decisions } = getRedirectDetails(redirectDecisions); - const documentMayUnload = true; - - return collect({ decisionsMeta: decisions, documentMayUnload }) - .then(() => { - window.location.replace(content); - }) - .catch(() => { - showContainers(); - logger.warn(REDIRECT_EXECUTION_ERROR); - }); - }; -}; diff --git a/src/components/Personalization/createViewCacheManager.js b/src/components/Personalization/createViewCacheManager.js index 8830c6b48..6f6a8a394 100644 --- a/src/components/Personalization/createViewCacheManager.js +++ b/src/components/Personalization/createViewCacheManager.js @@ -12,37 +12,87 @@ governing permissions and limitations under the License. import { assign } from "../../utils"; import defer from "../../utils/defer"; +import { VIEW_SCOPE_TYPE } from "./constants/scopeType"; -export default () => { - let viewStorage; - const viewStorageDeferred = defer(); +export default ({ createProposition }) => { + const viewStorage = {}; + let cacheUpdateCreatedAtLeastOnce = false; + let previousUpdateCacheComplete = Promise.resolve(); - const storeViews = decisionsPromise => { - decisionsPromise - .then(decisions => { - if (viewStorage === undefined) { - viewStorage = {}; + const getViewPropositions = (currentViewStorage, viewName) => { + const viewPropositions = currentViewStorage[viewName]; + if (viewPropositions && viewPropositions.length > 0) { + return viewPropositions.map(createProposition); + } + + const emptyViewProposition = createProposition({ + scope: viewName, + scopeDetails: { + characteristics: { + scopeType: "view" } - assign(viewStorage, decisions); - viewStorageDeferred.resolve(); + } + }); + emptyViewProposition.includeInDisplayNotification(); + emptyViewProposition.excludeInReturnedPropositions(); + return [emptyViewProposition]; + }; + + // This should be called before making the request to experience edge. + const createCacheUpdate = viewName => { + const updateCacheDeferred = defer(); + + cacheUpdateCreatedAtLeastOnce = true; + previousUpdateCacheComplete = previousUpdateCacheComplete + .then(() => updateCacheDeferred.promise) + .then(newViewStorage => { + assign(viewStorage, newViewStorage); }) - .catch(() => { - if (viewStorage === undefined) { - viewStorage = {}; + .catch(() => {}); + + return { + update(personalizationHandles) { + const newViewStorage = {}; + const otherPropositions = []; + personalizationHandles.forEach(handle => { + const { + scope, + scopeDetails: { characteristics: { scopeType } = {} } = {} + } = handle; + if (scopeType === VIEW_SCOPE_TYPE) { + newViewStorage[scope] = newViewStorage[scope] || []; + newViewStorage[scope].push(handle); + } else { + otherPropositions.push(createProposition(handle)); + } + }); + updateCacheDeferred.resolve(newViewStorage); + if (viewName) { + return [ + ...getViewPropositions(newViewStorage, viewName), + ...otherPropositions + ]; } - viewStorageDeferred.resolve(); - }); + return otherPropositions; + }, + cancel() { + updateCacheDeferred.reject(); + } + }; }; const getView = viewName => { - return viewStorageDeferred.promise.then(() => viewStorage[viewName] || []); + return previousUpdateCacheComplete.then(() => + getViewPropositions(viewStorage, viewName) + ); }; const isInitialized = () => { - return !(viewStorage === undefined); + return cacheUpdateCreatedAtLeastOnce; }; + return { - storeViews, + createCacheUpdate, getView, isInitialized }; diff --git a/src/components/Personalization/createViewChangeHandler.js b/src/components/Personalization/createViewChangeHandler.js index 8440abeff..aa7394731 100644 --- a/src/components/Personalization/createViewChangeHandler.js +++ b/src/components/Personalization/createViewChangeHandler.js @@ -10,46 +10,34 @@ OF ANY KIND, either express or implied. See the License for the specific languag governing permissions and limitations under the License. */ -import composePersonalizationResultingObject from "./utils/composePersonalizationResultingObject"; -import { isNonEmptyArray } from "../../utils"; import { PropositionEventType } from "./constants/propositionEventType"; +import { + buildReturnedPropositions, + buildReturnedDecisions +} from "./handlers/proposition"; -export default ({ - mergeDecisionsMeta, - collect, - executeDecisions, - viewCache -}) => { +export default ({ mergeDecisionsMeta, render, viewCache }) => { return ({ personalizationDetails, event, onResponse }) => { const viewName = personalizationDetails.getViewName(); - return viewCache.getView(viewName).then(viewDecisions => { + return viewCache.getView(viewName).then(propositions => { + onResponse(() => { + return { + propositions: buildReturnedPropositions(propositions), + decisions: buildReturnedDecisions(propositions) + }; + }); + if (personalizationDetails.isRenderDecisions()) { - return executeDecisions(viewDecisions).then(decisionsMeta => { - // if there are decisions to be rendered we render them and attach the result in experience.decisions.propositions - if (isNonEmptyArray(decisionsMeta)) { - mergeDecisionsMeta( - event, - decisionsMeta, - PropositionEventType.DISPLAY - ); - onResponse(() => { - return composePersonalizationResultingObject(viewDecisions, true); - }); - return; - } - // if there are no decisions in cache for this view, we will send a empty notification - onResponse(() => { - collect({ decisionsMeta: [], viewName }); - return composePersonalizationResultingObject(viewDecisions, true); - }); + return render(propositions).then(decisionsMeta => { + mergeDecisionsMeta( + event, + decisionsMeta, + PropositionEventType.DISPLAY + ); }); } - - onResponse(() => { - return composePersonalizationResultingObject(viewDecisions, false); - }); - return {}; + return Promise.resolve(); }); }; }; diff --git a/src/components/Personalization/dom-actions/action.js b/src/components/Personalization/dom-actions/action.js index 50cc3716d..28667d370 100644 --- a/src/components/Personalization/dom-actions/action.js +++ b/src/components/Personalization/dom-actions/action.js @@ -25,7 +25,6 @@ export { default as setStyles } from "./setStyles"; export { default as setAttributes } from "./setAttributes"; export { default as swapImage } from "./swapImage"; export { default as rearrangeChildren } from "./rearrangeChildren"; -export { default as click } from "./click"; const renderContent = (elements, content, renderFunc) => { const executions = elements.map(element => renderFunc(element, content)); @@ -34,8 +33,8 @@ const renderContent = (elements, content, renderFunc) => { }; export const createAction = renderFunc => { - return settings => { - const { selector, prehidingSelector, content, meta } = settings; + return itemData => { + const { selector, prehidingSelector, content } = itemData; hideElements(prehidingSelector); @@ -45,13 +44,12 @@ export const createAction = renderFunc => { () => { // if everything is OK, show elements showElements(prehidingSelector); - return { meta }; }, error => { // in case of awaiting timing or error, we need to remove the style tag // hence showing the pre-hidden elements showElements(prehidingSelector); - return { meta, error }; + throw error; } ); }; diff --git a/src/components/Personalization/dom-actions/clicks/index.js b/src/components/Personalization/dom-actions/clicks/index.js deleted file mode 100644 index a1ebd0b9e..000000000 --- a/src/components/Personalization/dom-actions/clicks/index.js +++ /dev/null @@ -1,15 +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 collectClicks from "./collectClicks"; - -export default collectClicks; diff --git a/src/components/Personalization/dom-actions/click.js b/src/components/Personalization/dom-actions/createPreprocess.js similarity index 70% rename from src/components/Personalization/dom-actions/click.js rename to src/components/Personalization/dom-actions/createPreprocess.js index 7a5f7332f..ae094c2e7 100644 --- a/src/components/Personalization/dom-actions/click.js +++ b/src/components/Personalization/dom-actions/createPreprocess.js @@ -1,5 +1,5 @@ /* -Copyright 2019 Adobe. All rights reserved. +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 @@ -9,10 +9,11 @@ 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 { assign } from "../../../utils"; -export default (settings, store) => { - const { selector, meta } = settings; - - store({ selector, meta }); - return Promise.resolve(); +export default preprocessors => action => { + return preprocessors.reduce( + (processed, fn) => assign(processed, fn(processed)), + action + ); }; diff --git a/src/components/Personalization/dom-actions/executeActions.js b/src/components/Personalization/dom-actions/executeActions.js deleted file mode 100644 index 3c7a3a346..000000000 --- a/src/components/Personalization/dom-actions/executeActions.js +++ /dev/null @@ -1,72 +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 remapHeadOffers from "./remapHeadOffers"; -import { assign } from "../../../utils"; -import remapCustomCodeOffers from "./remapCustomCodeOffers"; - -const logActionError = (logger, action, error) => { - if (logger.enabled) { - const details = JSON.stringify(action); - const { message, stack } = error; - const errorMessage = `Failed to execute action ${details}. ${message} ${ - stack ? `\n ${stack}` : "" - }`; - - logger.error(errorMessage); - } -}; - -const logActionCompleted = (logger, action) => { - if (logger.enabled) { - const details = JSON.stringify(action); - - logger.info(`Action ${details} executed.`); - } -}; - -const executeAction = (logger, modules, type, args) => { - const execute = modules[type]; - - if (!execute) { - const error = new Error(`DOM action "${type}" not found`); - logActionError(logger, args[0], error); - throw error; - } - return execute(...args); -}; - -const PREPROCESSORS = [remapHeadOffers, remapCustomCodeOffers]; - -const preprocess = action => - PREPROCESSORS.reduce( - (processed, fn) => assign(processed, fn(processed)), - action - ); - -export default (actions, modules, logger) => { - const actionPromises = actions.map(action => { - const processedAction = preprocess(action); - const { type } = processedAction; - - return executeAction(logger, modules, type, [processedAction]) - .then(result => { - logActionCompleted(logger, processedAction); - return result; - }) - .catch(error => { - logActionError(logger, processedAction, error); - throw error; - }); - }); - return Promise.all(actionPromises); -}; diff --git a/src/components/Personalization/dom-actions/index.js b/src/components/Personalization/dom-actions/index.js index 763784428..59f99e605 100644 --- a/src/components/Personalization/dom-actions/index.js +++ b/src/components/Personalization/dom-actions/index.js @@ -11,4 +11,3 @@ governing permissions and limitations under the License. */ export { default as initDomActionsModules } from "./initDomActionsModules"; -export { default as executeActions } from "./executeActions"; diff --git a/src/components/Personalization/dom-actions/initDomActionsModules.js b/src/components/Personalization/dom-actions/initDomActionsModules.js index 92710d8ce..7cdf223fb 100644 --- a/src/components/Personalization/dom-actions/initDomActionsModules.js +++ b/src/components/Personalization/dom-actions/initDomActionsModules.js @@ -23,11 +23,10 @@ import { appendHtml, prependHtml, insertHtmlAfter, - insertHtmlBefore, - click + insertHtmlBefore } from "./action"; -export default store => { +export default () => { return { setHtml: createAction(setHtml), customCode: createAction(prependHtml), @@ -43,8 +42,6 @@ export default store => { insertBefore: createAction(insertHtmlBefore), replaceHtml: createAction(replaceHtml), prependHtml: createAction(prependHtml), - appendHtml: createAction(appendHtml), - click: settings => click(settings, store), - defaultContent: settings => Promise.resolve({ meta: settings.meta }) + appendHtml: createAction(appendHtml) }; }; diff --git a/src/components/Personalization/groupDecisions.js b/src/components/Personalization/groupDecisions.js deleted file mode 100644 index 73528dbe4..000000000 --- a/src/components/Personalization/groupDecisions.js +++ /dev/null @@ -1,149 +0,0 @@ -/* -Copyright 2020 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 { isNonEmptyArray, includes } from "../../utils"; -import isPageWideScope from "./utils/isPageWideScope"; -import { - DOM_ACTION, - REDIRECT_ITEM, - DEFAULT_CONTENT_ITEM, - MEASUREMENT_SCHEMA -} from "./constants/schema"; -import { VIEW_SCOPE_TYPE } from "./constants/scopeType"; - -const splitItems = (items, schemas) => { - const matched = []; - const nonMatched = []; - - items.forEach(item => { - if (includes(schemas, item.schema)) { - matched.push(item); - } else { - nonMatched.push(item); - } - }); - - return [matched, nonMatched]; -}; - -const createDecision = (decision, items) => { - return { - id: decision.id, - scope: decision.scope, - items, - scopeDetails: decision.scopeDetails - }; -}; - -const splitMergedMetricDecisions = decisions => { - const matchedDecisions = decisions.filter(decision => { - const { items = [] } = decision; - return items.some(item => item.schema === MEASUREMENT_SCHEMA); - }); - const unmatchedDecisions = decisions.filter( - decision => !includes(matchedDecisions, decision) - ); - return { matchedDecisions, unmatchedDecisions }; -}; - -const splitDecisions = (decisions, ...schemas) => { - const matchedDecisions = []; - const unmatchedDecisions = []; - - decisions.forEach(decision => { - const { items = [] } = decision; - const [matchedItems, nonMatchedItems] = splitItems(items, schemas); - - if (isNonEmptyArray(matchedItems)) { - matchedDecisions.push(createDecision(decision, matchedItems)); - } - - if (isNonEmptyArray(nonMatchedItems)) { - unmatchedDecisions.push(createDecision(decision, nonMatchedItems)); - } - }); - return { matchedDecisions, unmatchedDecisions }; -}; - -const appendScopeDecision = (scopeDecisions, decision) => { - if (!scopeDecisions[decision.scope]) { - scopeDecisions[decision.scope] = []; - } - scopeDecisions[decision.scope].push(decision); -}; - -const isViewScope = scopeDetails => - scopeDetails.characteristics && - scopeDetails.characteristics.scopeType && - scopeDetails.characteristics.scopeType === VIEW_SCOPE_TYPE; - -const extractDecisionsByScope = decisions => { - const pageWideScopeDecisions = []; - const nonPageWideScopeDecisions = []; - const viewScopeDecisions = {}; - - if (isNonEmptyArray(decisions)) { - decisions.forEach(decision => { - if (isPageWideScope(decision.scope)) { - pageWideScopeDecisions.push(decision); - } else if (isViewScope(decision.scopeDetails)) { - appendScopeDecision(viewScopeDecisions, decision); - } else { - nonPageWideScopeDecisions.push(decision); - } - }); - } - - return { - pageWideScopeDecisions, - nonPageWideScopeDecisions, - viewScopeDecisions - }; -}; - -const groupDecisions = unprocessedDecisions => { - // split redirect decisions - const decisionsGroupedByRedirectItemSchema = splitDecisions( - unprocessedDecisions, - REDIRECT_ITEM - ); - // split merged measurement decisions - const mergedMetricDecisions = splitMergedMetricDecisions( - decisionsGroupedByRedirectItemSchema.unmatchedDecisions - ); - // split renderable decisions - const decisionsGroupedByRenderableSchemas = splitDecisions( - mergedMetricDecisions.unmatchedDecisions, - DOM_ACTION, - DEFAULT_CONTENT_ITEM - ); - // group renderable decisions by scope - const { - pageWideScopeDecisions, - nonPageWideScopeDecisions, - viewScopeDecisions - } = extractDecisionsByScope( - decisionsGroupedByRenderableSchemas.matchedDecisions - ); - - return { - redirectDecisions: decisionsGroupedByRedirectItemSchema.matchedDecisions, - pageWideScopeDecisions, - viewDecisions: viewScopeDecisions, - nonAutoRenderableDecisions: [ - ...mergedMetricDecisions.matchedDecisions, - ...decisionsGroupedByRenderableSchemas.unmatchedDecisions, - ...nonPageWideScopeDecisions - ] - }; -}; -export default groupDecisions; diff --git a/src/components/Personalization/handlers/createDomActionHandler.js b/src/components/Personalization/handlers/createDomActionHandler.js new file mode 100644 index 000000000..a27b6c48e --- /dev/null +++ b/src/components/Personalization/handlers/createDomActionHandler.js @@ -0,0 +1,46 @@ +/* +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 { DEFAULT_CONTENT_ITEM, DOM_ACTION } from "../constants/schema"; + +export default ({ + next, + modules, + storeClickMetrics, + preprocess +}) => proposition => { + const { items = [] } = proposition.getHandle(); + + items.forEach((item, index) => { + const { schema, data } = item; + if (schema === DEFAULT_CONTENT_ITEM) { + proposition.includeInDisplayNotification(); + proposition.addRenderer(index, () => undefined); + } + const { type, selector } = data || {}; + if (schema === DOM_ACTION && type && selector) { + if (type === "click") { + // Do not record the click proposition in display notification. + // Store it for later. + storeClickMetrics({ selector, meta: proposition.getItemMeta(index) }); + proposition.addRenderer(index, () => undefined); + } else if (modules[type]) { + proposition.includeInDisplayNotification(); + const processedData = preprocess(data); + proposition.addRenderer(index, () => { + return modules[type](processedData); + }); + } + } + }); + + next(proposition); +}; diff --git a/src/components/Personalization/handlers/createHtmlContentHandler.js b/src/components/Personalization/handlers/createHtmlContentHandler.js new file mode 100644 index 000000000..47f3d028d --- /dev/null +++ b/src/components/Personalization/handlers/createHtmlContentHandler.js @@ -0,0 +1,44 @@ +/* +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 { HTML_CONTENT_ITEM } from "../constants/schema"; +import { VIEW_SCOPE_TYPE } from "../constants/scopeType"; +import isPageWideScope from "../utils/isPageWideScope"; + +export default ({ next, modules, preprocess }) => proposition => { + const { + scope, + scopeDetails: { characteristics: { scopeType } = {} } = {}, + items = [] + } = proposition.getHandle(); + + items.forEach((item, index) => { + const { schema, data } = item; + const { type, selector } = data || {}; + if (schema === HTML_CONTENT_ITEM && type && selector && modules[type]) { + proposition.includeInDisplayNotification(); + const preprocessedData = preprocess(data); + proposition.addRenderer(index, () => { + return modules[type](preprocessedData); + }); + } + }); + + // only continue processing if it is a view scope proposition + // or if it is a page wide proposition. + if ( + scopeType === VIEW_SCOPE_TYPE || + isPageWideScope(scope) || + proposition.isApplyPropositions() + ) { + next(proposition); + } +}; diff --git a/src/components/Personalization/utils/composePersonalizationResultingObject.js b/src/components/Personalization/handlers/createMeasurementSchemaHandler.js similarity index 61% rename from src/components/Personalization/utils/composePersonalizationResultingObject.js rename to src/components/Personalization/handlers/createMeasurementSchemaHandler.js index ad1a0e98b..b6ec90680 100644 --- a/src/components/Personalization/utils/composePersonalizationResultingObject.js +++ b/src/components/Personalization/handlers/createMeasurementSchemaHandler.js @@ -9,17 +9,14 @@ 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 addRenderAttemptedToDecisions from "./addRenderAttemptedToDecisions"; +import { MEASUREMENT_SCHEMA } from "../constants/schema"; -export default (decisions = [], renderDecisions) => { - const resultingObject = { - propositions: addRenderAttemptedToDecisions({ - decisions, - renderAttempted: renderDecisions - }) - }; - if (!renderDecisions) { - resultingObject.decisions = decisions; +export default ({ next }) => proposition => { + const { items = [] } = proposition.getHandle(); + + // If there is a measurement schema in the item list, + // just return the whole proposition unrendered. (i.e. do not call next) + if (!items.some(item => item.schema === MEASUREMENT_SCHEMA)) { + next(proposition); } - return resultingObject; }; diff --git a/test/unit/specs/components/Personalization/dom-actions/click.spec.js b/src/components/Personalization/handlers/createRedirectHandler.js similarity index 53% rename from test/unit/specs/components/Personalization/dom-actions/click.spec.js rename to src/components/Personalization/handlers/createRedirectHandler.js index cb8090965..eed92e493 100644 --- a/test/unit/specs/components/Personalization/dom-actions/click.spec.js +++ b/src/components/Personalization/handlers/createRedirectHandler.js @@ -9,19 +9,20 @@ 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 { initDomActionsModules } from "../../../../../../src/components/Personalization/dom-actions"; +import { REDIRECT_ITEM } from "../constants/schema"; +import { find } from "../../../utils"; -describe("Personalization::actions::click", () => { - it("should set click tracking attribute", () => { - const store = jasmine.createSpy(); - const modules = initDomActionsModules(store); - const { click } = modules; - const selector = "#click"; - const meta = { a: 1 }; - const settings = { selector, meta }; +export default ({ next }) => proposition => { + const { items = [] } = proposition.getHandle() || {}; - click(settings, store); - - expect(store).toHaveBeenCalledWith({ selector, meta }); - }); -}); + const redirectItem = find(items, ({ schema }) => schema === REDIRECT_ITEM); + if (redirectItem) { + const { + data: { content } + } = redirectItem; + proposition.redirect(content); + // On a redirect, nothing else needs to handle this. + } else { + next(proposition); + } +}; diff --git a/src/components/Personalization/handlers/createRender.js b/src/components/Personalization/handlers/createRender.js new file mode 100644 index 000000000..1ef0ad97d --- /dev/null +++ b/src/components/Personalization/handlers/createRender.js @@ -0,0 +1,49 @@ +import { REDIRECT_EXECUTION_ERROR } from "../constants/loggerMessage"; + +/* +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. +*/ +export default ({ + handleChain, + collect, + executeRedirect, + logger, + showContainers +}) => propositions => { + for (let i = 0; i < propositions.length; i += 1) { + const proposition = propositions[i]; + handleChain(proposition); + const redirectUrl = proposition.getRedirectUrl(); + if (redirectUrl) { + const displayNotificationPropositions = []; + proposition.addToNotifications(displayNotificationPropositions); + // no return value because we are redirecting. i.e. the sendEvent promise will + // never resolve anyways so no need to generate the return value. + return collect({ decisionsMeta: displayNotificationPropositions }) + .then(() => { + executeRedirect(redirectUrl); + // This code should never be reached because we are redirecting, but in case + // it does we return an empty array of notifications to match the return type. + return []; + }) + .catch(() => { + showContainers(); + logger.warn(REDIRECT_EXECUTION_ERROR); + }); + } + } + + return Promise.all( + propositions.map(proposition => proposition.render(logger)) + ).then(notifications => { + return notifications.filter(notification => notification); + }); +}; diff --git a/src/components/Personalization/handlers/proposition.js b/src/components/Personalization/handlers/proposition.js new file mode 100644 index 000000000..c0e29babf --- /dev/null +++ b/src/components/Personalization/handlers/proposition.js @@ -0,0 +1,155 @@ +/* +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. +*/ + +const renderWithLogging = (renderer, item, logger) => { + return Promise.resolve() + .then(renderer) + .then(() => { + if (logger.enabled) { + const details = JSON.stringify(item); + logger.info(`Action ${details} executed.`); + } + return true; + }) + .catch(error => { + if (logger.enabled) { + const details = JSON.stringify(item); + const { message, stack } = error; + const errorMessage = `Failed to execute action ${details}. ${message} ${ + stack ? `\n ${stack}` : "" + }`; + logger.error(errorMessage); + } + return false; + }); +}; + +export const createProposition = (handle, isApplyPropositions = false) => { + const { id, scope, scopeDetails, items = [] } = handle; + + const renderers = []; + let redirectUrl; + let includeInDisplayNotification = false; + let includeInReturnedPropositions = true; + const itemsRenderAttempted = new Array(items.length); + for (let i = 0; i < items.length; i += 1) { + itemsRenderAttempted[i] = false; + } + + return { + getHandle() { + return handle; + }, + getItemMeta(i) { + const item = items[i]; + const meta = { id, scope, scopeDetails }; + if (item.characteristics && item.characteristics.trackingLabel) { + meta.trackingLabel = item.characteristics.trackingLabel; + } + + return meta; + }, + redirect(url) { + includeInDisplayNotification = true; + itemsRenderAttempted.forEach((_, index) => { + itemsRenderAttempted[index] = true; + }); + redirectUrl = url; + }, + getRedirectUrl() { + return redirectUrl; + }, + addRenderer(itemIndex, renderer) { + itemsRenderAttempted[itemIndex] = true; + renderers.push([itemIndex, renderer]); + }, + includeInDisplayNotification() { + includeInDisplayNotification = true; + }, + excludeInReturnedPropositions() { + includeInReturnedPropositions = false; + }, + render(logger) { + return Promise.all( + renderers.map(([itemIndex, renderer]) => + renderWithLogging(renderer, items[itemIndex], logger) + ) + ).then(successes => { + const notifications = []; + // as long as at least one renderer succeeds, we want to add the notification + // to the display notifications + if (successes.length === 0 || successes.includes(true)) { + this.addToNotifications(notifications); + } + return notifications[0]; + }); + }, + addToNotifications(notifications) { + if (includeInDisplayNotification) { + notifications.push({ id, scope, scopeDetails }); + } + }, + addToReturnedPropositions(propositions) { + if (includeInReturnedPropositions) { + const renderedItems = items.filter( + (_, index) => itemsRenderAttempted[index] + ); + if (renderedItems.length > 0) { + propositions.push({ + ...handle, + items: renderedItems, + renderAttempted: true + }); + } + const nonrenderedItems = items.filter( + (_, index) => !itemsRenderAttempted[index] + ); + if (nonrenderedItems.length > 0) { + propositions.push({ + ...handle, + items: nonrenderedItems, + renderAttempted: false + }); + } + } + }, + addToReturnedDecisions(decisions) { + if (includeInReturnedPropositions) { + const nonrenderedItems = items.filter( + (item, index) => !itemsRenderAttempted[index] + ); + if (nonrenderedItems.length > 0) { + decisions.push({ ...handle, items: nonrenderedItems }); + } + } + }, + isApplyPropositions() { + return isApplyPropositions; + } + }; +}; + +export const buildReturnedPropositions = propositions => { + const returnedPropositions = []; + propositions.forEach(p => { + p.addToReturnedPropositions(returnedPropositions); + }); + return returnedPropositions; +}; + +export const buildReturnedDecisions = propositions => { + const returnedDecisions = []; + propositions.forEach(p => { + p.addToReturnedDecisions(returnedDecisions); + }); + return returnedDecisions; +}; diff --git a/src/components/Personalization/index.js b/src/components/Personalization/index.js index 14c92f196..5a75c68a9 100644 --- a/src/components/Personalization/index.js +++ b/src/components/Personalization/index.js @@ -12,9 +12,8 @@ governing permissions and limitations under the License. import { string, boolean, objectOf } from "../../utils/validation"; import createComponent from "./createComponent"; -import { initDomActionsModules, executeActions } from "./dom-actions"; +import { initDomActionsModules } from "./dom-actions"; import createCollect from "./createCollect"; -import createExecuteDecisions from "./createExecuteDecisions"; import { hideContainers, showContainers } from "./flicker"; import createFetchDataHandler from "./createFetchDataHandler"; import collectClicks from "./dom-actions/clicks/collectClicks"; @@ -23,15 +22,19 @@ import { mergeDecisionsMeta, mergeQuery } from "./event"; import createOnClickHandler from "./createOnClickHandler"; import createViewCacheManager from "./createViewCacheManager"; import createViewChangeHandler from "./createViewChangeHandler"; -import groupDecisions from "./groupDecisions"; -import createOnResponseHandler from "./createOnResponseHandler"; import createClickStorage from "./createClickStorage"; -import createRedirectHandler from "./createRedirectHandler"; -import createAutorenderingHandler from "./createAutoRenderingHandler"; -import createNonRenderingHandler from "./createNonRenderingHandler"; import createApplyPropositions from "./createApplyPropositions"; import createGetPageLocation from "./createGetPageLocation"; import createSetTargetMigration from "./createSetTargetMigration"; +import createRedirectHandler from "./handlers/createRedirectHandler"; +import createHtmlContentHandler from "./handlers/createHtmlContentHandler"; +import createDomActionHandler from "./handlers/createDomActionHandler"; +import createMeasurementSchemaHandler from "./handlers/createMeasurementSchemaHandler"; +import createRender from "./handlers/createRender"; +import remapCustomCodeOffers from "./dom-actions/remapCustomCodeOffers"; +import remapHeadOffers from "./dom-actions/remapHeadOffers"; +import createPreprocess from "./dom-actions/createPreprocess"; +import { createProposition } from "./handlers/proposition"; const createPersonalization = ({ config, logger, eventManager }) => { const { targetMigrationEnabled, prehidingStyle } = config; @@ -43,41 +46,43 @@ const createPersonalization = ({ config, logger, eventManager }) => { storeClickMetrics } = createClickStorage(); const getPageLocation = createGetPageLocation({ window }); - const viewCache = createViewCacheManager(); - const modules = initDomActionsModules(storeClickMetrics); - const executeDecisions = createExecuteDecisions({ + const viewCache = createViewCacheManager({ createProposition }); + const modules = initDomActionsModules(); + + const preprocess = createPreprocess([remapHeadOffers, remapCustomCodeOffers]); + + const noOpHandler = () => undefined; + const domActionHandler = createDomActionHandler({ + next: noOpHandler, modules, - logger, - executeActions + storeClickMetrics, + preprocess }); - const handleRedirectDecisions = createRedirectHandler({ - collect, - window, - logger, - showContainers + const measurementSchemaHandler = createMeasurementSchemaHandler({ + next: domActionHandler }); - const autoRenderingHandler = createAutorenderingHandler({ - viewCache, - executeDecisions, - showContainers, - collect + const redirectHandler = createRedirectHandler({ + next: measurementSchemaHandler }); - const applyPropositions = createApplyPropositions({ - executeDecisions + const htmlContentHandler = createHtmlContentHandler({ + next: redirectHandler, + modules, + preprocess }); - const nonRenderingHandler = createNonRenderingHandler({ viewCache }); - const responseHandler = createOnResponseHandler({ - autoRenderingHandler, - nonRenderingHandler, - groupDecisions, - handleRedirectDecisions, - showContainers + + const render = createRender({ + handleChain: htmlContentHandler, + collect, + executeRedirect: url => window.location.replace(url), + logger }); const fetchDataHandler = createFetchDataHandler({ prehidingStyle, - responseHandler, + showContainers, hideContainers, - mergeQuery + mergeQuery, + collect, + render }); const onClickHandler = createOnClickHandler({ mergeDecisionsMeta, @@ -87,10 +92,12 @@ const createPersonalization = ({ config, logger, eventManager }) => { }); const viewChangeHandler = createViewChangeHandler({ mergeDecisionsMeta, - collect, - executeDecisions, + render, viewCache }); + const applyPropositions = createApplyPropositions({ + render + }); const setTargetMigration = createSetTargetMigration({ targetMigrationEnabled }); diff --git a/test/functional/specs/Personalization/C205528.js b/test/functional/specs/Personalization/C205528.js index 4e5beab79..6c4a4736f 100644 --- a/test/functional/specs/Personalization/C205528.js +++ b/test/functional/specs/Personalization/C205528.js @@ -49,6 +49,6 @@ test("Test C205528: A redirect offer should redirect the page to the URL in the } catch (e) { // an exception will be thrown because a redirect will be executed within the Alloy Client Function } finally { - await t.expect(redirectLogger.requests.length).eql(1); + await t.expect(redirectLogger.count(() => true)).eql(1); } }); diff --git a/test/functional/specs/Personalization/C6364798.js b/test/functional/specs/Personalization/C6364798.js index 4611d20ac..37d468032 100644 --- a/test/functional/specs/Personalization/C6364798.js +++ b/test/functional/specs/Personalization/C6364798.js @@ -124,24 +124,14 @@ const simulatePageLoad = async alloy => { personalizationPayload, PAGE_WIDE_SCOPE ); - await t - .expect( - // eslint-disable-next-line no-underscore-dangle - notificationRequestBody.events[0].xdm._experience.decisioning.propositions - ) - .eql(pageWideScopeDecisionsMeta); + await t .expect( // eslint-disable-next-line no-underscore-dangle notificationRequestBody.events[0].xdm._experience.decisioning - .propositionEventType.display + .propositions[1] ) - .eql(1); - // notification for view rendered decisions - const viewNotificationRequest = networkLogger.edgeEndpointLogs.requests[2]; - const viewNotificationRequestBody = JSON.parse( - viewNotificationRequest.request.body - ); + .eql(pageWideScopeDecisionsMeta[0]); const productsViewDecisionsMeta = getDecisionsMetaByScope( personalizationPayload, "products" @@ -149,17 +139,26 @@ const simulatePageLoad = async alloy => { await t .expect( // eslint-disable-next-line no-underscore-dangle - viewNotificationRequestBody.events[0].xdm._experience.decisioning - .propositions + notificationRequestBody.events[0].xdm._experience.decisioning + .propositions[0] + ) + .eql(productsViewDecisionsMeta[0]); + await t + .expect( + // eslint-disable-next-line no-underscore-dangle + notificationRequestBody.events[0].xdm._experience.decisioning.propositions + .length ) - .eql(productsViewDecisionsMeta); + .eql(2); + await t .expect( // eslint-disable-next-line no-underscore-dangle - viewNotificationRequestBody.events[0].xdm._experience.decisioning + notificationRequestBody.events[0].xdm._experience.decisioning .propositionEventType.display ) .eql(1); + const allPropositionsWereRendered = resultingObject.propositions.every( proposition => proposition.renderAttempted ); @@ -181,7 +180,7 @@ const simulateViewChange = async (alloy, personalizationPayload) => { } } }); - const viewChangeRequest = networkLogger.edgeEndpointLogs.requests[3]; + const viewChangeRequest = networkLogger.edgeEndpointLogs.requests[2]; const viewChangeRequestBody = JSON.parse(viewChangeRequest.request.body); // assert that no personalization query was attached to the request await t.expect(viewChangeRequestBody.events[0].query).eql(undefined); @@ -230,30 +229,41 @@ const simulateViewChangeForNonExistingView = async alloy => { } }); - const noViewViewChangeRequest = networkLogger.edgeEndpointLogs.requests[4]; + const noViewViewChangeRequest = networkLogger.edgeEndpointLogs.requests[3]; const noViewViewChangeRequestBody = JSON.parse( noViewViewChangeRequest.request.body ); // assert that no personalization query was attached to the request await t.expect(noViewViewChangeRequestBody.events[0].query).eql(undefined); - // assert that a notification call with xdm.web.webPageDetails.viewName and no personalization meta is sent - await flushPromiseChains(); - const noViewNotificationRequest = networkLogger.edgeEndpointLogs.requests[5]; - const noViewNotificationRequestBody = JSON.parse( - noViewNotificationRequest.request.body - ); await t - .expect(noViewNotificationRequestBody.events[0].xdm.eventType) - .eql("decisioning.propositionDisplay"); + .expect( + // eslint-disable-next-line no-underscore-dangle + noViewViewChangeRequestBody.events[0].xdm._experience.decisioning + .propositions + ) + .eql([ + { + scope: "noView", + scopeDetails: { + characteristics: { + scopeType: "view" + } + } + } + ]); + await t + .expect( + noViewViewChangeRequestBody.events[0].xdm.web.webPageDetails.viewName + ) + .eql("noView"); await t .expect( - noViewNotificationRequestBody.events[0].xdm.web.webPageDetails.viewName + noViewViewChangeRequestBody.events[0].xdm.web.webPageDetails.viewName ) .eql("noView"); await t - // eslint-disable-next-line no-underscore-dangle - .expect(noViewNotificationRequestBody.events[0].xdm._experience) - .eql(undefined); + .expect(noViewViewChangeRequestBody.events[0].xdm.eventType) + .eql("noviewoffers"); }; const simulateViewRerender = async (alloy, propositions) => { @@ -269,7 +279,7 @@ const simulateViewRerender = async (alloy, propositions) => { .expect(applyPropositionsResult.propositions.length) .eql(propositions.length); // make sure no new network requests are sent - applyPropositions is a client-side only command. - await t.expect(networkLogger.edgeEndpointLogs.requests.length).eql(4); + await t.expect(networkLogger.edgeEndpointLogs.requests.length).eql(3); }; test("Test C6364798: applyPropositions should re-render SPA view without sending view notifications", async () => { diff --git a/test/functional/specs/Personalization/C782718.js b/test/functional/specs/Personalization/C782718.js index 0a9b504cd..ddfb48a20 100644 --- a/test/functional/specs/Personalization/C782718.js +++ b/test/functional/specs/Personalization/C782718.js @@ -123,24 +123,13 @@ const simulatePageLoad = async alloy => { personalizationPayload, PAGE_WIDE_SCOPE ); - await t - .expect( - // eslint-disable-next-line no-underscore-dangle - notificationRequestBody.events[0].xdm._experience.decisioning.propositions - ) - .eql(pageWideScopeDecisionsMeta); await t .expect( // eslint-disable-next-line no-underscore-dangle notificationRequestBody.events[0].xdm._experience.decisioning - .propositionEventType.display + .propositions[1] ) - .eql(1); - // notification for view rendered decisions - const viewNotificationRequest = networkLogger.edgeEndpointLogs.requests[2]; - const viewNotificationRequestBody = JSON.parse( - viewNotificationRequest.request.body - ); + .eql(pageWideScopeDecisionsMeta[0]); const productsViewDecisionsMeta = getDecisionsMetaByScope( personalizationPayload, "products" @@ -148,14 +137,14 @@ const simulatePageLoad = async alloy => { await t .expect( // eslint-disable-next-line no-underscore-dangle - viewNotificationRequestBody.events[0].xdm._experience.decisioning - .propositions + notificationRequestBody.events[0].xdm._experience.decisioning + .propositions[0] ) - .eql(productsViewDecisionsMeta); + .eql(productsViewDecisionsMeta[0]); await t .expect( // eslint-disable-next-line no-underscore-dangle - viewNotificationRequestBody.events[0].xdm._experience.decisioning + notificationRequestBody.events[0].xdm._experience.decisioning .propositionEventType.display ) .eql(1); @@ -180,7 +169,7 @@ const simulateViewChange = async (alloy, personalizationPayload) => { } } }); - const viewChangeRequest = networkLogger.edgeEndpointLogs.requests[3]; + const viewChangeRequest = networkLogger.edgeEndpointLogs.requests[2]; const viewChangeRequestBody = JSON.parse(viewChangeRequest.request.body); // assert that no personalization query was attached to the request await t.expect(viewChangeRequestBody.events[0].query).eql(undefined); @@ -227,30 +216,40 @@ const simulateViewChangeForNonExistingView = async alloy => { } }); - const noViewViewChangeRequest = networkLogger.edgeEndpointLogs.requests[4]; + const noViewViewChangeRequest = networkLogger.edgeEndpointLogs.requests[3]; const noViewViewChangeRequestBody = JSON.parse( noViewViewChangeRequest.request.body ); // assert that no personalization query was attached to the request await t.expect(noViewViewChangeRequestBody.events[0].query).eql(undefined); // assert that a notification call with xdm.web.webPageDetails.viewName and no personalization meta is sent - await flushPromiseChains(); - const noViewNotificationRequest = networkLogger.edgeEndpointLogs.requests[5]; - const noViewNotificationRequestBody = JSON.parse( - noViewNotificationRequest.request.body - ); + await t - .expect(noViewNotificationRequestBody.events[0].xdm.eventType) - .eql("decisioning.propositionDisplay"); + .expect(noViewViewChangeRequestBody.events[0].xdm.eventType) + .eql("noviewoffers"); await t .expect( - noViewNotificationRequestBody.events[0].xdm.web.webPageDetails.viewName + noViewViewChangeRequestBody.events[0].xdm.web.webPageDetails.viewName ) .eql("noView"); await t // eslint-disable-next-line no-underscore-dangle - .expect(noViewNotificationRequestBody.events[0].xdm._experience) - .eql(undefined); + .expect(noViewViewChangeRequestBody.events[0].xdm._experience.decisioning) + .eql({ + propositions: [ + { + scope: "noView", + scopeDetails: { + characteristics: { + scopeType: "view" + } + } + } + ], + propositionEventType: { + display: 1 + } + }); }; test("Test C782718: SPA support with auto-rendering and view notifications", async () => { diff --git a/test/functional/specs/Privacy/IAB/C224677.js b/test/functional/specs/Privacy/IAB/C224677.js index 151c23b3e..9fb9c89b7 100644 --- a/test/functional/specs/Privacy/IAB/C224677.js +++ b/test/functional/specs/Privacy/IAB/C224677.js @@ -11,7 +11,6 @@ governing permissions and limitations under the License. */ import { t } from "testcafe"; import createNetworkLogger from "../../../helpers/networkLogger"; -import { responseStatus } from "../../../helpers/assertions/index"; import createFixture from "../../../helpers/createFixture"; import createResponse from "../../../helpers/createResponse"; import getResponseBody from "../../../helpers/networkLogger/getResponseBody"; @@ -82,5 +81,12 @@ test("Test C224677: Call setConsent when purpose 10 is FALSE", async () => { .notContains("https://ns.adobe.com/aep/errors/EXEG-0301-200"); await t.expect(networkLogger.edgeEndpointLogs.requests.length).eql(1); - await responseStatus(networkLogger.edgeEndpointLogs.requests, 200); + await t + .expect( + networkLogger.edgeEndpointLogs.count( + ({ response: { statusCode } }) => + statusCode === 200 || statusCode === 207 + ) + ) + .eql(1); }); diff --git a/test/unit/specs/components/Personalization/createApplyPropositions.spec.js b/test/unit/specs/components/Personalization/createApplyPropositions.spec.js index 0f75b7f9e..0d3963560 100644 --- a/test/unit/specs/components/Personalization/createApplyPropositions.spec.js +++ b/test/unit/specs/components/Personalization/createApplyPropositions.spec.js @@ -26,34 +26,33 @@ const METADATA = { }; describe("Personalization::createApplyPropositions", () => { - let executeDecisions; + let render; beforeEach(() => { - executeDecisions = jasmine.createSpy("executeDecisions"); + render = jasmine.createSpy("render"); + render.and.callFake(propositions => { + propositions.forEach(proposition => { + const { items = [] } = proposition.getHandle(); + items.forEach((_, i) => { + proposition.addRenderer(i, () => undefined); + }); + }); + }); }); it("it should return an empty propositions promise if propositions is empty array", () => { - executeDecisions.and.returnValue( - Promise.resolve(PAGE_WIDE_SCOPE_DECISIONS) - ); - const applyPropositions = createApplyPropositions({ - executeDecisions + render }); - return applyPropositions({ + const result = applyPropositions({ propositions: [] - }).then(result => { - expect(result).toEqual({ propositions: [] }); - expect(executeDecisions).toHaveBeenCalledTimes(0); }); + expect(result).toEqual({ propositions: [] }); + expect(render).toHaveBeenCalledOnceWith([]); }); it("it should apply user-provided dom-action schema propositions", () => { - executeDecisions.and.returnValue( - Promise.resolve(PAGE_WIDE_SCOPE_DECISIONS) - ); - const expectedExecuteDecisionsPropositions = clone( PAGE_WIDE_SCOPE_DECISIONS ).map(proposition => { @@ -62,56 +61,49 @@ describe("Personalization::createApplyPropositions", () => { }); const applyPropositions = createApplyPropositions({ - executeDecisions + render }); - return applyPropositions({ + const result = applyPropositions({ propositions: PAGE_WIDE_SCOPE_DECISIONS - }).then(result => { - expect(executeDecisions).toHaveBeenCalledTimes(1); - expect(executeDecisions.calls.first().args[0]).toEqual( - expectedExecuteDecisionsPropositions - ); + }); - const expectedScopes = expectedExecuteDecisionsPropositions.map( - proposition => proposition.scope - ); - result.propositions.forEach(proposition => { - expect(proposition.renderAttempted).toBeTrue(); - expect(expectedScopes).toContain(proposition.scope); - expect(proposition.items).toBeArrayOfObjects(); - expect(proposition.items.length).toEqual(2); - }); + expect(render).toHaveBeenCalledTimes(1); + + const expectedScopes = expectedExecuteDecisionsPropositions.map( + proposition => proposition.scope + ); + result.propositions.forEach(proposition => { + expect(proposition.renderAttempted).toBeTrue(); + expect(expectedScopes).toContain(proposition.scope); + expect(proposition.items).toBeArrayOfObjects(); + expect(proposition.items.length).toEqual(2); }); }); it("it should merge metadata with propositions that have html-content-item schema", () => { - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); - const applyPropositions = createApplyPropositions({ - executeDecisions + render }); - return applyPropositions({ + const { propositions } = applyPropositions({ propositions: MIXED_PROPOSITIONS, metadata: METADATA - }).then(() => { - const executedPropositions = executeDecisions.calls.first().args[0]; - expect(executedPropositions.length).toEqual(3); - executedPropositions.forEach(proposition => { - expect(proposition.items.length).toEqual(1); - if (proposition.items[0].id === "442358") { - expect(proposition.scope).toEqual("home"); - expect(proposition.items[0].data.selector).toEqual("#root"); - expect(proposition.items[0].data.type).toEqual("click"); - } else if (proposition.items[0].id === "442359") { - expect(proposition.scope).toEqual("home"); - expect(proposition.items[0].data.selector).toEqual("#home-item1"); - expect(proposition.items[0].data.type).toEqual("setHtml"); - } - }); - expect(executeDecisions).toHaveBeenCalledTimes(1); }); + + expect(propositions.length).toEqual(4); + propositions.forEach(proposition => { + expect(proposition.items.length).toEqual(1); + if (proposition.items[0].id === "442358") { + expect(proposition.items[0].data.selector).toEqual("#root"); + expect(proposition.items[0].data.type).toEqual("click"); + } else if (proposition.items[0].id === "442359") { + expect(proposition.scope).toEqual("home"); + expect(proposition.items[0].data.selector).toEqual("#home-item1"); + expect(proposition.items[0].data.type).toEqual("setHtml"); + } + }); + expect(render).toHaveBeenCalledTimes(1); }); it("it should drop items with html-content-item schema when there is no metadata", () => { @@ -142,108 +134,86 @@ describe("Personalization::createApplyPropositions", () => { } ]; - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); + const applyPropositions = createApplyPropositions({ render }); - const applyPropositions = createApplyPropositions({ - executeDecisions - }); - - return applyPropositions({ + const result = applyPropositions({ propositions - }).then(result => { - expect(result.propositions.length).toEqual(1); - expect(result.propositions[0].items.length).toEqual(1); - expect(result.propositions[0].items[0].id).toEqual("442358"); - expect(result.propositions[0].renderAttempted).toBeTrue(); }); + + expect(result.propositions.length).toEqual(1); + expect(result.propositions[0].items.length).toEqual(1); + expect(result.propositions[0].items[0].id).toEqual("442358"); + expect(result.propositions[0].renderAttempted).toBeTrue(); }); it("it should return renderAttempted = true on resulting propositions", () => { - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); - - const applyPropositions = createApplyPropositions({ - executeDecisions - }); + const applyPropositions = createApplyPropositions({ render }); - return applyPropositions({ + const result = applyPropositions({ propositions: MIXED_PROPOSITIONS - }).then(result => { - expect(result.propositions.length).toEqual(2); - result.propositions.forEach(proposition => { - expect(proposition.renderAttempted).toBeTrue(); - }); + }); + expect(result.propositions.length).toEqual(3); + result.propositions.forEach(proposition => { + expect(proposition.renderAttempted).toBeTrue(); }); }); it("it should ignore propositions with __view__ scope that have already been rendered", () => { - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); + const applyPropositions = createApplyPropositions({ render }); + const propositions = JSON.parse(JSON.stringify(MIXED_PROPOSITIONS)); + propositions[4].renderAttempted = true; - const applyPropositions = createApplyPropositions({ - executeDecisions + const result = applyPropositions({ + propositions }); - - return applyPropositions({ - propositions: MIXED_PROPOSITIONS - }).then(result => { - expect(result.propositions.length).toEqual(2); - result.propositions.forEach(proposition => { - expect(proposition.renderAttempted).toBeTrue(); - if (proposition.scope === "__view__") { - expect(proposition.items[0].id).not.toEqual("442358"); - } else { - expect(proposition.scope).toEqual("home"); - } - }); + expect(result.propositions.length).toEqual(2); + result.propositions.forEach(proposition => { + expect(proposition.renderAttempted).toBeTrue(); + if (proposition.scope === "__view__") { + expect(proposition.items[0].id).not.toEqual("442358"); + } else { + expect(proposition.scope).toEqual("home"); + } }); }); it("it should ignore items with unsupported schemas", () => { - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); - const expectedItemIds = ["442358", "442359"]; - const applyPropositions = createApplyPropositions({ - executeDecisions - }); + const applyPropositions = createApplyPropositions({ render }); - return applyPropositions({ + const { propositions } = applyPropositions({ propositions: MIXED_PROPOSITIONS - }).then(() => { - const executedPropositions = executeDecisions.calls.first().args[0]; - expect(executedPropositions.length).toEqual(2); - executedPropositions.forEach(proposition => { - expect(proposition.items.length).toEqual(1); - proposition.items.forEach(item => { - expect(expectedItemIds.indexOf(item.id) > -1); - }); + }); + expect(propositions.length).toEqual(3); + propositions.forEach(proposition => { + expect(proposition.items.length).toEqual(1); + proposition.items.forEach(item => { + expect(expectedItemIds.indexOf(item.id) > -1); }); }); }); it("it should not mutate original propositions", () => { - executeDecisions.and.returnValue(Promise.resolve(MIXED_PROPOSITIONS)); - - const applyPropositions = createApplyPropositions({ - executeDecisions - }); + const applyPropositions = createApplyPropositions({ render }); const originalPropositions = clone(MIXED_PROPOSITIONS); - return applyPropositions({ + const result = applyPropositions({ propositions: originalPropositions, metadata: METADATA - }).then(result => { - let numReturnedPropositions = 0; - expect(originalPropositions).toEqual(MIXED_PROPOSITIONS); - result.propositions.forEach(proposition => { - const [original] = originalPropositions.filter( - originalProposition => originalProposition.id === proposition.id - ); - if (original) { - numReturnedPropositions += 1; - expect(proposition).not.toBe(original); - } - }); - expect(numReturnedPropositions).toEqual(3); }); + + let numReturnedPropositions = 0; + expect(originalPropositions).toEqual(MIXED_PROPOSITIONS); + result.propositions.forEach(proposition => { + const [original] = originalPropositions.filter( + originalProposition => originalProposition.id === proposition.id + ); + if (original) { + numReturnedPropositions += 1; + expect(proposition).not.toBe(original); + } + }); + expect(numReturnedPropositions).toEqual(4); }); }); diff --git a/test/unit/specs/components/Personalization/createAutoRenderingHandler.spec.js b/test/unit/specs/components/Personalization/createAutoRenderingHandler.spec.js deleted file mode 100644 index e6756d060..000000000 --- a/test/unit/specs/components/Personalization/createAutoRenderingHandler.spec.js +++ /dev/null @@ -1,239 +0,0 @@ -/* -Copyright 2021 Adobe. All rights reserved. -This file is licensed to you under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. You may obtain a copy -of the License at http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software distributed under -the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS -OF ANY KIND, either express or implied. See the License for the specific language -governing permissions and limitations under the License. -*/ - -import { - CART_VIEW_DECISIONS, - PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS, - SCOPES_FOO1_FOO2_DECISIONS -} from "./responsesMock/eventResponses"; -import createAutoRenderingHandler from "../../../../../src/components/Personalization/createAutoRenderingHandler"; -import isPageWideScope from "../../../../../src/components/Personalization/utils/isPageWideScope"; - -describe("Personalization::createAutoRenderingHandler", () => { - let viewCache; - let executeDecisions; - let showContainers; - let collect; - let pageWideScopeDecisions; - let nonAutoRenderableDecisions; - - beforeEach(() => { - showContainers = jasmine.createSpy("showContainers"); - viewCache = jasmine.createSpyObj("viewCache", ["getView"]); - collect = jasmine.createSpy("collect"); - executeDecisions = jasmine.createSpy("executeDecisions"); - pageWideScopeDecisions = PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS; - nonAutoRenderableDecisions = SCOPES_FOO1_FOO2_DECISIONS; - }); - - it("it should fetch decisions from cache when viewName is present", () => { - const viewName = "cart"; - const executeViewDecisionsPromise = { - then: callback => callback(CART_VIEW_DECISIONS) - }; - const executePageWideScopeDecisionsPromise = { - then: callback => - callback(PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS) - }; - - viewCache.getView.and.returnValue(Promise.resolve(CART_VIEW_DECISIONS)); - const autorenderingHandler = createAutoRenderingHandler({ - viewCache, - executeDecisions, - showContainers, - collect - }); - - executeDecisions.and.returnValues( - executePageWideScopeDecisionsPromise, - executeViewDecisionsPromise - ); - - return autorenderingHandler({ - viewName, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }).then(result => { - expect(viewCache.getView).toHaveBeenCalledWith("cart"); - expect(executeDecisions).toHaveBeenCalledTimes(2); - expect(executeDecisions.calls.all()[0].args[0]).toEqual( - PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS - ); - expect(executeDecisions.calls.all()[1].args[0]).toEqual( - CART_VIEW_DECISIONS - ); - - result.decisions.forEach(decision => { - expect(decision.renderAttempted).toBeUndefined(); - }); - - result.propositions.forEach(proposition => { - if ( - isPageWideScope(proposition.scope) || - proposition.scope === viewName - ) { - expect(proposition.renderAttempted).toEqual(true); - } else { - expect(proposition.renderAttempted).toEqual(false); - } - }); - - expect(collect).toHaveBeenCalledTimes(2); - expect(collect.calls.all()[0].args[0]).toEqual({ - decisionsMeta: PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS - }); - expect(collect.calls.all()[1].args[0]).toEqual({ - decisionsMeta: CART_VIEW_DECISIONS, - viewName - }); - expect(showContainers).toHaveBeenCalled(); - }); - }); - - it("it should execute page wide scope decisions when no viewName", () => { - const viewName = undefined; - const executePageWideScopeDecisionsPromise = { - then: callback => - callback(PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS) - }; - executeDecisions.and.returnValue(executePageWideScopeDecisionsPromise); - - const autorenderingHandler = createAutoRenderingHandler({ - viewCache, - executeDecisions, - collect, - showContainers - }); - - return autorenderingHandler({ - viewName, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }).then(result => { - result.decisions.forEach(decision => { - expect(decision.renderAttempted).toBeUndefined(); - }); - - result.propositions.forEach(proposition => { - if (isPageWideScope(proposition.scope)) { - expect(proposition.renderAttempted).toEqual(true); - } else { - expect(proposition.renderAttempted).toEqual(false); - } - }); - expect(viewCache.getView).not.toHaveBeenCalled(); - expect(executeDecisions).toHaveBeenCalledTimes(1); - expect(executeDecisions).toHaveBeenCalledWith(pageWideScopeDecisions); - expect(collect).toHaveBeenCalledTimes(1); - expect(collect).toHaveBeenCalledWith({ - decisionsMeta: pageWideScopeDecisions - }); - expect(showContainers).toHaveBeenCalled(); - }); - }); - - it("it calls collect if cache returns empty array for a view", () => { - const viewName = "cart"; - const promise = { - then: callback => callback([]) - }; - const executePageWideScopeDecisionsPromise = { - then: callback => - callback(PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS) - }; - const executeViewDecisionsPromise = { - then: callback => callback([]) - }; - viewCache.getView.and.returnValue(promise); - executeDecisions.and.returnValues( - executePageWideScopeDecisionsPromise, - executeViewDecisionsPromise - ); - - const autorenderingHandler = createAutoRenderingHandler({ - viewCache, - executeDecisions, - showContainers, - collect - }); - - return autorenderingHandler({ - viewName, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }).then(result => { - expect(viewCache.getView).toHaveBeenCalledWith("cart"); - expect(executeDecisions).toHaveBeenCalledWith(pageWideScopeDecisions); - expect(showContainers).toHaveBeenCalled(); - expect(collect).toHaveBeenCalledTimes(2); - expect(collect.calls.all()[1].args[0]).toEqual({ - decisionsMeta: [], - viewName - }); - result.decisions.forEach(decision => { - expect(decision.renderAttempted).toBeUndefined(); - }); - - result.propositions.forEach(proposition => { - if ( - isPageWideScope(proposition.scope) || - proposition.scope === viewName - ) { - expect(proposition.renderAttempted).toEqual(true); - } else { - expect(proposition.renderAttempted).toEqual(false); - } - }); - }); - }); - - it("it shouldn't call collect when no pageWideScopeDecisions and no viewName", () => { - const viewName = undefined; - - const executePageWideScopeDecisionsPromise = { - then: callback => callback([]) - }; - - executeDecisions.and.returnValue(executePageWideScopeDecisionsPromise); - - const autorenderingHandler = createAutoRenderingHandler({ - viewCache, - executeDecisions, - showContainers, - collect - }); - - return autorenderingHandler({ - viewName, - pageWideScopeDecisions, - nonAutoRenderableDecisions - }).then(result => { - expect(viewCache.getView).not.toHaveBeenCalled(); - expect(showContainers).toHaveBeenCalled(); - expect(collect).not.toHaveBeenCalled(); - result.decisions.forEach(decision => { - expect(decision.renderAttempted).toBeUndefined(); - }); - - result.propositions.forEach(proposition => { - if ( - isPageWideScope(proposition.scope) || - proposition.scope === viewName - ) { - expect(proposition.renderAttempted).toEqual(true); - } else { - expect(proposition.renderAttempted).toEqual(false); - } - }); - }); - }); -}); diff --git a/test/unit/specs/components/Personalization/createComponent.spec.js b/test/unit/specs/components/Personalization/createComponent.spec.js index fcb8d92d3..e674d3943 100644 --- a/test/unit/specs/components/Personalization/createComponent.spec.js +++ b/test/unit/specs/components/Personalization/createComponent.spec.js @@ -24,6 +24,7 @@ describe("Personalization", () => { let event; let personalizationComponent; let setTargetMigration; + let cacheUpdate; const build = () => { personalizationComponent = createComponent({ @@ -56,8 +57,10 @@ describe("Personalization", () => { mergeQuery = jasmine.createSpy("mergeQuery"); viewCache = jasmine.createSpyObj("viewCache", [ "isInitialized", - "storeViews" + "createCacheUpdate" ]); + cacheUpdate = jasmine.createSpyObj("cacheUpdate", ["update", "cancel"]); + viewCache.createCacheUpdate.and.returnValue(cacheUpdate); setTargetMigration = jasmine.createSpy("setTargetMigration"); build(); @@ -85,7 +88,7 @@ describe("Personalization", () => { expect(viewChangeHandler).not.toHaveBeenCalled(); expect(onClickHandler).not.toHaveBeenCalled(); expect(showContainers).not.toHaveBeenCalled(); - expect(viewCache.storeViews).not.toHaveBeenCalled(); + expect(viewCache.createCacheUpdate).not.toHaveBeenCalled(); }); it("should trigger pageLoad if there are decisionScopes", () => { @@ -104,7 +107,7 @@ describe("Personalization", () => { expect(viewChangeHandler).not.toHaveBeenCalled(); expect(mergeQuery).not.toHaveBeenCalled(); expect(onClickHandler).not.toHaveBeenCalled(); - expect(viewCache.storeViews).toHaveBeenCalled(); + expect(viewCache.createCacheUpdate).toHaveBeenCalled(); }); it("should trigger pageLoad if cache is not initialized", () => { const renderDecisions = false; @@ -124,7 +127,7 @@ describe("Personalization", () => { expect(viewChangeHandler).not.toHaveBeenCalled(); expect(mergeQuery).not.toHaveBeenCalled(); expect(onClickHandler).not.toHaveBeenCalled(); - expect(viewCache.storeViews).toHaveBeenCalled(); + expect(viewCache.createCacheUpdate).toHaveBeenCalled(); }); it("should trigger viewHandler if cache is initialized and viewName is provided", () => { const renderDecisions = false; @@ -145,7 +148,7 @@ describe("Personalization", () => { expect(viewChangeHandler).toHaveBeenCalled(); expect(mergeQuery).not.toHaveBeenCalled(); expect(onClickHandler).not.toHaveBeenCalled(); - expect(viewCache.storeViews).not.toHaveBeenCalled(); + expect(viewCache.createCacheUpdate).not.toHaveBeenCalled(); }); it("should trigger onClickHandler at onClick", () => { personalizationComponent.lifecycle.onClick({ event }); diff --git a/test/unit/specs/components/Personalization/createExecuteDecisions.spec.js b/test/unit/specs/components/Personalization/createExecuteDecisions.spec.js deleted file mode 100644 index 31759f179..000000000 --- a/test/unit/specs/components/Personalization/createExecuteDecisions.spec.js +++ /dev/null @@ -1,127 +0,0 @@ -/* -Copyright 2020 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 createExecuteDecisions from "../../../../../src/components/Personalization/createExecuteDecisions"; - -describe("Personalization::createExecuteDecisions", () => { - let logger; - let executeActions; - let collect; - - const decisions = [ - { - id: 1, - scope: "foo", - scopeDetails: { - test: "blah1" - }, - items: [ - { - schema: "https://ns.adobe.com/personalization/dom-action", - data: { - type: "setHtml", - selector: "#foo", - content: "
Unsupported tag content
' - }, - { - type: "customCode", - selector: "BODY > *:eq(0)", - content: "Some custom content for the home page
" + } + } + ] + }; + const proposition = createProposition(handle); + redirectHandler(proposition); + expect(next).toHaveBeenCalledOnceWith(proposition); + expect(proposition.getRedirectUrl()).toBeUndefined(); + }); +}); diff --git a/test/unit/specs/components/Personalization/handlers/createRender.spec.js b/test/unit/specs/components/Personalization/handlers/createRender.spec.js new file mode 100644 index 000000000..c191c568e --- /dev/null +++ b/test/unit/specs/components/Personalization/handlers/createRender.spec.js @@ -0,0 +1,87 @@ +import createRender from "../../../../../../src/components/Personalization/handlers/createRender"; + +describe("Personalization::handlers::createRender", () => { + let handleChain; + let collect; + let executeRedirect; + let logger; + let showContainers; + + let proposition1; + let proposition2; + + let render; + + beforeEach(() => { + handleChain = jasmine.createSpy("handleChain"); + collect = jasmine.createSpy("collect"); + executeRedirect = jasmine.createSpy("executeRedirect"); + logger = jasmine.createSpyObj("logger", ["warn"]); + showContainers = jasmine.createSpy("showContainers"); + proposition1 = jasmine.createSpyObj("proposition1", [ + "getRedirectUrl", + "addToNotifications", + "render" + ]); + proposition2 = jasmine.createSpyObj("proposition2", [ + "getRedirectUrl", + "addToNotifications", + "render" + ]); + render = createRender({ + handleChain, + collect, + executeRedirect, + logger, + showContainers + }); + }); + + it("does nothing with an empty array", async () => { + const returnValue = await render([]); + expect(handleChain).not.toHaveBeenCalled(); + expect(collect).not.toHaveBeenCalled(); + expect(executeRedirect).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(showContainers).not.toHaveBeenCalled(); + expect(returnValue).toEqual([]); + }); + + it("returns notifications", async () => { + proposition1.render.and.returnValue("rendered1"); + proposition2.render.and.returnValue("rendered2"); + const returnValue = await render([proposition1, proposition2]); + expect(handleChain).toHaveBeenCalledWith(proposition1); + expect(handleChain).toHaveBeenCalledWith(proposition2); + expect(returnValue).toEqual(["rendered1", "rendered2"]); + }); + + it("returns empty notifications", async () => { + const returnValue = await render([proposition1, proposition2]); + expect(returnValue).toEqual([]); + }); + + it("handles a redirect", async () => { + proposition1.getRedirectUrl.and.returnValue("redirect1"); + collect.and.returnValue(Promise.resolve()); + proposition1.addToNotifications.and.callFake(array => { + array.push("notification1"); + }); + await render([proposition1, proposition2]); + expect(executeRedirect).toHaveBeenCalledWith("redirect1"); + expect(collect).toHaveBeenCalledOnceWith({ + decisionsMeta: ["notification1"] + }); + expect(showContainers).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("handles an error in a redirect", async () => { + proposition1.getRedirectUrl.and.returnValue("redirect1"); + collect.and.returnValue(Promise.resolve()); + executeRedirect.and.throwError("error1"); + await render([proposition1, proposition2]); + expect(showContainers).toHaveBeenCalledOnceWith(); + expect(logger.warn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/specs/components/Personalization/handlers/proposition.spec.js b/test/unit/specs/components/Personalization/handlers/proposition.spec.js new file mode 100644 index 000000000..b501990e0 --- /dev/null +++ b/test/unit/specs/components/Personalization/handlers/proposition.spec.js @@ -0,0 +1,250 @@ +import { + createProposition, + buildReturnedDecisions, + buildReturnedPropositions +} from "../../../../../../src/components/Personalization/handlers/proposition"; + +describe("Personalization::handlers", () => { + describe("createProposition", () => { + it("returns the handle", () => { + const handle = { id: "id", scope: "scope", scopeDetails: "scopeDetails" }; + const proposition = createProposition(handle); + expect(proposition.getHandle()).toEqual(handle); + }); + it("is okay with an empty handle", () => { + const proposition = createProposition({}); + expect(proposition.getHandle()).toEqual({}); + }); + it("returns the item meta", () => { + const handle = { + id: "id", + scope: "scope", + scopeDetails: "scopeDetails", + other: "other", + items: [{}] + }; + const proposition = createProposition(handle); + expect(proposition.getItemMeta(0)).toEqual({ + id: "id", + scope: "scope", + scopeDetails: "scopeDetails" + }); + }); + it("extracts the trackingLabel in the item meta", () => { + const handle = { + id: "id", + scope: "scope", + scopeDetails: "scopeDetails", + items: [ + { characteristics: { trackingLabel: "trackingLabel1" } }, + { characteristics: { trackingLabel: "trackingLabel2" } } + ] + }; + const proposition = createProposition(handle); + expect(proposition.getItemMeta(1)).toEqual({ + id: "id", + scope: "scope", + scopeDetails: "scopeDetails", + trackingLabel: "trackingLabel2" + }); + }); + it("saves the redirect", () => { + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + proposition.redirect("redirectUrl"); + expect(proposition.getRedirectUrl()).toEqual("redirectUrl"); + }); + it("includes the redirect in the notifications", () => { + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + proposition.redirect("redirectUrl"); + const notifications = []; + proposition.addToNotifications(notifications); + expect(notifications).toEqual([ + { id: "id1", scope: undefined, scopeDetails: undefined } + ]); + }); + it("includes the redirect in the returned propositions", () => { + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + proposition.redirect("redirectUrl"); + const propositions = []; + proposition.addToReturnedPropositions(propositions); + expect(propositions).toEqual([ + { id: "id1", items: [{ a: 1 }, { b: 2 }], renderAttempted: true } + ]); + }); + it("doesn't include the redirect in the returned decisions", () => { + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + proposition.redirect("redirectUrl"); + const decisions = []; + proposition.addToReturnedDecisions(decisions); + expect(decisions).toEqual([]); + }); + it("returns undefined for the redirect URL when it is not set", () => { + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + expect(proposition.getRedirectUrl()).toBeUndefined(); + }); + it("includes the proposition in the returned propositions when not rendered", () => { + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + const propositions = []; + proposition.addToReturnedPropositions(propositions); + expect(propositions).toEqual([ + { id: "id1", items: [{ a: 1 }, { b: 2 }], renderAttempted: false } + ]); + }); + it("includes the proposition in the returned decisions when not rendered", () => { + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + const decisions = []; + proposition.addToReturnedDecisions(decisions); + expect(decisions).toEqual([{ id: "id1", items: [{ a: 1 }, { b: 2 }] }]); + }); + it("does not include the notification if it isn't rendered", () => { + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + const notifications = []; + proposition.addToNotifications(notifications); + expect(notifications).toEqual([]); + }); + it("handles a completely rendered item", async () => { + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + proposition.includeInDisplayNotification(); + proposition.addRenderer(0, () => {}); + proposition.addRenderer(1, () => {}); + + const notification = await proposition.render({ enabled: false }); + expect(notification).toEqual({ + id: "id1", + scope: undefined, + scopeDetails: undefined + }); + const propositions = []; + proposition.addToReturnedPropositions(propositions); + expect(propositions).toEqual([ + { id: "id1", items: [{ a: 1 }, { b: 2 }], renderAttempted: true } + ]); + const decisions = []; + proposition.addToReturnedDecisions(decisions); + expect(decisions).toEqual([]); + }); + it("handles a partially rendered item", async () => { + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + proposition.includeInDisplayNotification(); + proposition.addRenderer(0, () => {}); + + const notification = await proposition.render({ enabled: false }); + expect(notification).toEqual({ + id: "id1", + scope: undefined, + scopeDetails: undefined + }); + const propositions = []; + proposition.addToReturnedPropositions(propositions); + expect(propositions).toEqual([ + { id: "id1", items: [{ a: 1 }], renderAttempted: true }, + { id: "id1", items: [{ b: 2 }], renderAttempted: false } + ]); + const decisions = []; + proposition.addToReturnedDecisions(decisions); + expect(decisions).toEqual([{ id: "id1", items: [{ b: 2 }] }]); + }); + it("renders items", async () => { + const logger = jasmine.createSpyObj("logger", ["info", "warn"]); + logger.enabled = true; + const renderer1 = jasmine.createSpy("renderer1"); + const renderer2 = jasmine.createSpy("renderer2"); + const proposition = createProposition({ + id: "id1", + items: [{ a: 1 }, { b: 2 }] + }); + proposition.includeInDisplayNotification(); + proposition.addRenderer(0, renderer1); + proposition.addRenderer(1, renderer2); + await proposition.render(logger); + expect(renderer1).toHaveBeenCalledTimes(1); + expect(renderer2).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith(`Action {"a":1} executed.`); + expect(logger.info).toHaveBeenCalledWith(`Action {"b":2} executed.`); + }); + }); + + describe("buildReturnedDecisions", () => { + let p1; + let p2; + let p3; + + beforeEach(() => { + p1 = jasmine.createSpyObj("p1", ["addToReturnedDecisions"]); + p2 = jasmine.createSpyObj("p2", ["addToReturnedDecisions"]); + p3 = jasmine.createSpyObj("p3", ["addToReturnedDecisions"]); + }); + + it("returns empty array when no propositions", () => { + const returnedDecisions = buildReturnedDecisions([]); + expect(returnedDecisions).toEqual([]); + }); + it("returns added decisions", () => { + p1.addToReturnedDecisions.and.callFake(array => { + array.push("decision1"); + }); + p3.addToReturnedDecisions.and.callFake(array => { + array.push("decision3"); + }); + const returnedDecisions = buildReturnedDecisions([p1, p2, p3]); + expect(returnedDecisions).toEqual(["decision1", "decision3"]); + }); + }); + + describe("buildReturnedPropositions", () => { + let p1; + let p2; + let p3; + + beforeEach(() => { + p1 = jasmine.createSpyObj("p1", ["addToReturnedPropositions"]); + p2 = jasmine.createSpyObj("p2", ["addToReturnedPropositions"]); + p3 = jasmine.createSpyObj("p3", ["addToReturnedPropositions"]); + }); + + it("returns empty array when no propositions", () => { + const returnedPropositions = buildReturnedPropositions([]); + expect(returnedPropositions).toEqual([]); + }); + it("returns added propositions", () => { + p1.addToReturnedPropositions.and.callFake(array => { + array.push("proposition1"); + }); + p3.addToReturnedPropositions.and.callFake(array => { + array.push("proposition3"); + }); + const returnedPropositions = buildReturnedPropositions([p1, p2, p3]); + expect(returnedPropositions).toEqual(["proposition1", "proposition3"]); + }); + }); +}); diff --git a/test/unit/specs/components/Personalization/responsesMock/eventResponses.js b/test/unit/specs/components/Personalization/responsesMock/eventResponses.js index 329b340c5..9482fb28c 100644 --- a/test/unit/specs/components/Personalization/responsesMock/eventResponses.js +++ b/test/unit/specs/components/Personalization/responsesMock/eventResponses.js @@ -374,8 +374,7 @@ export const MIXED_PROPOSITIONS = [ id: "1202449" } } - ], - renderAttempted: false + ] }, { id: "AT:eyJhY3Rpdml0eUlkIjoiMTQxNjY0IiwiZXhwZXJpZW5jZUlkIjoiMCJ9", @@ -401,7 +400,6 @@ export const MIXED_PROPOSITIONS = [ { id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", scope: "__view__", - renderAttempted: true, items: [ { id: "442358", @@ -417,7 +415,6 @@ export const MIXED_PROPOSITIONS = [ { id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn2=", scope: "__view__", - renderAttempted: false, items: [ { id: "442379", diff --git a/test/unit/specs/components/Personalization/topLevel/buildAlloy.js b/test/unit/specs/components/Personalization/topLevel/buildAlloy.js new file mode 100644 index 000000000..be2d9a204 --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/buildAlloy.js @@ -0,0 +1,187 @@ +/* +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 createEvent from "../../../../../../src/core/createEvent"; +import flushPromiseChains from "../../../../helpers/flushPromiseChains"; +import createComponent from "../../../../../../src/components/Personalization/createComponent"; +import createCollect from "../../../../../../src/components/Personalization/createCollect"; +import createFetchDataHandler from "../../../../../../src/components/Personalization/createFetchDataHandler"; +import collectClicks from "../../../../../../src/components/Personalization/dom-actions/clicks/collectClicks"; +import isAuthoringModeEnabled from "../../../../../../src/components/Personalization/utils/isAuthoringModeEnabled"; +import { + mergeDecisionsMeta, + mergeQuery +} from "../../../../../../src/components/Personalization/event"; +import createOnClickHandler from "../../../../../../src/components/Personalization/createOnClickHandler"; +import createViewCacheManager from "../../../../../../src/components/Personalization/createViewCacheManager"; +import createViewChangeHandler from "../../../../../../src/components/Personalization/createViewChangeHandler"; +import createClickStorage from "../../../../../../src/components/Personalization/createClickStorage"; +import createApplyPropositions from "../../../../../../src/components/Personalization/createApplyPropositions"; +import createSetTargetMigration from "../../../../../../src/components/Personalization/createSetTargetMigration"; +import { createCallbackAggregator, assign } from "../../../../../../src/utils"; +import createRender from "../../../../../../src/components/Personalization/handlers/createRender"; +import createDomActionHandler from "../../../../../../src/components/Personalization/handlers/createDomActionHandler"; +import createMeasurementSchemaHandler from "../../../../../../src/components/Personalization/handlers/createMeasurementSchemaHandler"; +import createRedirectHandler from "../../../../../../src/components/Personalization/handlers/createRedirectHandler"; +import createHtmlContentHandler from "../../../../../../src/components/Personalization/handlers/createHtmlContentHandler"; +import { isPageWideSurface } from "../../../../../../src/components/Personalization/utils/surfaceUtils"; +import { createProposition } from "../../../../../../src/components/Personalization/handlers/proposition"; + +const createAction = renderFunc => ({ selector, content }) => { + renderFunc(selector, content); + if (selector === "#error") { + return Promise.reject(new Error(`Error while rendering ${content}`)); + } + return Promise.resolve(); +}; + +const buildComponent = ({ + actions, + config, + logger, + eventManager, + getPageLocation, + window, + hideContainers, + showContainers +}) => { + const initDomActionsModulesMocks = () => { + return { + setHtml: createAction(actions.setHtml), + customCode: createAction(actions.prependHtml), + setText: createAction(actions.setText), + setAttribute: createAction(actions.setAttributes), + setImageSource: createAction(actions.swapImage), + setStyle: createAction(actions.setStyles), + move: createAction(actions.setStyles), + resize: createAction(actions.setStyles), + rearrange: createAction(actions.rearrangeChildren), + remove: createAction(actions.removeNode), + insertAfter: createAction(actions.insertHtmlAfter), + insertBefore: createAction(actions.insertHtmlBefore), + replaceHtml: createAction(actions.replaceHtml), + appendHtml: createAction(actions.appendHtml), + prependHtml: createAction(actions.prependHtml) + }; + }; + + const { targetMigrationEnabled, prehidingStyle } = config; + const collect = createCollect({ eventManager, mergeDecisionsMeta }); + + const { + getClickMetasBySelector, + getClickSelectors, + storeClickMetrics + } = createClickStorage(); + + const viewCache = createViewCacheManager({ createProposition }); + const modules = initDomActionsModulesMocks(); + + const noOpHandler = () => undefined; + const preprocess = action => action; + const domActionHandler = createDomActionHandler({ + next: noOpHandler, + isPageWideSurface, + modules, + storeClickMetrics, + preprocess + }); + const measurementSchemaHandler = createMeasurementSchemaHandler({ + next: domActionHandler + }); + const redirectHandler = createRedirectHandler({ + next: measurementSchemaHandler + }); + const htmlContentHandler = createHtmlContentHandler({ + next: redirectHandler, + modules, + preprocess + }); + + const render = createRender({ + handleChain: htmlContentHandler, + collect, + executeRedirect: url => window.location.replace(url), + logger + }); + const fetchDataHandler = createFetchDataHandler({ + prehidingStyle, + showContainers, + hideContainers, + mergeQuery, + collect, + render + }); + const onClickHandler = createOnClickHandler({ + mergeDecisionsMeta, + collectClicks, + getClickSelectors, + getClickMetasBySelector + }); + const viewChangeHandler = createViewChangeHandler({ + mergeDecisionsMeta, + render, + viewCache + }); + const applyPropositions = createApplyPropositions({ + render + }); + const setTargetMigration = createSetTargetMigration({ + targetMigrationEnabled + }); + return createComponent({ + getPageLocation, + logger, + fetchDataHandler, + viewChangeHandler, + onClickHandler, + isAuthoringModeEnabled, + mergeQuery, + viewCache, + showContainers, + applyPropositions, + setTargetMigration + }); +}; + +export default mocks => { + const component = buildComponent(mocks); + const { response } = mocks; + return { + async sendEvent({ + xdm, + data, + renderDecisions, + decisionScopes, + personalization + }) { + const event = createEvent(); + event.setUserXdm(xdm); + event.setUserData(data); + const callbacks = createCallbackAggregator(); + await component.lifecycle.onBeforeEvent({ + event, + renderDecisions, + decisionScopes, + personalization, + onResponse: callbacks.add + }); + const results = await callbacks.call({ response }); + const result = assign({}, ...results); + await flushPromiseChains(); + event.finalize(); + return { event, result }; + }, + applyPropositions(args) { + return component.commands.applyPropositions.run(args); + } + }; +}; diff --git a/test/unit/specs/components/Personalization/topLevel/buildMocks.js b/test/unit/specs/components/Personalization/topLevel/buildMocks.js new file mode 100644 index 000000000..be2023ca3 --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/buildMocks.js @@ -0,0 +1,77 @@ +/* +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 createEvent from "../../../../../../src/core/createEvent"; +import createResponse from "../../../../../functional/helpers/createResponse"; + +export default decisions => { + const response = createResponse({ + content: { + handle: decisions.map(payload => ({ + type: "personalization:decisions", + payload + })) + } + }); + + const actions = jasmine.createSpyObj("actions", [ + "createAction", + "setHtml", + "setText", + "setAttributes", + "swapImage", + "setStyles", + "rearrangeChildren", + "removeNode", + "replaceHtml", + "appendHtml", + "prependHtml", + "insertHtmlAfter", + "insertHtmlBefore" + ]); + + const config = { + targetMigrationEnabled: true, + prehidingStyle: "myprehidingstyle" + }; + const logger = { + warn: spyOn(console, "warn").and.callThrough(), + error: spyOn(console, "error").and.callThrough() + }; + const sendEvent = jasmine.createSpy("sendEvent"); + const eventManager = { + createEvent, + async sendEvent(event) { + event.finalize(); + sendEvent(event.toJSON()); + return Promise.resolve(); + } + }; + const getPageLocation = () => new URL("http://example.com/home"); + const window = { + location: jasmine.createSpyObj("location", ["replace"]) + }; + const hideContainers = jasmine.createSpy("hideContainers"); + const showContainers = jasmine.createSpy("showContainers"); + + return { + actions, + config, + logger, + sendEvent, + eventManager, + getPageLocation, + window, + hideContainers, + showContainers, + response + }; +}; diff --git a/test/unit/specs/components/Personalization/topLevel/cartViewDecisions.spec.js b/test/unit/specs/components/Personalization/topLevel/cartViewDecisions.spec.js new file mode 100644 index 000000000..6aa3faca5 --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/cartViewDecisions.spec.js @@ -0,0 +1,257 @@ +import { CART_VIEW_DECISIONS } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; +import resetMocks from "./resetMocks"; +import flushPromiseChains from "../../../../helpers/flushPromiseChains"; + +describe("PersonalizationComponent", () => { + it("CART_VIEW_DECISIONS", async () => { + const mocks = buildMocks(CART_VIEW_DECISIONS); + const alloy = buildAlloy(mocks); + let { event, result } = await alloy.sendEvent( + { + renderDecisions: true + }, + CART_VIEW_DECISIONS + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + expect(result).toEqual({ + propositions: [], + decisions: [] + }); + expect(mocks.sendEvent).not.toHaveBeenCalled(); + + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + + resetMocks(mocks); + ({ event, result } = await alloy.sendEvent( + { + renderDecisions: true, + xdm: { + web: { + webPageDetails: { + viewName: "cart" + } + } + } + }, + [] + )); + + expect(event.toJSON()).toEqual({ + xdm: { + _experience: { + decisioning: { + propositions: [ + { + id: "TNT:activity4:experience9", + scope: "cart", + scopeDetails: { + blah: "test", + characteristics: { + scopeType: "view" + } + } + } + ], + propositionEventType: { + display: 1 + } + } + }, + web: { + webPageDetails: { + viewName: "cart" + } + } + } + }); + expect(result).toEqual({ + propositions: [ + { + renderAttempted: true, + id: "TNT:activity4:experience9", + scope: "cart", + items: [ + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo", + content: "Some custom content for the home page
", + format: "text/html", + id: "1202448" + } + } + ] + }, + { + renderAttempted: false, + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", + scope: "home", + items: [ + { + id: "442360", + schema: "https://ns.adobe.com/personalization/json-content-item", + data: { + content: "{'field1': 'custom content'}", + format: "text/javascript", + id: "1202449" + } + } + ] + }, + { + renderAttempted: false, + id: "AT:eyJhY3Rpdml0eUlkIjoiMTQxNjY0IiwiZXhwZXJpZW5jZUlkIjoiMCJ9", + scope: "home", + items: [ + { + id: "xcore:personalized-offer:134ce877e13a04ca", + etag: "4", + schema: + "https://ns.adobe.com/experience/offer-management/content-component-html", + data: { + id: "xcore:personalized-offer:134ce877e13a04ca", + format: "text/html", + language: ["en-us"], + content: "An html offer from Offer Decisioning
", + characteristics: { + testing: "true" + } + } + } + ] + }, + { + renderAttempted: false, + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn0=", + scope: "home", + items: [ + { + id: "442358", + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "click", + format: "application/vnd.adobe.target.dom-action", + selector: "#root" + } + } + ] + } + ]) + ); + expect(result.decisions).toEqual( + jasmine.arrayWithExactContents([ + { + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", + scope: "home", + items: [ + { + id: "442359", + schema: "https://ns.adobe.com/personalization/html-content-item", + data: { + content: "Some custom content for the home page
", + format: "text/html", + id: "1202448" + } + } + ] + }, + { + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", + scope: "home", + items: [ + { + id: "442360", + schema: "https://ns.adobe.com/personalization/json-content-item", + data: { + content: "{'field1': 'custom content'}", + format: "text/javascript", + id: "1202449" + } + } + ] + }, + { + id: "AT:eyJhY3Rpdml0eUlkIjoiMTQxNjY0IiwiZXhwZXJpZW5jZUlkIjoiMCJ9", + scope: "home", + items: [ + { + id: "xcore:personalized-offer:134ce877e13a04ca", + etag: "4", + schema: + "https://ns.adobe.com/experience/offer-management/content-component-html", + data: { + id: "xcore:personalized-offer:134ce877e13a04ca", + format: "text/html", + language: ["en-us"], + content: "An html offer from Offer Decisioning
", + characteristics: { + testing: "true" + } + } + } + ] + }, + { + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn0=", + scope: "home", + items: [ + { + id: "442358", + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "click", + format: "application/vnd.adobe.target.dom-action", + selector: "#root" + } + } + ] + } + ]) + ); + expect(mocks.sendEvent).not.toHaveBeenCalled(); + + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + + resetMocks(mocks); + const applyPropositionsResult = await alloy.applyPropositions({ + propositions: result.propositions, + metadata: { + home: { + selector: "#myhomeselector", + actionType: "appendHtml" + } + } + }); + expect(applyPropositionsResult.propositions).toEqual([ + { + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn0=", + scope: "home", + items: [ + { + id: "442358", + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "click", + format: "application/vnd.adobe.target.dom-action", + selector: "#root" + } + } + ], + renderAttempted: true, + scopeDetails: undefined + }, + { + id: "AT:eyJhY3Rpdml0eUlkIjoiNDQyMzU4IiwiZXhwZXJpZW5jZUlkIjoiIn1=", + scope: "home", + items: [ + { + id: "442359", + schema: "https://ns.adobe.com/personalization/html-content-item", + data: { + content: "Some custom content for the home page
", + format: "text/html", + id: "1202448", + selector: "#myhomeselector", + type: "appendHtml" + } + } + ], + renderAttempted: true, + scopeDetails: undefined + } + ]); + expect(applyPropositionsResult.decisions).toBeUndefined(); + + expect(mocks.sendEvent).not.toHaveBeenCalled(); + expect(mocks.actions.appendHtml).toHaveBeenCalledOnceWith( + "#myhomeselector", + "Some custom content for the home page
" + ); + expect(mocks.logger.warn).not.toHaveBeenCalled(); + expect(mocks.logger.error).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/specs/components/Personalization/topLevel/pageWideDecisionsWithDomActionSchemaItems.spec.js b/test/unit/specs/components/Personalization/topLevel/pageWideDecisionsWithDomActionSchemaItems.spec.js new file mode 100644 index 000000000..3d936ca45 --- /dev/null +++ b/test/unit/specs/components/Personalization/topLevel/pageWideDecisionsWithDomActionSchemaItems.spec.js @@ -0,0 +1,144 @@ +import { PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS } from "../responsesMock/eventResponses"; + +import buildMocks from "./buildMocks"; +import buildAlloy from "./buildAlloy"; + +describe("PersonalizationComponent", () => { + it("PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS", async () => { + const mocks = buildMocks(PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS); + const alloy = buildAlloy(mocks); + const { event, result } = await alloy.sendEvent( + { + renderDecisions: true + }, + PAGE_WIDE_DECISIONS_WITH_DOM_ACTION_SCHEMA_ITEMS + ); + expect(event.toJSON()).toEqual({ + query: { + personalization: { + schemas: [ + "https://ns.adobe.com/personalization/default-content-item", + "https://ns.adobe.com/personalization/html-content-item", + "https://ns.adobe.com/personalization/json-content-item", + "https://ns.adobe.com/personalization/redirect-item", + "https://ns.adobe.com/personalization/dom-action" + ], + decisionScopes: ["__view__"], + surfaces: ["web://example.com/home"] + } + } + }); + expect(result).toEqual({ + propositions: [ + { + renderAttempted: true, + id: "TNT:activity1:experience1", + scope: "__view__", + items: [ + { + schema: "https://ns.adobe.com/personalization/dom-action", + data: { + type: "setHtml", + selector: "#foo", + content: "