From f2fbeaa7970af2573fa4f6a595c1db7c8d149c09 Mon Sep 17 00:00:00 2001 From: Raghav Sharma <118168183+sharmrj@users.noreply.github.com> Date: Wed, 4 Dec 2024 14:48:14 +0530 Subject: [PATCH 01/11] MWPW-161273 Standalone Gnav needs a release cycle [Bundle] (#3132) * bundled standalone gnav and footer * Fixed dark mode; load css from navigation.js * Refactored global footer to no longer use milo modal to render region-nav * global footer unit test * fix lint * Added keyboard navigation to the new region nav modal * export stuff from modal.js * replaced the new modal implementation with a more explicit usage of the current milo modal so that it can be bundled * code compatibility ignore pattern * Fix keyboard navigation unit tests * fixed footer unit test after changing the implementation of the region nav (again) * added sourcemaps; fixed dark mode issue * navigation unit tests * Cover uncovered lines in global-footer.js * prevent FOUC in region nav * built latest gnav changes * modified package.json to have a files field * use evergreen css for non-bundled and built css for bundled * Fixed region picker when there's no hash * Added a workflow to release standalone feds and removed dist from the PR * changed workflow_call to workflow_dispatch * Adjusted the cd command in the workflow * added a working directory * missed a space in the gh release upload command * added GITHUB_TOKEN to the upload asset step * fixed an error with file upload in the workflow * Removed a console.log from the build script; explicitly load fragment block in footer * Removed an unused import * Renamed a funciton in the build file and added a comment * Fixed region nav breaking on certain milo consumers * Fixed region nav breaking on some milo consumers for real this time * Removed an unused import * Added logic to not call the region nav code twice * unit test * modified a standalone footer unit test slightly * Removed a comment --- .eslintrc-code-compatibility.js | 1 + .eslintrc.js | 1 + .github/workflows/release-standalone-feds.yml | 55 +++ .gitignore | 1 + libs/blocks/global-footer/global-footer.css | 14 + libs/blocks/global-footer/global-footer.js | 40 +- libs/blocks/global-navigation/base.css | 4 +- .../global-navigation/global-navigation.js | 19 +- .../utilities/getUserEntitlements.js | 1 + .../utilities/getUserEventHistory.js | 1 + .../global-navigation/utilities/utilities.js | 20 +- libs/blocks/region-nav/region-nav.css | 4 + libs/navigation/base.css | 1 + libs/navigation/bootstrapper.js | 11 +- libs/navigation/build.mjs | 57 +++ libs/navigation/dark-nav.css | 1 + libs/navigation/footer.css | 2 + libs/navigation/navigation.css | 5 + libs/navigation/navigation.js | 58 ++- libs/navigation/package-lock.json | 439 ++++++++++++++++++ libs/navigation/package.json | 16 + libs/utils/utils.js | 2 +- .../global-footer/global-footer.test.js | 28 ++ .../keyboard/mocks/global-nav-mobile.html | 2 +- .../keyboard/mocks/global-nav.html | 2 +- test/navigation/bootstrapper.test.js | 19 +- test/navigation/navigation.test.js | 3 +- 27 files changed, 749 insertions(+), 58 deletions(-) create mode 100644 .github/workflows/release-standalone-feds.yml create mode 100644 libs/navigation/base.css create mode 100755 libs/navigation/build.mjs create mode 100644 libs/navigation/dark-nav.css create mode 100644 libs/navigation/footer.css create mode 100644 libs/navigation/package-lock.json create mode 100644 libs/navigation/package.json diff --git a/.eslintrc-code-compatibility.js b/.eslintrc-code-compatibility.js index 92080c6c42..c2a8c26aca 100644 --- a/.eslintrc-code-compatibility.js +++ b/.eslintrc-code-compatibility.js @@ -14,6 +14,7 @@ module.exports = { ], ignorePatterns: [ '/libs/deps/*', + '/libs/navigation/dist/*', '/tools/loc/*', ], }; diff --git a/.eslintrc.js b/.eslintrc.js index f24adfa387..e7c17dc5fb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -62,6 +62,7 @@ module.exports = { '/libs/features/mas/*', '/tools/loc/*', '/libs/features/spectrum-web-components/*', + '/libs/navigation/dist/*', ], plugins: [ 'chai-friendly', diff --git a/.github/workflows/release-standalone-feds.yml b/.github/workflows/release-standalone-feds.yml new file mode 100644 index 0000000000..89a11a1886 --- /dev/null +++ b/.github/workflows/release-standalone-feds.yml @@ -0,0 +1,55 @@ +name: Create a Release for Standalone Feds GlobalNav and Footer +on: + workflow_dispatch: + inputs: + version: + description: 'Version' + required: true + type: string + +permissions: + contents: write + +jobs: + release-feds: + name: Release Standalone Feds + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + defaults: + run: + working-directory: ./libs/navigation + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Build Files + run: node ./build.mjs + + - name: Generate tarball + run: npm pack + + - name: Create Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "feds-standalone-v${{ inputs.version }}" \ + --repo="$GITHUB_REPOSITORY" \ + --title="@adobecom/standalone-feds v${{ inputs.version }} Release" \ + --generate-notes + + - name: Upload Files to Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "feds-standalone-v${{ inputs.version }}" "adobecom-standalone-feds-${{ inputs.version }}.tgz" diff --git a/.gitignore b/.gitignore index 168ba7ef67..4952087964 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ logs/* test-html-results/ test-results/ test-a11y-results/ +libs/navigation/dist/ diff --git a/libs/blocks/global-footer/global-footer.css b/libs/blocks/global-footer/global-footer.css index 5f32030e01..951a1d72d3 100644 --- a/libs/blocks/global-footer/global-footer.css +++ b/libs/blocks/global-footer/global-footer.css @@ -252,6 +252,20 @@ height: 12px; } +@media (min-width: 600px) { + dialog.feds-dialog { + max-width: 80vw; + width: fit-content; + } +} + +@media (min-width: 1200px) { + dialog.feds-dialog { + width: 1200px; + max-width: calc((100% - 6px) - 2em); + } +} + @media (min-width: 900px) { /* If there is too much content, float it on multiple rows */ .feds-footer-wrapper .feds-menu-content { diff --git a/libs/blocks/global-footer/global-footer.js b/libs/blocks/global-footer/global-footer.js index 2cd9f35559..6c74620291 100644 --- a/libs/blocks/global-footer/global-footer.js +++ b/libs/blocks/global-footer/global-footer.js @@ -4,8 +4,8 @@ import { decorateLinks, getMetadata, getConfig, - loadBlock, localizeLink, + loadStyle, } from '../../utils/utils.js'; import { @@ -218,6 +218,8 @@ class Footer { ${regionPickerTextElem} `; + regionPickerElem.dataset.modalPath = `${url.pathname}#_inline`; + regionPickerElem.dataset.modalHash = url.hash; const regionPickerWrapperClass = 'feds-regionPicker-wrapper'; this.elements.regionPicker = toFragment`
${regionPickerElem} @@ -231,24 +233,48 @@ class Footer { // Hash -> region selector opens a modal decorateAutoBlock(regionPickerElem); // add modal-specific attributes // TODO remove logs after finding the root cause for the region picker 404s -> MWPW-143627 + regionPickerElem.href = url.hash; if (regionPickerElem.classList[0] !== 'modal') { lanaLog({ message: `Modal block class missing from region picker pre loading the block; locale: ${locale}; regionPickerElem: ${regionPickerElem.outerHTML}`, tags: 'errorType=warn,module=global-footer', }); } - await loadBlock(regionPickerElem); // load modal logic and styles + loadStyle(`${base}/blocks/modal/modal.css`); + const { default: initModal } = await import('../modal/modal.js'); + const modal = await initModal(regionPickerElem); + + const loadRegionNav = async () => { + const block = document.querySelector('.region-nav'); + if (block && getConfig().standaloneGnav) { + // on standalone the region-nav will fail to load automatically through + // the modal calling fragment.js. In that case we will have data-failed=true + // and we should manually load region nav + // If that's not the case then we're not a standalone gnav + // and we mustn't load region-nav twice. + if (block.getAttribute('data-failed') !== 'true') return; + block.classList.add('hide'); + loadStyle(`${base}/blocks/region-nav/region-nav.css`); + const { default: initRegionNav } = await import('../region-nav/region-nav.js'); + initRegionNav(block); + // decoratePlaceholders(block, getConfig()); + block.classList.remove('hide'); + } + }; + + if (modal) await loadRegionNav(); // just in case the modal is already open + if (regionPickerElem.classList[0] !== 'modal') { lanaLog({ message: `Modal block class missing from region picker post loading the block; locale: ${locale}; regionPickerElem: ${regionPickerElem.outerHTML}`, tags: 'errorType=warn,module=global-footer', }); } - // 'decorateAutoBlock' logic replaces class name entirely, need to add it back - regionPickerElem.classList.add(regionPickerClass); regionPickerElem.addEventListener('click', () => { if (!isRegionPickerExpanded()) { regionPickerElem.setAttribute('aria-expanded', 'true'); + // wait for the modal to load before we load the region nav + window.addEventListener('milo:modal:loaded', loadRegionNav, { once: true }); } }); // Set aria-expanded to false when region modal is closed @@ -263,7 +289,8 @@ class Footer { regionSelector.href = localizeLink(regionSelector.href); decorateAutoBlock(regionSelector); // add fragment-specific class(es) this.elements.regionPicker.append(regionSelector); // add fragment after regionPickerElem - await loadBlock(regionSelector); // load fragment and replace original link + const { default: initFragment } = await import('../fragment/fragment.js'); + await initFragment(regionSelector); // load fragment and replace original link // Update aria-expanded on click regionPickerElem.addEventListener('click', (e) => { e.preventDefault(); @@ -272,6 +299,7 @@ class Footer { }); // Close region picker dropdown on outside click document.addEventListener('click', (e) => { + e.preventDefault(); if (isRegionPickerExpanded() && !e.target.closest(`.${regionPickerWrapperClass}`)) { regionPickerElem.setAttribute('aria-expanded', false); @@ -279,7 +307,7 @@ class Footer { }); } - return this.regionPicker; + return this.elements.regionPicker; }; decorateSocial = () => { diff --git a/libs/blocks/global-navigation/base.css b/libs/blocks/global-navigation/base.css index 1eb4d98d29..15a7925834 100644 --- a/libs/blocks/global-navigation/base.css +++ b/libs/blocks/global-navigation/base.css @@ -118,8 +118,8 @@ align-items: center; } -header.global-navigation { - visibility: visible; +header.global-navigation.ready { + visibility: visible !important; } /* Desktop styles */ diff --git a/libs/blocks/global-navigation/global-navigation.js b/libs/blocks/global-navigation/global-navigation.js index 4a1903171a..55815c5994 100644 --- a/libs/blocks/global-navigation/global-navigation.js +++ b/libs/blocks/global-navigation/global-navigation.js @@ -1,3 +1,4 @@ +/* eslint import/no-relative-packages: 0 */ /* eslint-disable no-async-promise-executor */ import { getConfig, @@ -21,7 +22,6 @@ import { isTangentToViewport, lanaLog, loadBaseStyles, - loadBlock, loadDecorateMenu, rootPath, loadStyles, @@ -222,7 +222,7 @@ const decorateProfileTrigger = async ({ avatar }) => { let keyboardNav; const setupKeyboardNav = async () => { keyboardNav = keyboardNav || new Promise(async (resolve) => { - const KeyboardNavigation = await loadBlock('./keyboard/index.js'); + const { default: KeyboardNavigation } = await import('./utilities/keyboard/index.js'); const instance = new KeyboardNavigation(); resolve(instance); }); @@ -425,17 +425,17 @@ class Gnav { this.block.removeEventListener('keydown', this.loadDelayed); if (this.searchPresent()) { const [ - Search, + { default: Search }, ] = await Promise.all([ - loadBlock('../features/search/gnav-search.js'), + import('./features/search/gnav-search.js'), loadStyles(rootPath('features/search/gnav-search.css')), ]); this.Search = Search; } if (!this.useUniversalNav) { - const [ProfileDropdown] = await Promise.all([ - loadBlock('../features/profile/dropdown.js'), + const [{ default: ProfileDropdown }] = await Promise.all([ + import('./features/profile/dropdown.js'), loadStyles(rootPath('features/profile/dropdown.css')), ]); this.ProfileDropdown = ProfileDropdown; @@ -540,7 +540,7 @@ class Gnav { const unavVersion = new URLSearchParams(window.location.search).get('unavVersion') || '1.3'; await Promise.all([ loadScript(`https://${environment}.adobeccstatic.com/unav/${unavVersion}/UniversalNav.js`), - loadStyles(`https://${environment}.adobeccstatic.com/unav/${unavVersion}/UniversalNav.css`), + loadStyles(`https://${environment}.adobeccstatic.com/unav/${unavVersion}/UniversalNav.css`, true), ]); const getChildren = () => { @@ -910,7 +910,7 @@ class Gnav { const menuLogic = await loadDecorateMenu(); - menuLogic.decorateMenu({ + await menuLogic.decorateMenu({ item, template, type: itemType, @@ -1021,7 +1021,7 @@ class Gnav { const breadcrumbsElem = this.block.querySelector('.breadcrumbs'); // Breadcrumbs are not initially part of the nav, need to decorate the links if (breadcrumbsElem) decorateLinks(breadcrumbsElem); - const createBreadcrumbs = await loadBlock('../features/breadcrumbs/breadcrumbs.js'); + const { default: createBreadcrumbs } = await import('./features/breadcrumbs/breadcrumbs.js'); this.elements.breadcrumbsWrapper = await createBreadcrumbs(breadcrumbsElem); return this.elements.breadcrumbsWrapper; }; @@ -1091,5 +1091,6 @@ export default async function init(block) { const mepMartech = mep?.martech || ''; block.setAttribute('daa-lh', `gnav|${getExperienceName()}${mepMartech}`); if (isDarkMode()) block.classList.add('feds--dark'); + block.classList.add('ready'); return gnav; } diff --git a/libs/blocks/global-navigation/utilities/getUserEntitlements.js b/libs/blocks/global-navigation/utilities/getUserEntitlements.js index 1166146179..b9812236b6 100644 --- a/libs/blocks/global-navigation/utilities/getUserEntitlements.js +++ b/libs/blocks/global-navigation/utilities/getUserEntitlements.js @@ -1,3 +1,4 @@ +/* eslint import/no-relative-packages: 0 */ /* eslint-disable camelcase */ import { getConfig } from '../../../utils/utils.js'; diff --git a/libs/blocks/global-navigation/utilities/getUserEventHistory.js b/libs/blocks/global-navigation/utilities/getUserEventHistory.js index e1fcc23fee..25937e34a2 100644 --- a/libs/blocks/global-navigation/utilities/getUserEventHistory.js +++ b/libs/blocks/global-navigation/utilities/getUserEventHistory.js @@ -1,3 +1,4 @@ +/* eslint import/no-relative-packages: 0 */ /* eslint-disable no-promise-executor-return, no-async-promise-executor */ import { getConfig } from '../../../utils/utils.js'; diff --git a/libs/blocks/global-navigation/utilities/utilities.js b/libs/blocks/global-navigation/utilities/utilities.js index 79104db68c..370ae1f2ca 100644 --- a/libs/blocks/global-navigation/utilities/utilities.js +++ b/libs/blocks/global-navigation/utilities/utilities.js @@ -1,3 +1,4 @@ +/* eslint import/no-relative-packages: 0 */ import { getConfig, getMetadata, loadStyle, loadLana, decorateLinks, localizeLink, } from '../../../utils/utils.js'; @@ -152,7 +153,9 @@ export function rootPath(path) { return url; } -export function loadStyles(url) { +export function loadStyles(url, override = false) { + const { standaloneGnav } = getConfig(); + if (standaloneGnav && !override) return; loadStyle(url, (e) => { if (e === 'error') { lanaLog({ @@ -173,6 +176,8 @@ export function isDarkMode() { // since they can be independent of each other. // CSS imports were not used due to duplication of file include export async function loadBaseStyles() { + const { standaloneGnav } = getConfig(); + if (standaloneGnav) return; if (isDarkMode()) { new Promise((resolve) => { loadStyle(rootPath('base.css'), resolve); }) .then(() => loadStyles(rootPath('dark-nav.css'))); @@ -182,10 +187,6 @@ export async function loadBaseStyles() { } } -export function loadBlock(path) { - return import(path).then((module) => module.default); -} - let cachedDecorateMenu; export async function loadDecorateMenu() { if (cachedDecorateMenu) return cachedDecorateMenu; @@ -195,15 +196,12 @@ export async function loadDecorateMenu() { resolve = _resolve; }); - const [{ decorateMenu, decorateLinkGroup }] = await Promise.all([ - loadBlock('./menu/menu.js'), + const [menu] = await Promise.all([ + import('./menu/menu.js'), loadStyles(rootPath('utilities/menu/menu.css')), ]); - resolve({ - decorateMenu, - decorateLinkGroup, - }); + resolve(menu.default); return cachedDecorateMenu; } diff --git a/libs/blocks/region-nav/region-nav.css b/libs/blocks/region-nav/region-nav.css index b3a9859c0b..a996434f33 100644 --- a/libs/blocks/region-nav/region-nav.css +++ b/libs/blocks/region-nav/region-nav.css @@ -56,6 +56,10 @@ column-count: 1; } +.region-nav.hide { + display: none; +} + @media (min-width: 600px) { .region-nav > div:nth-of-type(2) { column-count: 3; diff --git a/libs/navigation/base.css b/libs/navigation/base.css new file mode 100644 index 0000000000..6df2730955 --- /dev/null +++ b/libs/navigation/base.css @@ -0,0 +1 @@ +@import '../blocks/global-navigation/base.css'; diff --git a/libs/navigation/bootstrapper.js b/libs/navigation/bootstrapper.js index 4f19bb3ffd..55778464cc 100644 --- a/libs/navigation/bootstrapper.js +++ b/libs/navigation/bootstrapper.js @@ -1,10 +1,7 @@ -export default async function bootstrapBlock(miloLibs, blockConfig) { +/* eslint import/no-relative-packages: 0 */ +export default async function bootstrapBlock(initBlock, blockConfig) { const { name, targetEl, layout, noBorder, jarvis } = blockConfig; - const { getConfig, createTag, loadLink, loadScript } = await import(`${miloLibs}/utils/utils.js`); - const { default: initBlock } = await import(`${miloLibs}/blocks/${name}/${name}.js`); - - const styles = [`${miloLibs}/blocks/${name}/${name}.css`, `${miloLibs}/navigation/navigation.css`]; - styles.forEach((url) => loadLink(url, { rel: 'stylesheet' })); + const { getConfig, createTag, loadScript } = await import('../utils/utils.js'); const setNavLayout = () => { const element = document.querySelector(targetEl); @@ -41,7 +38,7 @@ export default async function bootstrapBlock(miloLibs, blockConfig) { await initBlock(document.querySelector(targetEl)); if (blockConfig.targetEl === 'footer') { - const { loadPrivacy } = await import(`${miloLibs}/scripts/delayed.js`); + const { loadPrivacy } = await import('../scripts/delayed.js'); setTimeout(() => { loadPrivacy(getConfig, loadScript); }, blockConfig.delay); diff --git a/libs/navigation/build.mjs b/libs/navigation/build.mjs new file mode 100755 index 0000000000..f94fb1f657 --- /dev/null +++ b/libs/navigation/build.mjs @@ -0,0 +1,57 @@ +import * as esbuild from 'esbuild'; // eslint-disable-line +import fs from 'node:fs'; + +fs.rmSync('./dist/', { recursive: true, force: true }); + +await esbuild.build({ + entryPoints: ['navigation.css', 'footer.css', 'dark-nav.css', 'base.css'], + bundle: true, + minify: true, + outdir: './dist/', +}); + +// This function behaves slightly different +// than the built in split function in +// that it only splits the array xs into two arrays +// on the first occurence of y only +const splitAt = (xs, y) => { + if (!xs.length) return null; + const splitInternal = (before, after) => { + if (!after.length) return [before, []]; + const [x, ...rest] = after; + if (x === y) return [before, rest]; + return splitInternal(before.concat([x]), rest); + }; + return splitInternal([], xs); +}; + +const StyleLoader = { + name: 'inline-style', + setup({ onLoad }) { + const template = (css) => ` + typeof document<'u'&& + document.head + .appendChild(document.createElement('style')) + .appendChild(document.createTextNode(${JSON.stringify(css)}))`; + onLoad({ filter: /\.css$/ }, async (args) => { + const { path } = args; + const [before, after] = splitAt(path.split('/'), 'navigation'); + const newPath = before + .concat(['navigation', 'dist']) + .concat(after) + .join('/'); + const css = await fs.promises.readFile(newPath, 'utf8'); + return { contents: template(css) }; + }); + }, +}; + +await esbuild.build({ + entryPoints: ['navigation.js'], + bundle: true, + splitting: true, + format: 'esm', + sourcemap: true, + outdir: './dist/', + plugins: [StyleLoader], +}); diff --git a/libs/navigation/dark-nav.css b/libs/navigation/dark-nav.css new file mode 100644 index 0000000000..8cf31dba0e --- /dev/null +++ b/libs/navigation/dark-nav.css @@ -0,0 +1 @@ +@import '../blocks/global-navigation/dark-nav.css'; diff --git a/libs/navigation/footer.css b/libs/navigation/footer.css new file mode 100644 index 0000000000..802e676252 --- /dev/null +++ b/libs/navigation/footer.css @@ -0,0 +1,2 @@ +@import '../blocks/global-footer/global-footer.css'; +@import '../blocks/modal/modal.css'; diff --git a/libs/navigation/navigation.css b/libs/navigation/navigation.css index cca872ed0b..ce0aae2d06 100644 --- a/libs/navigation/navigation.css +++ b/libs/navigation/navigation.css @@ -1,3 +1,8 @@ +@import '../blocks/global-navigation/global-navigation.css'; +@import '../blocks/global-navigation/features/profile/dropdown.css'; +@import '../blocks/global-navigation/features/search/gnav-search.css'; +@import '../blocks/global-navigation/utilities/menu/menu.css'; + /* Extracting the essential styles required for rendering the component independently */ :root { --navigation-link-color: #035FE6; diff --git a/libs/navigation/navigation.js b/libs/navigation/navigation.js index d54e439eaa..a4cb526218 100644 --- a/libs/navigation/navigation.js +++ b/libs/navigation/navigation.js @@ -1,3 +1,5 @@ +import { loadStyle } from '../utils/utils.js'; + const blockConfig = [ { key: 'header', @@ -51,6 +53,7 @@ function getParamsConfigs(configs) { }, {}); } +/* eslint import/no-relative-packages: 0 */ export default async function loadBlock(configs, customLib) { const { header, @@ -61,21 +64,40 @@ export default async function loadBlock(configs, customLib) { theme, stageDomainsMap = {}, } = configs || {}; - const branch = new URLSearchParams(window.location.search).get('navbranch'); - const miloLibs = branch ? `https://${branch}--milo--adobecom.aem.page` : customLib || envMap[env]; if (!header && !footer) { // eslint-disable-next-line no-console console.error('Global navigation Error: header and footer configurations are missing.'); return; } - // Relative path can't be used, as the script will run on consumer's app + const branch = new URLSearchParams(window.location.search).get('navbranch'); + const miloLibs = branch ? `https://${branch}--milo--adobecom.aem.page` : customLib || envMap[env]; + + // The below css imports will fail when using the non-bundled standalone gnav + // and fallback to using loadStyle. On the other hand, the bundler will rewrite + // the css imports to attach the styles to the head (and point to the dist folder + // using the custom StyleLoader plugin found in build.mjs + try { + await import('./base.css'); + if (theme === 'dark') { + await import('./dark-nav.css'); + } + await import('./navigation.css'); + } catch (e) { + if (theme === 'dark') { + loadStyle(`${miloLibs}/libs/navigation/dist/base.css`, () => loadStyle(`${miloLibs}/libs/navigation/dist/dark-nav.css`)); + } else { + loadStyle(`${miloLibs}/libs/navigation/dist/base.css`); + } + loadStyle(`${miloLibs}/libs/navigation/dist/navigation.css`); + } + + // Relative paths work just fine since they exist in the context of this file's origin const [{ default: bootstrapBlock }, { default: locales }, { setConfig }] = await Promise.all([ - import(`${miloLibs}/libs/navigation/bootstrapper.js`), - import(`${miloLibs}/libs/utils/locales.js`), - import(`${miloLibs}/libs/utils/utils.js`), + import('./bootstrapper.js'), + import('../utils/locales.js'), + import('../utils/utils.js'), ]); - - const paramConfigs = getParamsConfigs(configs, miloLibs); + const paramConfigs = getParamsConfigs(configs); const clientConfig = { clientEnv: env, origin: `https://main--federal--adobecom.aem.${env === 'prod' ? 'live' : 'page'}`, @@ -85,6 +107,7 @@ export default async function loadBlock(configs, customLib) { contentRoot: authoringPath || footer.authoringPath, theme, ...paramConfigs, + standaloneGnav: true, stageDomainsMap: getStageDomainsMap(stageDomainsMap), }; setConfig(clientConfig); @@ -92,16 +115,25 @@ export default async function loadBlock(configs, customLib) { const configBlock = configs[block.key]; try { if (configBlock) { - await bootstrapBlock(`${miloLibs}/libs`, { - ...block, - ...(block.key === 'header' && { + if (block.key === 'header') { + const { default: init } = await import('../blocks/global-navigation/global-navigation.js'); + await bootstrapBlock(init, { + ...block, unavComponents: configBlock.unav?.unavComponents, redirect: configBlock.redirect, layout: configBlock.layout, noBorder: configBlock.noBorder, jarvis: configBlock.jarvis, - }), - }); + }); + } else if (block.key === 'footer') { + try { + await import('./footer.css'); + } catch (e) { + loadStyle(`${miloLibs}/libs/navigation/dist/footer.css`); + } + const { default: init } = await import('../blocks/global-footer/global-footer.js'); + await bootstrapBlock(init, { ...block }); + } configBlock.onReady?.(); } } catch (e) { diff --git a/libs/navigation/package-lock.json b/libs/navigation/package-lock.json new file mode 100644 index 0000000000..f29d7ac225 --- /dev/null +++ b/libs/navigation/package-lock.json @@ -0,0 +1,439 @@ +{ + "name": "navigation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "navigation", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "esbuild": "0.24.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", + "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz", + "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", + "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz", + "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", + "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", + "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", + "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", + "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", + "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", + "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", + "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", + "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", + "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", + "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", + "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", + "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", + "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", + "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", + "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", + "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", + "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", + "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", + "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", + "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", + "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.0", + "@esbuild/android-arm": "0.24.0", + "@esbuild/android-arm64": "0.24.0", + "@esbuild/android-x64": "0.24.0", + "@esbuild/darwin-arm64": "0.24.0", + "@esbuild/darwin-x64": "0.24.0", + "@esbuild/freebsd-arm64": "0.24.0", + "@esbuild/freebsd-x64": "0.24.0", + "@esbuild/linux-arm": "0.24.0", + "@esbuild/linux-arm64": "0.24.0", + "@esbuild/linux-ia32": "0.24.0", + "@esbuild/linux-loong64": "0.24.0", + "@esbuild/linux-mips64el": "0.24.0", + "@esbuild/linux-ppc64": "0.24.0", + "@esbuild/linux-riscv64": "0.24.0", + "@esbuild/linux-s390x": "0.24.0", + "@esbuild/linux-x64": "0.24.0", + "@esbuild/netbsd-x64": "0.24.0", + "@esbuild/openbsd-arm64": "0.24.0", + "@esbuild/openbsd-x64": "0.24.0", + "@esbuild/sunos-x64": "0.24.0", + "@esbuild/win32-arm64": "0.24.0", + "@esbuild/win32-ia32": "0.24.0", + "@esbuild/win32-x64": "0.24.0" + } + } + } +} diff --git a/libs/navigation/package.json b/libs/navigation/package.json new file mode 100644 index 0000000000..9323d847cd --- /dev/null +++ b/libs/navigation/package.json @@ -0,0 +1,16 @@ +{ + "name": "@adobecom/standalone-feds", + "version": "0.0.1", + "description": "", + "main": "dist/navigation.js", + "type": "module", + "scripts": { + "build": "node ./build.mjs" + }, + "files": ["dist"], + "author": "", + "license": "ISC", + "devDependencies": { + "esbuild": "0.24.0" + } +} diff --git a/libs/utils/utils.js b/libs/utils/utils.js index 13ecb1d7fb..9cae5ef3e6 100644 --- a/libs/utils/utils.js +++ b/libs/utils/utils.js @@ -819,7 +819,7 @@ const findReplaceableNodes = (area) => { }; let placeholderRequest; -async function decoratePlaceholders(area, config) { +export async function decoratePlaceholders(area, config) { if (!area) return; const nodes = findReplaceableNodes(area); if (!nodes.length) return; diff --git a/test/blocks/global-footer/global-footer.test.js b/test/blocks/global-footer/global-footer.test.js index 3ab306b9ce..8e8ed697d8 100644 --- a/test/blocks/global-footer/global-footer.test.js +++ b/test/blocks/global-footer/global-footer.test.js @@ -187,6 +187,11 @@ describe('global footer', () => { const regionPickerElem = document.querySelector(allSelectors.regionPicker); regionPickerElem.dispatchEvent(new Event('click')); + const regionNavModal = document.createElement('div'); + regionNavModal.classList.add('region-nav'); // pretend that the modal was added to the body + // since clicking on the regionpicker elem apparently doesnt set the hash + document.body.append(regionNavModal); + window.dispatchEvent(new Event('milo:modal:loaded')); expect(regionPickerElem.getAttribute('href') === '#langnav').to.equal(true); expect(regionPickerElem.getAttribute('aria-expanded')).to.equal('true'); @@ -433,4 +438,27 @@ describe('global footer', () => { expect(document.querySelector('footer').classList.contains('feds--dark')).to.be.true; }); }); + describe('standalone footer', async () => { + it('should still load the regionnav if it\'s a standalone footer', async () => { + await createFullGlobalFooter({ + waitForDecoration: true, + customConfig: { standaloneGnav: true }, + }); + + const regionPickerElem = document.querySelector(allSelectors.regionPicker); + regionPickerElem.dispatchEvent(new Event('click')); + const regionNavModal = document.createElement('div'); + regionNavModal.classList.add('region-nav'); // pretend that the modal was added to the body + regionNavModal.setAttribute('data-failed', 'true'); + // since clicking on the regionpicker elem apparently doesnt set the hash + document.body.append(regionNavModal); + window.dispatchEvent(new Event('milo:modal:loaded')); + + expect(regionPickerElem.getAttribute('href') === '#langnav').to.equal(true); + expect(regionPickerElem.getAttribute('aria-expanded')).to.equal('true'); + + window.dispatchEvent(new Event('milo:modal:closed')); + expect(regionPickerElem.getAttribute('aria-expanded')).to.equal('false'); + }); + }); }); diff --git a/test/blocks/global-navigation/keyboard/mocks/global-nav-mobile.html b/test/blocks/global-navigation/keyboard/mocks/global-nav-mobile.html index 4b4c984091..0d3eb2cb36 100644 --- a/test/blocks/global-navigation/keyboard/mocks/global-nav-mobile.html +++ b/test/blocks/global-navigation/keyboard/mocks/global-nav-mobile.html @@ -1,5 +1,5 @@

Status: ${status}

Setting: ${dynamicNavSetting}

+

Group: ${groupMetaSetting}

+

Group matches stored group: ${groupsMatchMessage}

Consumer key: ${dynamicNavKey}

diff --git a/test/blocks/video/mocks/body.html b/test/blocks/video/mocks/body.html index b3f1963c85..1c59b91e82 100644 --- a/test/blocks/video/mocks/body.html +++ b/test/blocks/video/mocks/body.html @@ -16,7 +16,7 @@
-
+
https://main--blog--adobecom.hlx.page/media_17927691d22fe4e1bd058e94762a224fdc57ebb7b.mp4#autoplay @@ -44,7 +44,7 @@
- + https://main--milo--adobecom.hlx.page/media_1e798d01c6ddc7e7eadc8f134d69e4f8d7193fdbb1.mp4#autoplay#viewportplay
@@ -86,4 +86,9 @@ https://main--milo--adobecom.hlx.page/media_1e798d01c6ddc7e7eadc8f134d69e4f8d7193fdbb6.mp4#autoplay1#_hoverplay#viewportplay
+
+ + https://main--blog--adobecom.hlx.page/media_17927691d22fe4e1bd058e94762a224fdc57ebb7b.mp4#autoplay + +
diff --git a/test/blocks/video/video.test.js b/test/blocks/video/video.test.js index e55c4e2915..c4e167cb20 100644 --- a/test/blocks/video/video.test.js +++ b/test/blocks/video/video.test.js @@ -4,20 +4,44 @@ import sinon from 'sinon'; import { waitFor, waitForElement } from '../../helpers/waitfor.js'; import { setConfig, createTag } from '../../../libs/utils/utils.js'; -import { decorateAnchorVideo } from '../../../libs/utils/decorate.js'; +import { decorateAnchorVideo, handlePause, applyHoverPlay, decoratePausePlayWrapper } from '../../../libs/utils/decorate.js'; setConfig({}); const { default: init } = await import('../../../libs/blocks/video/video.js'); describe('video uploaded using franklin bot', () => { + let clock; + const callback = sinon.spy(); beforeEach(async () => { + clock = sinon.useFakeTimers({ + toFake: ['setTimeout'], + shouldAdvanceTime: true, + }); document.body.innerHTML = await readFile({ path: './mocks/body.html' }); }); afterEach(() => { + clock.restore(); document.body.innerHTML = ''; }); + it('aria-label should not have index when page has only one video', async () => { + const block = document.querySelector('.video.autoplay.single'); + const block2 = document.querySelector('.video.autoplay.second'); + const a = block.querySelector('a'); + const a2 = block2.querySelector('a'); + init(a); + setTimeout(callback, 600); + await clock.runAllAsync(); + const pausePlayWrapper = block.querySelector('.pause-play-wrapper'); + pausePlayWrapper.removeAttribute('video-index'); + init(a2); + setTimeout(callback, 500); + await clock.runAllAsync(); + const videoIndex = pausePlayWrapper.getAttribute('video-index'); + expect(videoIndex).to.be.null; + }); + it('removes the element, if it does not have a parent node', (done) => { const anchor = createTag('a'); anchor.remove = () => done(); @@ -114,6 +138,85 @@ describe('video uploaded using franklin bot', () => { expect(video.hasAttribute('data-play-viewport')).to.be.true; }); + it('accessibility controls should pause autoplay videos', async () => { + const block = document.querySelector('.video.autoplay.viewportplay'); + const fetchStub = sinon.stub(window, 'fetch'); + fetchStub.resolves({ + total: 19, + offset: 0, + limit: 19, + data: [ + { + key: 'play-motion', + value: 'Play', + }, + { + key: 'pause-motion', + value: 'Pause', + }, + { + key: 'play-icon', + value: 'play icon', + }, + { + key: 'pause-icon', + value: 'pause icon', + }, + ], + ':type': 'sheet', + }); + + const a = block.querySelector('a'); + init(a); + const video = block.querySelector('video'); + decoratePausePlayWrapper(video, ''); + const pausePlayWrapper = block.querySelector('.pause-play-wrapper'); + pausePlayWrapper.click(); + setTimeout(callback, 500); + await clock.runAllAsync(); + expect(pausePlayWrapper.ariaPressed).to.eql('false'); + }); + + it('accessibility controls should play autoplay videos after pausing', async () => { + const block = document.querySelector('.video.autoplay.viewportplay'); + const a = block.querySelector('a'); + init(a); + const pausePlayWrapper = block.querySelector('.pause-play-wrapper'); + pausePlayWrapper.click(); + pausePlayWrapper.setAttribute('daa-ll', 'pause-motion'); + setTimeout(callback, 500); + await clock.runAllAsync(); + pausePlayWrapper.click(); + expect(pausePlayWrapper.querySelector('.is-playing')).to.exist; + }); + + it('handlePause should return undefined if called with unknown event', async () => { + const event = {}; + event.stopPropagation = sinon.stub(); + const x = handlePause(event); + expect(x).to.be.undefined; + }); + + it('video should be paused on focus out or blur', async () => { + const block = document.querySelector('.video.autoplay1.hoverplay.no-viewportplay'); + const a = block.querySelector('a'); + init(a); + setTimeout(callback, 0); + await clock.runAllAsync(); + const pausePlayWrapper = block.querySelector('.pause-play-wrapper'); + const video = block.querySelector('video'); + pausePlayWrapper.focus(); + setTimeout(callback, 0); + await clock.runAllAsync(); + pausePlayWrapper.blur(); + expect(video.paused).to.be.true; + }); + + it('should return undefined if video is not present', async () => { + const returnValue = applyHoverPlay(); + expect(returnValue).to.be.undefined; + }); + it('play video when element reached 80% viewport', async () => { const block = document.querySelector('.video.autoplay.viewportplay.scrolled-80'); const a = block.querySelector('a'); @@ -130,9 +233,8 @@ describe('video uploaded using franklin bot', () => { await waitFor(intersectionObserverAddsSource); video.scrollIntoView(); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 100); + await clock.runAllAsync(); assert.isTrue(playSpy.calledOnce); // push the video out of the viewport @@ -141,9 +243,8 @@ describe('video uploaded using franklin bot', () => { video.parentNode.insertBefore(div, video); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 100); + await clock.runAllAsync(); assert.isTrue(pauseSpy.calledOnce); expect(video.hasAttribute('data-play-viewport')).to.be.true; }); @@ -167,9 +268,8 @@ describe('video uploaded using franklin bot', () => { video.addEventListener('ended', endedSpy); video.scrollIntoView(); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 100); + await clock.runAllAsync(); assert.isTrue(playSpy.calledOnce); // push the video out of the viewport @@ -178,21 +278,18 @@ describe('video uploaded using franklin bot', () => { video.parentNode.insertBefore(div, video); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 200); + await clock.runAllAsync(); assert.isTrue(pauseSpy.calledOnce); video.dispatchEvent(new Event('ended')); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 100); + await clock.runAllAsync(); video.scrollIntoView(); await nextFrame(); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); + setTimeout(callback, 100); + await clock.runAllAsync(); expect(playSpy.callCount).to.equal(1); expect(video.hasAttribute('data-play-viewport')).to.be.true; From 91accb65f1cf7d3623a125f8121ae927eef339de Mon Sep 17 00:00:00 2001 From: Rares Munteanu Date: Wed, 4 Dec 2024 11:41:19 +0100 Subject: [PATCH 06/11] Add Express SOTs (#3287) --- .github/workflows/merge-to-stage.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/merge-to-stage.js b/.github/workflows/merge-to-stage.js index 71f290ace4..7c85415da1 100644 --- a/.github/workflows/merge-to-stage.js +++ b/.github/workflows/merge-to-stage.js @@ -20,19 +20,20 @@ const LABELS = { zeroImpact: 'zero-impact', }; const TEAM_MENTIONS = [ - '@adobecom/miq-sot', '@adobecom/bacom-sot', - '@adobecom/homepage-sot', '@adobecom/creative-cloud-sot', '@adobecom/document-cloud-sot', + '@adobecom/express-sot', + '@adobecom/homepage-sot', + '@adobecom/miq-sot', ]; const SLACK = { merge: ({ html_url, number, title, prefix = '' }) => `:merged: PR merged to stage: ${prefix} <${html_url}|${number}: ${title}>.`, openedSyncPr: ({ html_url, number }) => `:fast_forward: Created <${html_url}|Stage to Main PR ${number}>`, }; -let github; -let owner; +let github; +let owner; let repo; let body = ` From b632a21ed42b5bd9149d8857012b1f13fc695a1a Mon Sep 17 00:00:00 2001 From: Raghav Sharma <118168183+sharmrj@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:26:33 +0530 Subject: [PATCH 07/11] MWPW-163606 removed the /dist/ from the css paths where it's incorrect (#3302) * removed the /dist/ from the css paths where it's incorrect * Cover an uncovered line in tests --- libs/navigation/navigation.js | 8 ++++---- test/navigation/navigation.test.js | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/navigation/navigation.js b/libs/navigation/navigation.js index a4cb526218..043f5d1de3 100644 --- a/libs/navigation/navigation.js +++ b/libs/navigation/navigation.js @@ -84,11 +84,11 @@ export default async function loadBlock(configs, customLib) { await import('./navigation.css'); } catch (e) { if (theme === 'dark') { - loadStyle(`${miloLibs}/libs/navigation/dist/base.css`, () => loadStyle(`${miloLibs}/libs/navigation/dist/dark-nav.css`)); + loadStyle(`${miloLibs}/libs/navigation/base.css`, () => loadStyle(`${miloLibs}/libs/navigation/dark-nav.css`)); } else { - loadStyle(`${miloLibs}/libs/navigation/dist/base.css`); + loadStyle(`${miloLibs}/libs/navigation/base.css`); } - loadStyle(`${miloLibs}/libs/navigation/dist/navigation.css`); + loadStyle(`${miloLibs}/libs/navigation/navigation.css`); } // Relative paths work just fine since they exist in the context of this file's origin @@ -129,7 +129,7 @@ export default async function loadBlock(configs, customLib) { try { await import('./footer.css'); } catch (e) { - loadStyle(`${miloLibs}/libs/navigation/dist/footer.css`); + loadStyle(`${miloLibs}/libs/navigation/footer.css`); } const { default: init } = await import('../blocks/global-footer/global-footer.js'); await bootstrapBlock(init, { ...block }); diff --git a/test/navigation/navigation.test.js b/test/navigation/navigation.test.js index 93331eb331..1ea752635f 100644 --- a/test/navigation/navigation.test.js +++ b/test/navigation/navigation.test.js @@ -45,7 +45,7 @@ describe('Navigation component', async () => { it('Renders the header block', async () => { const onReady = stub(); - await loadBlock({ authoringPath: '/federal/dev', header: { imsClientId: 'fedsmilo', onReady }, env: 'prod' }, 'http://localhost:2000'); + await loadBlock({ authoringPath: '/federal/dev', header: { imsClientId: 'fedsmilo', onReady }, env: 'prod', theme: 'dark' }, 'http://localhost:2000'); const el = document.getElementsByTagName('header'); expect(el).to.exist; expect(onReady.called).to.be.true; From d9ddd1726505eb2770468f38b94b74dd636128a9 Mon Sep 17 00:00:00 2001 From: Swati Mukherjee Date: Wed, 4 Dec 2024 17:54:34 +0530 Subject: [PATCH 08/11] =?UTF-8?q?[MWPW-158749]=20Enhance=20Interact=20call?= =?UTF-8?q?=20efficiency=20and=20defer=20non-essentia=E2=80=A6=20(#3258)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * move common snippets to helper, add defer logic and make interact call the 1st thing in the flow for signed out users * toggle FPID and ECID logic, re-organise exports, dont fire if consent cookie present * Pre merge branch (#5) move methods utils , use async load of helper, review comments * move try catch block as per promise * Pre merge branch (#6) * Test remove martech logic (#7) fix delay logic, fix pr comments * Test remove martech logic (#8) change uuid logic, promise structure, alloy response logic, requestid, * Final local branch (#9) fix review comments, add test cases * fix coverage for if check * fix coverage errors * add test case for martech helpers * send full cookie flag added post testing round 1 --- .../personalization/personalization.js | 126 +++++- libs/martech/helpers.js | 424 ++++++++++++++++++ libs/martech/martech.js | 77 +--- libs/utils/utils.js | 83 +++- test/martech/helpers.test.js | 260 +++++++++++ test/utils/utils-enable-perf.test.js | 79 ++++ test/utils/utils.test.js | 20 + 7 files changed, 986 insertions(+), 83 deletions(-) create mode 100644 libs/martech/helpers.js create mode 100644 test/martech/helpers.test.js create mode 100644 test/utils/utils-enable-perf.test.js diff --git a/libs/features/personalization/personalization.js b/libs/features/personalization/personalization.js index 9472ec413f..cbb87ba508 100644 --- a/libs/features/personalization/personalization.js +++ b/libs/features/personalization/personalization.js @@ -1,7 +1,9 @@ /* eslint-disable no-underscore-dangle */ /* eslint-disable no-console */ -import { createTag, getConfig, loadLink, loadScript, localizeLink } from '../../utils/utils.js'; +import { + createTag, getConfig, loadLink, loadScript, localizeLink, enablePersonalizationV2, +} from '../../utils/utils.js'; import { getFederatedUrl } from '../../utils/federated.js'; /* c8 ignore start */ @@ -467,7 +469,7 @@ function getSelectedElements(sel, rootEl, forceRootEl) { try { els = root.querySelectorAll(modifiedSelector); } catch (e) { - /* eslint-disable-next-line no-console */ + /* eslint-disable-next-line no-console */ log('Invalid selector: ', selector); return null; } @@ -773,7 +775,11 @@ async function getPersonalizationVariant(manifestPath, variantNames = [], varian let userEntitlements = []; if (hasEntitlementTag) { - userEntitlements = await config.entitlements(); + if (enablePersonalizationV2()) { + userEntitlements = []; + } else { + userEntitlements = await config.entitlements(); + } } const hasMatch = (name) => { @@ -1111,9 +1117,7 @@ export const combineMepSources = async (persEnabled, promoEnabled, mepParam) => return persManifests; }; -async function callMartech(config) { - const { getTargetPersonalization } = await import('../../martech/martech.js'); - const { targetManifests, targetPropositions } = await getTargetPersonalization(); +function updateManifestsAndPropositions({ config, targetManifests, targetPropositions }) { config.mep.targetManifests = targetManifests; if (targetPropositions?.length && window._satellite) { window._satellite.track('propositionDisplay', targetPropositions); @@ -1124,6 +1128,97 @@ async function callMartech(config) { } return targetManifests; } + +function roundToQuarter(num) { + return Math.ceil(num / 250) / 4; +} + +function calculateResponseTime(responseStart) { + const responseTime = Date.now() - responseStart; + return roundToQuarter(responseTime); +} + +function sendTargetResponseAnalytics(failure, responseStart, timeoutLocal, message) { + // temporary solution until we can decide on a better timeout value + const responseTime = calculateResponseTime(responseStart); + const timeoutTime = roundToQuarter(timeoutLocal); + let val = `target response time ${responseTime}:timed out ${failure}:timeout ${timeoutTime}`; + if (message) val += `:${message}`; + // eslint-disable-next-line no-underscore-dangle + window._satellite?.track?.('event', { + documentUnloading: true, + xdm: { + eventType: 'web.webinteraction.linkClicks', + web: { + webInteraction: { + linkClicks: { value: 1 }, + type: 'other', + name: val, + }, + }, + }, + data: { _adobe_corpnew: { digitalData: { primaryEvent: { eventInfo: { eventName: val } } } } }, + }); +} + +const handleAlloyResponse = (response) => ((response.propositions || response.decisions)) + ?.map((i) => i.items) + ?.flat() + ?.map((item) => { + const content = item?.data?.content; + if (!content || !(content.manifestLocation || content.manifestContent)) return null; + + return { + manifestPath: content.manifestLocation || content.manifestPath, + manifestUrl: content.manifestLocation, + manifestData: content.manifestContent?.experiences?.data || content.manifestContent?.data, + manifestPlaceholders: content.manifestContent?.placeholders?.data, + manifestInfo: content.manifestContent?.info.data, + name: item.meta['activity.name'], + variantLabel: item.meta['experience.name'] && `target-${item.meta['experience.name']}`, + meta: item.meta, + }; + }) + ?.filter(Boolean) ?? []; + +async function handleMartechTargetInteraction( + { config, targetInteractionPromise, calculatedTimeout }, +) { + let targetManifests = []; + let targetPropositions = []; + if (enablePersonalizationV2() && targetInteractionPromise) { + try { + const { targetInteractionData, respTime, respStartTime } = await targetInteractionPromise; + sendTargetResponseAnalytics(false, respStartTime, calculatedTimeout); + + const roundedResponseTime = roundToQuarter(respTime); + performance.clearMarks(); + performance.clearMeasures(); + try { + window.lana.log(`target response time: ${roundedResponseTime}`, { tags: 'martech', errorType: 'i' }); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error logging target response time:', e); + } + targetManifests = handleAlloyResponse(targetInteractionData.result); + targetPropositions = targetInteractionData.result?.propositions || []; + } catch (err) { + console.log('Oops!! Interact Call didnt go through', err); + } + } + + return updateManifestsAndPropositions({ config, targetManifests, targetPropositions }); +} + +async function callMartech(config) { + const { getTargetPersonalization } = await import('../../martech/martech.js'); + const { + targetManifests, + targetPropositions, + } = await getTargetPersonalization({ handleAlloyResponse, sendTargetResponseAnalytics }); + return updateManifestsAndPropositions({ config, targetManifests, targetPropositions }); +} + const awaitMartech = () => new Promise((resolve) => { const listener = (event) => resolve(event.detail); window.addEventListener(MARTECH_RETURNED_EVENT, listener, { once: true }); @@ -1132,7 +1227,8 @@ const awaitMartech = () => new Promise((resolve) => { export async function init(enablements = {}) { let manifests = []; const { - mepParam, mepHighlight, mepButton, pzn, promo, target, postLCP, + mepParam, mepHighlight, mepButton, pzn, promo, + target, targetInteractionPromise, calculatedTimeout, postLCP, } = enablements; const config = getConfig(); if (postLCP) { @@ -1157,11 +1253,17 @@ export async function init(enablements = {}) { if (pzn) loadLink(getXLGListURL(config), { as: 'fetch', crossorigin: 'anonymous', rel: 'preload' }); } - if (target === true) manifests = manifests.concat(await callMartech(config)); - if (target === 'postlcp') callMartech(config); - if (postLCP) { - if (!config.mep.targetManifests) await awaitMartech(); - manifests = config.mep.targetManifests; + if (enablePersonalizationV2()) { + manifests = manifests.concat(await handleMartechTargetInteraction( + { config, targetInteractionPromise, calculatedTimeout }, + )); + } else { + if (target === true) manifests = manifests.concat(await callMartech(config)); + if (target === 'postlcp') callMartech(config); + if (postLCP) { + if (!config.mep.targetManifests) await awaitMartech(); + manifests = config.mep.targetManifests; + } } if (!manifests || !manifests.length) return; try { diff --git a/libs/martech/helpers.js b/libs/martech/helpers.js new file mode 100644 index 0000000000..25760ba57a --- /dev/null +++ b/libs/martech/helpers.js @@ -0,0 +1,424 @@ +/** + * Generates a random UUIDv4 using cryptographically secure random values. + * This implementation follows the RFC 4122 specification for UUIDv4. + * It uses the `crypto` API for secure randomness without any bitwise operators. + * + * @returns {string} A random UUIDv4 string, e.g., 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' + * where: + * - 'x' is any hexadecimal digit (0-9, a-f) + * - 'y' is one of 8, 9, A, or B, ensuring that the UUID conforms to version 4. + * + * @example + * const myUuid = generateUUIDv4(); + * console.log(myUuid); // Outputs: 'e8b57e2f-8cb1-4d0f-804b-e1a45bce2d90' + */ +function generateUUIDv4() { + // Generate an array of 16 random values using the crypto API for better randomness + const randomValues = new Uint8Array(16); + crypto.getRandomValues(randomValues); + + // Set the version (4) at the 13th position + randomValues[6] = (randomValues[6] % 16) + 64; // '4' for version 4 + // Set the variant (8, 9, A, or B) at the 17th position + randomValues[8] = (randomValues[8] % 16) + 128; // One of 8, 9, A, or B + + // Accumulate the UUID string in a separate variable (to avoid modifying the parameter directly) + let uuid = ''; + + // Convert the random values to a UUID string (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) + randomValues.forEach((byte, index) => { + const hex = byte.toString(16).padStart(2, '0'); // Convert byte to hex + if (index === 4 || index === 6 || index === 8 || index === 10) { + uuid += '-'; // Add dashes at appropriate positions + } + uuid += hex; + }); + + return uuid; +} + +/** + * Determines the Adobe Target property value based on the page's region. + * + * @param {string} env - The environment (e.g., 'prod' for production, 'dev' for development). + * @returns {string} Adobe Target property value. + */ +function getTargetPropertyBasedOnPageRegion(env) { + const { pathname } = window.location; + + if (env !== 'prod') return 'bc8dfa27-29cc-625c-22ea-f7ccebfc6231'; // Default for non-prod environments + + // EMEA & LATAM + if ( + pathname.search( + /(\/africa\/|\/be_en\/|\/be_fr\/|\/be_nl\/|\/cis_en\/|\/cy_en\/|\/dk\/|\/de\/|\/ee\/|\/es\/|\/fr\/|\/gr_en\/|\/ie\/|\/il_en\/|\/it\/|\/lv\/|\/lu_de\/|\/lu_en\/|\/lu_fr\/|\/hu\/|\/mt\/|\/mena_en\/|\/nl\/|\/no\/|\/pl\/|\/pt\/|\/ro\/|\/ch_de\/|\/si\/|\/sk\/|\/ch_fr\/|\/fi\/|\/se\/|\/ch_it\/|\/tr\/|\/uk\/|\/at\/|\/cz\/|\/bg\/|\/ru\/|\/cis_ru\/|\/ua\/|\/il_he\/|\/mena_ar\/|\/lt\/|\/sa_en\/|\/ae_en\/|\/ae_ar\/|\/sa_ar\/|\/ng\/|\/za\/|\/qa_ar\/|\/eg_en\/|\/eg_ar\/|\/kw_ar\/|\/eg_ar\/|\/qa_en\/|\/kw_en\/|\/gr_el\/|\/br\/|\/cl\/|\/la\/|\/mx\/|\/co\/|\/ar\/|\/pe\/|\/gt\/|\/pr\/|\/ec\/|\/cr\/)/, + ) !== -1 + ) { + return '488edf5f-3cbe-f410-0953-8c0c5c323772'; + } + if ( // APAC + pathname.search( + /(\/au\/|\/hk_en\/|\/in\/|\/nz\/|\/sea\/|\/cn\/|\/hk_zh\/|\/tw\/|\/kr\/|\/sg\/|\/th_en\/|\/th_th\/|\/my_en\/|\/my_ms\/|\/ph_en\/|\/ph_fil\/|\/vn_en\/|\/vn_vi\/|\/in_hi\/|\/id_id\/|\/id_en\/)/, + ) !== -1 + ) { + return '3de509ee-bbc7-58a3-0851-600d1c2e2918'; + } + // JP + if (pathname.indexOf('/jp/') !== -1) { + return 'ba5bc9e8-8fb4-037a-12c8-682384720007'; + } + + return 'bc8dfa27-29cc-625c-22ea-f7ccebfc6231'; // Default +} + +/** + * Retrieves device-related information such as screen and viewport dimensions. + * + * @returns {Object} Object containing device and viewport information. + */ +function getDeviceInfo() { + return { + screenWidth: window.screen.width, + screenHeight: window.screen.height, + screenOrientation: window.innerWidth > window.innerHeight ? 'landscape' : 'portrait', + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }; +} + +/** + * Retrieves the value of a specific cookie by its key. + * + * @param {string} key - The cookie key. + * @returns {string|null} The cookie value, or null if the cookie doesn't exist. + */ +function getCookie(key, sendFullCookie) { + const cookie = document.cookie.split(';') + .map((x) => x.trim().split('=')) + .find(([k]) => k === key); + + if (sendFullCookie) { + return cookie; + } + return cookie ? cookie[1] : null; +} + +/** + * Sets a cookie with a specified expiration time (default 730 days). + * + * @param {string} key - The cookie key. + * @param {string} value - The cookie value. + * @param {Object} [options={}] - Optional settings for cookie properties. + * Defaults to an expiration of 730 days. + */ +function setCookie(key, value, options = {}) { + // Default expiration (24 months) + const expires = options.expires || 730; + const date = new Date(); + date.setTime(date.getTime() + expires * 24 * 60 * 60 * 1000); + const expiresString = `expires=${date.toUTCString()}`; + + document.cookie = `${key}=${value}; ${expiresString}; path=/`; +} + +/** + * Retrieves the ECID (Experience Cloud ID) from the browser's cookies or + * generates a new FPID (First Party ID) if the ECID is not found. Returns + * the ID in a structured object, depending on which ID is available. + * + * @returns {Object} An object containing either the ECID or FPID. + * - If ECID is found, the object will be: + * { ECID: [{ id: string, authenticatedState: string, primary: boolean }] } + * - If ECID is not found, the object will be: + * { FPID: [{ id: string, authenticatedState: string, primary: boolean }] } + */ +function getOrGenerateUserId() { + const experienceCloudCookieName = 'AMCV_9E1005A551ED61CA0A490D45%40AdobeOrg'; + const amcvCookieValue = getCookie(experienceCloudCookieName); + + // If ECID is not found, generate and return FPID + if (!amcvCookieValue) { + const fpidValue = generateUUIDv4(); + return { + FPID: [{ + id: fpidValue, + authenticatedState: 'ambiguous', + primary: true, + }], + }; + } + + // ECID found, return structured ECID object + const extractedEcid = amcvCookieValue.substring(6); // Extract the ECID value from the cookie + return { + ECID: [{ + id: extractedEcid, + authenticatedState: 'ambiguous', + primary: true, + }], + }; +} + +/** + * Retrieves the page name for analytics, modified for the current locale. + * + * @param {Object} params - The parameters. + * @param {Object} params.locale - The locale object containing + * language/region info (e.g., { ietf: 'en-US', prefix: 'us' }). + * @returns {string} The modified page name. + */ +function getPageNameForAnalytics({ locale }) { + const { host, pathname } = new URL(window.location.href); + const [modifiedPath] = pathname.split('/').filter((x) => x !== locale.prefix).join(':').split('.'); + return `${host.replace('www.', '')}:${modifiedPath}`; +} + +/** + * Creates the updated context for the request payload for analytics or personalization requests. + * + * @param {number} screenWidth - Screen width. + * @param {number} screenHeight - Screen height. + * @param {string} screenOrientation - Orientation of the screen. + * @param {number} viewportWidth - Viewport width. + * @param {number} viewportHeight - Viewport height. + * @param {string} localTime - The local time in ISO format. + * @param {number} timezoneOffset - The timezone offset. + * @returns {Object} The updated context for the request payload. + */ +function getUpdatedContext({ + screenWidth, screenHeight, screenOrientation, + viewportWidth, viewportHeight, localTime, timezoneOffset, +}) { + return { + device: { + screenHeight, + screenWidth, + screenOrientation, + }, + environment: { + type: 'browser', + browserDetails: { + viewportWidth, + viewportHeight, + }, + }, + placeContext: { + localTime, + localTimezoneOffset: timezoneOffset, + }, + }; +} + +/** + * Retrieves specific MarTech cookies by their keys. + * + * @returns {Array} List of MarTech cookies with each + * object containing 'key' and 'value' properties. + */ +const getMarctechCookies = () => { + const KNDCTR_COOKIE_KEYS = [ + 'kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_identity', + 'kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_cluster', + ]; + return document.cookie.split(';') + .map((x) => x.trim().split('=')) + .filter(([key]) => KNDCTR_COOKIE_KEYS.includes(key)) + .map(([key, value]) => ({ key, value })); +}; + +/** + * Creates the request payload for Adobe Analytics and Target. + * + * @param {Object} params - Parameters required to create the payload. + * @param {Object} params.updatedContext - The updated context for the request. + * @param {string} params.pageName - The page name for the analytics request. + * @param {Object} params.locale - The locale object containing language/region info. + * @param {string} params.env - The environment (e.g., 'prod' for production). + * @returns {Object} The request payload for Adobe Analytics and Target. + */ +function createRequestPayload({ updatedContext, pageName, locale, env }) { + const prevPageName = getCookie('gpv'); + + const REPORT_SUITES_ID = env === 'prod' ? ['adbadobenonacdcprod'] : ['adbadobenonacdcqa']; + const AT_PROPERTY_VAL = getTargetPropertyBasedOnPageRegion(env); + + return { + event: { + xdm: { + ...updatedContext, + identityMap: getOrGenerateUserId(), + web: { + webPageDetails: { + URL: window.location.href, + siteSection: 'www.adobe.com', + server: 'www.adobe.com', + isErrorPage: false, + isHomePage: false, + name: pageName, + pageViews: { value: 0 }, + }, + webInteraction: { + name: 'Martech-API', + type: 'other', + linkClicks: { value: 1 }, + }, + webReferrer: { URL: document.referrer }, + }, + timestamp: new Date().toISOString(), + eventType: 'decisioning.propositionFetch', + }, + data: { + __adobe: { + target: { + is404: false, authState: 'loggedOut', hitType: 'propositionFetch', isMilo: true, adobeLocale: locale.ietf, hasGnav: true, + }, + }, + _adobe_corpnew: { + marketingtech: { adobe: { alloy: { approach: 'martech-API' } } }, + digitalData: { + page: { pageInfo: { language: locale.ietf } }, + diagnostic: { franklin: { implementation: 'milo' } }, + previousPage: { pageInfo: { pageName: prevPageName } }, + primaryUser: { primaryProfile: { profileInfo: { authState: 'loggedOut', returningStatus: 'Repeat' } } }, + }, + }, + }, + }, + query: { + identity: { fetch: ['ECID'] }, + 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__'], + }, + }, + meta: { + target: { migration: true }, + configOverrides: { + com_adobe_analytics: { reportSuites: REPORT_SUITES_ID }, + com_adobe_target: { propertyToken: AT_PROPERTY_VAL }, + }, + state: { + domain: 'localhost', + cookiesEnabled: true, + entries: getMarctechCookies(), + }, + }, + }; +} + +/** + * Extracts the ECID (Experience Cloud ID) from the API response data. + * + * @param {Object} data - The response data from the API. + * @returns {string|null} The ECID value, or null if not found. + */ +function extractECID(data) { + return data.handle + .flatMap((item) => item.payload) + .find((p) => p.namespace?.code === 'ECID')?.id || null; +} + +/** + * Updates the AMCV cookie with the new ECID. + * + * @param {string} ECID - The Experience Cloud ID (ECID). + */ +function updateAMCVCookie(ECID) { + const cookieName = 'AMCV_9E1005A551ED61CA0A490D45%40AdobeOrg'; + const cookieValue = `MCMID|${ECID}`; + + if (getCookie(cookieName) !== cookieValue) { + setCookie(cookieName, `MCMID|${ECID}`); + } +} + +/** + * Loads analytics and interaction data based on the user and page context. + * Sends the data to Adobe Analytics and Adobe Target for personalization. + * + * @param {Object} params - The parameters for the function. + * @param {Object} params.locale - The locale object containing language/region info. + * @param {string} params.env - The environment (e.g., 'prod' for production). + * @param {string} [params.calculatedTimeout] - timeout value for the request in milliseconds. + * + * @returns {Promise} A promise that resolves to the + * personalization propositions fetched from Adobe Target. + */ +export const loadAnalyticsAndInteractionData = async ({ locale, env, calculatedTimeout }) => { + const value = getCookie('kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_consent', true); + if (value?.[1] === 'general' && value?.[2] === 'out') { + return Promise.reject(new Error('Consent Cookie doesnt allow interact')); + } + + // Define constants based on environment + const DATA_STREAM_ID = env === 'prod' ? '5856abb0-95d8-4f9a-bb92-37f99d2bd492' : '87f9b644-5fd3-4015-81d5-f68ad81c3561'; + const TARGET_API_URL = 'https://edge.adobedc.net/ee/v2/interact'; + + // Device and viewport information + const { + screenWidth, screenHeight, + screenOrientation, viewportWidth, viewportHeight, + } = getDeviceInfo(); + + // Date and Time Constants + const CURRENT_DATE = new Date(); + const LOCAL_TIME = CURRENT_DATE.toISOString(); + const LOCAL_TIMEZONE_OFFSET = CURRENT_DATE.getTimezoneOffset(); + + const pageName = getPageNameForAnalytics({ locale }); + + const updatedContext = getUpdatedContext({ + screenWidth, + screenHeight, + screenOrientation, + viewportWidth, + viewportHeight, + LOCAL_TIME, + LOCAL_TIMEZONE_OFFSET, + }); + + // Prepare the body for the request + const requestBody = createRequestPayload({ + updatedContext, + pageName, + locale, + env, + }); + + try { + const targetResp = await Promise.race([ + fetch(`${TARGET_API_URL}?dataStreamId=${DATA_STREAM_ID}&requestId=${generateUUIDv4()}`, { + method: 'POST', + body: JSON.stringify(requestBody), + }), + new Promise((_, reject) => { setTimeout(() => reject(new Error('Request timed out')), calculatedTimeout); }), + ]); + + if (!targetResp.ok) { + throw new Error('Failed to fetch interact call'); + } + const targetRespJson = await targetResp.json(); + const ECID = extractECID(targetRespJson); + + // Update the AMCV cookie with ECID + updateAMCVCookie(ECID); + + // Resolve or reject based on propositions + const resultPayload = targetRespJson?.handle?.find((d) => d.type === 'personalization:decisions')?.payload; + if (resultPayload.length === 0) throw new Error('No propositions found'); + return { + type: 'propositionFetch', + result: { propositions: resultPayload }, + }; + } catch (err) { + throw new Error(err); + } +}; + +export default { loadAnalyticsAndInteractionData }; diff --git a/libs/martech/martech.js b/libs/martech/martech.js index 80f5cfd319..9cddce3358 100644 --- a/libs/martech/martech.js +++ b/libs/martech/martech.js @@ -1,12 +1,17 @@ import { - getConfig, getMetadata, loadIms, loadLink, loadScript, getMepEnablement, + getConfig, loadIms, loadLink, loadScript, getMepEnablement, getMetadata, } from '../utils/utils.js'; const ALLOY_SEND_EVENT = 'alloy_sendEvent'; const ALLOY_SEND_EVENT_ERROR = 'alloy_sendEvent_error'; -const TARGET_TIMEOUT_MS = 4000; const ENTITLEMENT_TIMEOUT = 3000; +const TARGET_TIMEOUT_MS = 4000; +const params = new URL(window.location.href).searchParams; +const timeout = parseInt(params.get('target-timeout'), 10) + || parseInt(getMetadata('target-timeout'), 10) + || TARGET_TIMEOUT_MS; + const setDeep = (obj, path, value) => { const pathArr = path.split('.'); let currentObj = obj; @@ -22,7 +27,7 @@ const setDeep = (obj, path, value) => { }; // eslint-disable-next-line max-len -const waitForEventOrTimeout = (eventName, timeout, returnValIfTimeout) => new Promise((resolve) => { +const waitForEventOrTimeout = (eventName, timeoutLocal, returnValIfTimeout) => new Promise((resolve) => { const listener = (event) => { // eslint-disable-next-line no-use-before-define clearTimeout(timer); @@ -42,40 +47,12 @@ const waitForEventOrTimeout = (eventName, timeout, returnValIfTimeout) => new Pr } else { resolve({ timeout: true }); } - }, timeout); + }, timeoutLocal); window.addEventListener(eventName, listener, { once: true }); window.addEventListener(ALLOY_SEND_EVENT_ERROR, errorListener, { once: true }); }); -const handleAlloyResponse = (response) => { - const items = ( - (response.propositions?.length && response.propositions) - || (response.decisions?.length && response.decisions) - || [] - ).map((i) => i.items).flat(); - - if (!items?.length) return []; - - return items - .map((item) => { - const content = item?.data?.content; - if (!content || !(content.manifestLocation || content.manifestContent)) return null; - - return { - manifestPath: content.manifestLocation || content.manifestPath, - manifestUrl: content.manifestLocation, - manifestData: content.manifestContent?.experiences?.data || content.manifestContent?.data, - manifestPlaceholders: content.manifestContent?.placeholders?.data, - manifestInfo: content.manifestContent?.info.data, - name: item.meta['activity.name'], - variantLabel: item.meta['experience.name'] && `target-${item.meta['experience.name']}`, - meta: item.meta, - }; - }) - .filter(Boolean); -}; - function roundToQuarter(num) { return Math.ceil(num / 250) / 4; } @@ -85,36 +62,9 @@ function calculateResponseTime(responseStart) { return roundToQuarter(responseTime); } -function sendTargetResponseAnalytics(failure, responseStart, timeout, message) { - // temporary solution until we can decide on a better timeout value - const responseTime = calculateResponseTime(responseStart); - const timeoutTime = roundToQuarter(timeout); - let val = `target response time ${responseTime}:timed out ${failure}:timeout ${timeoutTime}`; - if (message) val += `:${message}`; - // eslint-disable-next-line no-underscore-dangle - window._satellite?.track?.('event', { - documentUnloading: true, - xdm: { - eventType: 'web.webinteraction.linkClicks', - web: { - webInteraction: { - linkClicks: { value: 1 }, - type: 'other', - name: val, - }, - }, - }, - data: { _adobe_corpnew: { digitalData: { primaryEvent: { eventInfo: { eventName: val } } } } }, - }); -} - -export const getTargetPersonalization = async () => { - const params = new URL(window.location.href).searchParams; - - const timeout = parseInt(params.get('target-timeout'), 10) - || parseInt(getMetadata('target-timeout'), 10) - || TARGET_TIMEOUT_MS; - +export const getTargetPersonalization = async ( + { handleAlloyResponse, sendTargetResponseAnalytics }, +) => { const responseStart = Date.now(); window.addEventListener(ALLOY_SEND_EVENT, () => { const responseTime = calculateResponseTime(responseStart); @@ -128,6 +78,7 @@ export const getTargetPersonalization = async () => { let targetManifests = []; let targetPropositions = []; + const response = await waitForEventOrTimeout(ALLOY_SEND_EVENT, timeout); if (response.error) { try { @@ -194,7 +145,7 @@ const loadMartechFiles = async (config) => { .then(() => { if (window.adobeIMS.isSignedInUser()) setupEntitlementCallback(); }) - .catch(() => {}); + .catch(() => { }); } setDeep( diff --git a/libs/utils/utils.js b/libs/utils/utils.js index 9cae5ef3e6..40355460ac 100644 --- a/libs/utils/utils.js +++ b/libs/utils/utils.js @@ -140,6 +140,7 @@ ENVS.local = { }; export const MILO_EVENTS = { DEFERRED: 'milo:deferred' }; +const TARGET_TIMEOUT_MS = 4000; const LANGSTORE = 'langstore'; const PREVIEW = 'target-preview'; @@ -148,7 +149,7 @@ export const SLD = PAGE_URL.hostname.includes('.aem.') ? 'aem' : 'hlx'; const PROMO_PARAM = 'promo'; -function getEnv(conf) { +export function getEnv(conf) { const { host } = window.location; const query = PAGE_URL.searchParams.get('env'); @@ -197,7 +198,7 @@ export function getMetadata(name, doc = document) { const handleEntitlements = (() => { const { martech } = Object.fromEntries(PAGE_URL.searchParams); - if (martech === 'off') return () => {}; + if (martech === 'off') return () => { }; let entResolve; const entPromise = new Promise((resolve) => { entResolve = resolve; @@ -311,7 +312,7 @@ export function localizeLink( const isLocalizedLink = path.startsWith(`/${LANGSTORE}`) || path.startsWith(`/${PREVIEW}`) || Object.keys(locales).some((loc) => loc !== '' && (path.startsWith(`/${loc}/`) - || path.endsWith(`/${loc}`))); + || path.endsWith(`/${loc}`))); if (isLocalizedLink) return processedHref; const urlPath = `${locale.prefix}${path}${url.search}${hash}`; return relative ? urlPath : `${url.origin}${urlPath}`; @@ -764,7 +765,7 @@ function decorateHeader() { } header.className = headerMeta || 'global-navigation'; const metadataConfig = getMetadata('breadcrumbs')?.toLowerCase() - || getConfig().breadcrumbs; + || getConfig().breadcrumbs; if (metadataConfig === 'off') return; const baseBreadcrumbs = getMetadata('breadcrumbs-base')?.length; @@ -826,8 +827,8 @@ export async function decoratePlaceholders(area, config) { area.dataset.hasPlaceholders = 'true'; const placeholderPath = `${config.locale?.contentRoot}/placeholders.json`; placeholderRequest = placeholderRequest - || customFetch({ resource: placeholderPath, withCacheRules: true }) - .catch(() => ({})); + || customFetch({ resource: placeholderPath, withCacheRules: true }) + .catch(() => ({})); const { decoratePlaceholderArea } = await import('../features/placeholders.js'); await decoratePlaceholderArea({ placeholderPath, placeholderRequest, nodes }); } @@ -1023,7 +1024,7 @@ export async function loadMartech({ } window.targetGlobalSettings = { bodyHidingEnabled: false }; - loadIms().catch(() => {}); + loadIms().catch(() => { }); const { default: initMartech } = await import('../martech/martech.js'); await initMartech({ persEnabled, persManifests, postLCP }); @@ -1031,6 +1032,30 @@ export async function loadMartech({ return true; } +/** + * Checks if the user is signed out based on the server timing and navigation performance. + * + * @returns {boolean} True if the user is signed out, otherwise false. + */ +function isSignedOut() { + const serverTiming = window.performance?.getEntriesByType('navigation')?.[0]?.serverTiming?.reduce( + (acc, { name, description }) => ({ ...acc, [name]: description }), + {}, + ); + + return !Object.keys(serverTiming || {}).length || serverTiming?.sis === '0'; +} + +/** + * Enables personalization (V2) for the page. + * + * @returns {boolean} True if personalization is enabled, otherwise false. + */ +export function enablePersonalizationV2() { + const enablePersV2 = document.head.querySelector('meta[name="personalization-v2"]'); + return !!enablePersV2 && isSignedOut(); +} + async function checkForPageMods() { const { mep: mepParam, @@ -1038,13 +1063,51 @@ async function checkForPageMods() { mepButton, martech, } = Object.fromEntries(PAGE_URL.searchParams); + let targetInteractionPromise = null; if (mepParam === 'off') return; const pzn = getMepEnablement('personalization'); const promo = getMepEnablement('manifestnames', PROMO_PARAM); const target = martech === 'off' ? false : getMepEnablement('target'); const xlg = martech === 'off' ? false : getMepEnablement('xlg'); + if (!(pzn || target || promo || mepParam || mepHighlight || mepButton || mepParam === '' || xlg)) return; + + const enablePersV2 = enablePersonalizationV2(); + if (martech !== 'off' && (target || xlg || pzn) && enablePersV2) { + const params = new URL(window.location.href).searchParams; + const calculatedTimeout = parseInt(params.get('target-timeout'), 10) + || parseInt(getMetadata('target-timeout'), 10) + || TARGET_TIMEOUT_MS; + + const { locale } = getConfig(); + targetInteractionPromise = (async () => { + const { loadAnalyticsAndInteractionData } = await import('../martech/helpers.js'); + const now = performance.now(); + performance.mark('interaction-start'); + const data = await loadAnalyticsAndInteractionData( + { locale, env: getEnv({})?.name, calculatedTimeout }, + ); + performance.mark('interaction-end'); + performance.measure('total-time', 'interaction-start', 'interaction-end'); + const respTime = performance.getEntriesByName('total-time')[0]; + + return { targetInteractionData: data, respTime, respStartTime: now }; + })(); + + const { init } = await import('../features/personalization/personalization.js'); + await init({ + mepParam, + mepHighlight, + mepButton, + pzn, + promo, + target, + targetInteractionPromise, + calculatedTimeout, + }); + return; + } if (target || xlg) { loadMartech(); } else if (pzn && martech !== 'off') { @@ -1058,7 +1121,7 @@ async function checkForPageMods() { const { init } = await import('../features/personalization/personalization.js'); await init({ - mepParam, mepHighlight, mepButton, pzn, promo, target, + mepParam, mepHighlight, mepButton, pzn, promo, target, targetInteractionPromise, }); } @@ -1070,9 +1133,13 @@ async function loadPostLCP(config) { /* c8 ignore next 2 */ const { init } = await import('../features/personalization/personalization.js'); await init({ postLCP: true }); + if (enablePersonalizationV2()) { + loadMartech(); + } } else { loadMartech(); } + const georouting = getMetadata('georouting') || config.geoRouting; if (georouting === 'on') { const { default: loadGeoRouting } = await import('../features/georoutingv2/georoutingv2.js'); diff --git a/test/martech/helpers.test.js b/test/martech/helpers.test.js new file mode 100644 index 0000000000..880b9ca6a5 --- /dev/null +++ b/test/martech/helpers.test.js @@ -0,0 +1,260 @@ +import { expect } from 'chai'; +import { loadAnalyticsAndInteractionData } from '../../libs/martech/helpers.js'; + +describe('loadAnalyticsAndInteractionData', () => { + beforeEach(() => { + window.fetch = () => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + handle: [ + { + type: 'personalization:decisions', + payload: [{ decisionId: '1', proposition: 'Test Proposition' }], + }, + ], + }), + }); + }); + + afterEach(() => { + delete window.fetch; + }); + + it('should fetch and return the proposition data', async () => { + const result = await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 10000, + }); + + expect(result).to.deep.equal({ + type: 'propositionFetch', + result: { propositions: [{ decisionId: '1', proposition: 'Test Proposition' }] }, + }); + }); + + it('should handle consent cookie being set to "out"', async () => { + window.getCookie = () => 'general%3Dout'; + + try { + await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 10000, + }); + } catch (err) { + expect(err.message).to.equal('Consent Cookie doesnt allow interact'); + } + }); + + it('should handle fetch failure gracefully', async () => { + window.fetch = () => Promise.reject(new Error('Network error')); + + try { + await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 10000, + }); + } catch (err) { + expect(err.message).to.equal('Error: Network error'); + } + }); + + it('should handle timeout errors correctly', async () => { + window.fetch = () => new Promise((_, reject) => { + setTimeout(() => reject(new Error('Request timed out')), 2000); + }); + + try { + await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 1000, + }); + } catch (err) { + expect(err.message).to.equal('Error: Request timed out'); + } + }); + + it('should handle missing handle in the response', async () => { + window.fetch = () => Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + + try { + await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 10000, + }); + } catch (err) { + expect(err.message).to.be.string; + } + }); + + it('should return the correct target property for non-prod environments', async () => { + window.getCookie = () => null; + + const result = await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'dev', + calculatedTimeout: 10000, + }); + + expect(result.type).to.equal('propositionFetch'); + expect(result.result.propositions).to.have.lengthOf(1); + }); + + it('should return the correct target property for prod environment', async () => { + window.getCookie = () => null; + + const result = await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 10000, + }); + + expect(result.type).to.equal('propositionFetch'); + expect(result.result.propositions).to.have.lengthOf(1); + }); + + it('should handle different screen orientations', async () => { + window.getDeviceInfo = () => ({ + screenWidth: 1920, + screenHeight: 1080, + screenOrientation: 'portrait', + viewportWidth: 800, + viewportHeight: 600, + }); + + const result = await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 10000, + }); + + expect(result.type).to.equal('propositionFetch'); + expect(result.result.propositions).to.have.lengthOf(1); + }); + + it('should generate a new FPID when ECID is not present', async () => { + window.innerWidth = 234; + window.innerHeight = 1234; + window.getCookie = () => null; + const data = { + handle: [ + { + type: 'personalization:decisions', + payload: [ + { + id: '12345', + namespace: { code: 'ECID' }, + }, + { + id: '67890', + namespace: { code: 'Other' }, + }, + ], + }, + { + type: 'user:profile', + payload: [ + { + id: '98765', + namespace: { code: 'ECID' }, + }, + { + id: '54321', + namespace: { code: 'Other' }, + }, + ], + }, + { + type: 'session:info', + payload: [ + { + id: '11223', + namespace: { code: 'ECID' }, + }, + ], + }, + ], + }; + window.fetch = () => Promise.resolve({ + ok: true, + json: () => Promise.resolve(data), + }); + + const result = await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 10000, + }); + + expect(result.type).to.equal('propositionFetch'); + expect(result.result.propositions).to.have.lengthOf(2); + }); + + it('should handle targetResp.ok as false and throw an error', async () => { + window.fetch = () => Promise.resolve({ + ok: false, + json: () => Promise.resolve({}), + }); + try { + await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 10000, + }); + + expect.fail('Some error'); + } catch (err) { + expect(err.message).to.equal('Error: Failed to fetch interact call'); + } + }); + + it('should handle targetRespwhen no prepositions are there', async () => { + window.fetch = () => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + handle: [ + { + type: 'personalization:decisions', + payload: [], + }, + { + type: 'other:type', + payload: [ + { decisionId: '3', proposition: 'Other Proposition' }, + ], + }, + ], + }), + }); + try { + await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 10000, + }); + } catch (err) { + expect(err.message).to.equal('Error: No propositions found'); + } + }); + it('should throw error when cookie prohibits it', async () => { + document.cookie = 'kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_consent=general=out'; + window.getCookie = () => null; + + try { + await loadAnalyticsAndInteractionData({ + locale: { ietf: 'en-US', prefix: 'us' }, + env: 'prod', + calculatedTimeout: 1000, + }); + } catch (err) { + expect(err.message).to.equal('Consent Cookie doesnt allow interact'); + } + }); +}); diff --git a/test/utils/utils-enable-perf.test.js b/test/utils/utils-enable-perf.test.js new file mode 100644 index 0000000000..b96c3bac5a --- /dev/null +++ b/test/utils/utils-enable-perf.test.js @@ -0,0 +1,79 @@ +import { expect } from '@esm-bundle/chai'; +import { enablePersonalizationV2 } from '../../libs/utils/utils.js'; + +function mockPerformanceData(serverTimingData) { + window.performance.getEntriesByType = () => [{ serverTiming: serverTimingData }]; +} + +describe('enablePersonalizationV2', () => { + let originalQuerySelector; + + beforeEach(() => { + originalQuerySelector = document.head.querySelector; + }); + + afterEach(() => { + document.head.querySelector = originalQuerySelector; + }); + + it('should return true when personalization-v2 meta tag is present and user is signed out', () => { + document.head.querySelector = () => ({ name: 'personalization-v2' }); + + mockPerformanceData([]); + + const result = enablePersonalizationV2(); + expect(result).to.be.true; + }); + + it('should return false when personalization-v2 meta tag is present and user is signed in', () => { + document.head.querySelector = () => ({ name: 'personalization-v2' }); + + mockPerformanceData([{ name: 'sis', description: '1' }]); + + const result = enablePersonalizationV2(); + expect(result).to.be.false; + }); + + it('should return false when personalization-v2 meta tag is absent', () => { + document.head.querySelector = () => null; + + const result = enablePersonalizationV2(); + expect(result).to.be.false; + }); + + it('should return true when serverTiming is empty (signed out)', () => { + document.head.querySelector = () => ({ name: 'personalization-v2' }); + + mockPerformanceData([]); + + const result = enablePersonalizationV2(); + expect(result).to.be.true; + }); + + it('should return false when serverTiming has `sis` other than `0` (signed in)', () => { + document.head.querySelector = () => ({ name: 'personalization-v2' }); + + mockPerformanceData([{ name: 'sis', description: '1' }]); + + const result = enablePersonalizationV2(); + expect(result).to.be.false; + }); + + it('should return true when serverTiming has `sis` equal to `0` (signed out)', () => { + document.head.querySelector = () => ({ name: 'personalization-v2' }); + + mockPerformanceData([{ name: 'sis', description: '0' }]); + + const result = enablePersonalizationV2(); + expect(result).to.be.true; + }); + + it('should return false when serverTiming has other data but `sis` is missing (signed in)', () => { + document.head.querySelector = () => ({ name: 'personalization-v2' }); + + mockPerformanceData([{ name: 'other', description: 'value' }]); + + const result = enablePersonalizationV2(); + expect(result).to.be.false; + }); +}); diff --git a/test/utils/utils.test.js b/test/utils/utils.test.js index 0e3ce81045..88a39e6e1d 100644 --- a/test/utils/utils.test.js +++ b/test/utils/utils.test.js @@ -76,6 +76,7 @@ describe('Utils', () => { it('preloads blocks for performance reasons', async () => { document.head.innerHTML = head; document.body.innerHTML = await readFile({ path: './mocks/marquee.html' }); + await utils.loadArea(); const scriptPreload = document.head.querySelector('link[href*="/libs/blocks/marquee/marquee.js"]'); const marqueeDecoratePreload = document.head.querySelector('link[href*="/libs/utils/decorate.js"]'); @@ -94,11 +95,30 @@ describe('Utils', () => { expect(document.querySelector('.global-navigation')).to.exist; }); + it('render meta performanceV2 renders the normal flow', async () => { + // const spyOnLoadMartech = sinon.spy(loadMartech); + const localHead = await readFile({ path: './mocks/mep/head-target-postlcp.html' }); + document.head.innerHTML = localHead; + const metaTag = document.createElement('meta'); + metaTag.setAttribute('name', 'personalization-v2'); + document.head.appendChild(metaTag); + + const bodyWithheader = await readFile({ path: './mocks/body-gnav.html' }); + document.body.innerHTML = bodyWithheader; + + await utils.loadArea(); + // expect(spyOnLoadMartech.called).to.be.true; + // spyOnLoadMartech.resetHistory(); + expect(document.querySelector('.global-navigation')).to.exist; + }); + describe('with body', () => { beforeEach(async () => { window.fetch = mockFetch({ payload: { data: '' } }); + document.head.innerHTML = head; document.body.innerHTML = body; + await utils.loadArea(); sinon.spy(console, 'log'); }); From ac9010a9facbfcf183c20ef8ec2a30e38a4e5497 Mon Sep 17 00:00:00 2001 From: Rares Munteanu Date: Wed, 4 Dec 2024 13:24:41 +0100 Subject: [PATCH 09/11] [MWPW-161403] Remove default alt from media block (#3286) --- libs/blocks/media/media.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/libs/blocks/media/media.js b/libs/blocks/media/media.js index 62b3af490c..65a91d8c7f 100644 --- a/libs/blocks/media/media.js +++ b/libs/blocks/media/media.js @@ -79,9 +79,7 @@ export default async function init(el) { decorateBlockText(text, blockTypeSizes[size], blockType); } const image = row.querySelector(':scope > div:not([class])'); - if (image) image.classList.add('image'); - const img = image?.querySelector(':scope img'); - if (header && img?.alt === '') img.alt = header.textContent; + image?.classList.add('image'); const imageVideo = image?.querySelector('video'); if (imageVideo) applyHoverPlay(imageVideo); From e86a8868e172f335b420a9e0195be3f7f7edac76 Mon Sep 17 00:00:00 2001 From: Jason Slavin Date: Thu, 5 Dec 2024 02:29:11 -0800 Subject: [PATCH 10/11] Revert "MWPW-155723 - Adds group metadata to ensure dyanmic nav continuity " (#3308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "MWPW-155723 - Adds group metadata to ensure dyanmic nav continuity (…" This reverts commit 22dab6644861e52865ab288c0149d91b912823b3. --- .../dynamic-navigation/dynamic-navigation.js | 14 ---- libs/features/dynamic-navigation/status.js | 8 +-- test/features/dynamic-nav/dynamicNav.test.js | 30 -------- test/features/dynamic-nav/status.test.js | 70 +------------------ 4 files changed, 2 insertions(+), 120 deletions(-) diff --git a/libs/features/dynamic-navigation/dynamic-navigation.js b/libs/features/dynamic-navigation/dynamic-navigation.js index 7d943cc597..7354518a58 100644 --- a/libs/features/dynamic-navigation/dynamic-navigation.js +++ b/libs/features/dynamic-navigation/dynamic-navigation.js @@ -14,30 +14,16 @@ export function foundDisableValues() { return foundValues.length ? foundValues : false; } -function dynamicNavGroupMatches(groupMetaData) { - const storedGroup = window.sessionStorage.getItem('dynamicNavGroup'); - if (groupMetaData && storedGroup) { - return storedGroup.toLowerCase() === groupMetaData.toLowerCase(); - } - return false; -} - export default function dynamicNav(url, key) { if (foundDisableValues()) return url; const metadataContent = getMetadata('dynamic-nav'); - const dynamicNavGroup = getMetadata('dynamic-nav-group'); if (metadataContent === 'entry') { window.sessionStorage.setItem('gnavSource', url); window.sessionStorage.setItem('dynamicNavKey', key); - if (dynamicNavGroup) window.sessionStorage.setItem('dynamicNavGroup', dynamicNavGroup); return url; } - if (metadataContent === 'on' && dynamicNavGroup) { - if (!dynamicNavGroupMatches(dynamicNavGroup)) return url; - } - if (metadataContent !== 'on' || key !== window.sessionStorage.getItem('dynamicNavKey')) return url; return window.sessionStorage.getItem('gnavSource') || url; diff --git a/libs/features/dynamic-navigation/status.js b/libs/features/dynamic-navigation/status.js index 3321459581..792f586765 100644 --- a/libs/features/dynamic-navigation/status.js +++ b/libs/features/dynamic-navigation/status.js @@ -80,11 +80,7 @@ const createStatusWidget = (dynamicNavKey) => { const currentSource = getCurrentSource(dynamicNavSetting, storedSource, authoredSource); const dynamicNavDisableValues = getMetadata('dynamic-nav-disable'); const foundValues = foundDisableValues(); - const groupMetaSetting = getMetadata('dynamic-nav-group') || 'Group not set'; - const groupsMatch = groupMetaSetting.toLowerCase() === window.sessionStorage.getItem('dynamicNavGroup').toLowerCase(); - const groupsMatchMessage = groupsMatch ? 'Yes' : 'No'; - const isDisabled = foundValues.length >= 1 || (!groupsMatch && groupMetaSetting !== 'Group not set'); - const status = getStatus(dynamicNavSetting, isDisabled, storedSource); + const status = getStatus(dynamicNavSetting, foundValues.length >= 1, storedSource); const statusWidget = createTag('div', { class: 'dynamic-nav-status' }); statusWidget.innerHTML = ` @@ -98,8 +94,6 @@ const createStatusWidget = (dynamicNavKey) => {

Status: ${status}

Setting: ${dynamicNavSetting}

-

Group: ${groupMetaSetting}

-

Group matches stored group: ${groupsMatchMessage}

Consumer key: ${dynamicNavKey}