From 0aff45f5900e03c9a68ac329f1c411fddb0621f3 Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Tue, 25 Mar 2025 17:28:09 -0700 Subject: [PATCH 01/17] support contextual experimentation UI -v2 version --- src/index.js | 73 ++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/src/index.js b/src/index.js index caf210f..7fc80e6 100644 --- a/src/index.js +++ b/src/index.js @@ -639,13 +639,37 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) { return null; } + const thumbnailMeta = + document.querySelector('meta[property="og:image:secure_url"]') || + document.querySelector('meta[property="og:image"]'); + const thumbnail = thumbnailMeta ? thumbnailMeta.getAttribute('content') : ''; + const audiences = stringToArray(metadata.audiences).map(toClassName); const splits = metadata.split - // custom split - ? stringToArray(metadata.split).map((i) => parseFloat(i) / 100) - // even split - : [...new Array(pages.length)].map(() => 1 / (pages.length + 1)); + ? // custom split + (() => { + const splitValues = stringToArray(metadata.split).map( + (i) => parseFloat(i) / 100 + ); + + // If fewer splits than pages, pad with zeros + if (splitValues.length < pages.length) { + return [ + ...splitValues, + ...Array(pages.length - splitValues.length).fill(0), + ]; + } + + // If more splits than needed, truncate + if (splitValues.length > pages.length) { + return splitValues.slice(0, pages.length); + } + + return splitValues; + })() +: // even split + [...new Array(pages.length)].map(() => 1 / (pages.length + 1)); const variantNames = []; variantNames.push('control'); @@ -695,6 +719,7 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) { startDate, variants, variantNames, + thumbnail, }; config.run = ( @@ -970,18 +995,38 @@ export async function loadEager(document, options = {}) { ns.campaign = ns.campaigns.find((e) => e.type === 'page'); } -export async function loadLazy(document, options = {}) { - const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; +export async function loadLazy() { + // const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; // do not show the experimentation pill on prod domains if (!isDebugEnabled) { return; } - // eslint-disable-next-line import/no-unresolved - const preview = await import('https://opensource.adobe.com/aem-experimentation/preview.js'); - const context = { - getMetadata, - toClassName, - debug, - }; - preview.default.call(context, document, pluginOptions); + // Add event listener for experimentation config requests + window.addEventListener('message', (event) => { + if (event.data?.type === 'hlx:experimentation-get-config') { + try { + const safeClone = JSON.parse(JSON.stringify(window.hlx)); + + event.source.postMessage( + { + type: 'hlx:experimentation-config', + config: safeClone, + source: 'index-js', + }, + '*' + ); + } catch (e) { + console.error('Error sending hlx config:', e); + } + } + }); + + // // eslint-disable-next-line import/no-unresolved + // const preview = await import('https://opensource.adobe.com/aem-experimentation/preview.js'); + // const context = { + // getMetadata, + // toClassName, + // debug, + // }; + // preview.default.call(context, document, pluginOptions); } From c4cb8fb77807e569c5eb16c273bf9d66359d51e6 Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Mon, 31 Mar 2025 10:02:10 -0700 Subject: [PATCH 02/17] add event listener for polling header --- src/index.js | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 7fc80e6..443de01 100644 --- a/src/index.js +++ b/src/index.js @@ -1001,9 +1001,39 @@ export async function loadLazy() { if (!isDebugEnabled) { return; } - // Add event listener for experimentation config requests - window.addEventListener('message', (event) => { - if (event.data?.type === 'hlx:experimentation-get-config') { + + window.addEventListener('message', async (event) => { + // Handle Last-Modified request + if (event.data && event.data.type === 'hlx:last-modified-request') { + const url = event.data.url; + + try { + const response = await fetch(url, { + method: 'HEAD', + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + }, + }); + + const lastModified = response.headers.get('Last-Modified'); + console.log('Last-Modified header for', url, ':', lastModified); + + event.source.postMessage( + { + type: 'hlx:last-modified-response', + url: url, + lastModified: lastModified, + status: response.status, + }, + event.origin + ); + } catch (error) { + console.error('Error fetching Last-Modified header:', error); + } + } + // Handle experimentation config request + else if (event.data?.type === 'hlx:experimentation-get-config') { try { const safeClone = JSON.parse(JSON.stringify(window.hlx)); @@ -1019,6 +1049,13 @@ export async function loadLazy() { console.error('Error sending hlx config:', e); } } + // Handle window reload request + else if ( + event.data?.type === 'hlx:experimentation-window-reload' && + event.data?.action === 'reload' + ) { + window.location.reload(); + } }); // // eslint-disable-next-line import/no-unresolved From 1c0059eb439d010eceae4d127bdfc2b732e0257f Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Mon, 31 Mar 2025 17:20:48 -0700 Subject: [PATCH 03/17] fix lint and event listener --- src/index.js | 80 ++++++++++++++++++++-------------------------------- 1 file changed, 31 insertions(+), 49 deletions(-) diff --git a/src/index.js b/src/index.js index 443de01..77d7c76 100644 --- a/src/index.js +++ b/src/index.js @@ -639,37 +639,33 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) { return null; } - const thumbnailMeta = - document.querySelector('meta[property="og:image:secure_url"]') || - document.querySelector('meta[property="og:image"]'); + const thumbnailMeta = document.querySelector('meta[property="og:image:secure_url"]') + || document.querySelector('meta[property="og:image"]'); const thumbnail = thumbnailMeta ? thumbnailMeta.getAttribute('content') : ''; const audiences = stringToArray(metadata.audiences).map(toClassName); const splits = metadata.split - ? // custom split - (() => { - const splitValues = stringToArray(metadata.split).map( - (i) => parseFloat(i) / 100 - ); + ? (() => { + const splitValues = stringToArray(metadata.split).map( + (i) => parseFloat(i) / 100, + ); - // If fewer splits than pages, pad with zeros - if (splitValues.length < pages.length) { - return [ - ...splitValues, - ...Array(pages.length - splitValues.length).fill(0), - ]; - } + // If fewer splits than pages, pad with zeros + if (splitValues.length < pages.length) { + return [ + ...splitValues, + ...Array(pages.length - splitValues.length).fill(0), + ]; + } - // If more splits than needed, truncate - if (splitValues.length > pages.length) { - return splitValues.slice(0, pages.length); - } + // If more splits than needed, truncate + if (splitValues.length > pages.length) { + return splitValues.slice(0, pages.length); + } - return splitValues; - })() -: // even split - [...new Array(pages.length)].map(() => 1 / (pages.length + 1)); + return splitValues; + })() : [...new Array(pages.length)].map(() => 1 / (pages.length + 1)); const variantNames = []; variantNames.push('control'); @@ -996,16 +992,14 @@ export async function loadEager(document, options = {}) { } export async function loadLazy() { - // const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; // do not show the experimentation pill on prod domains if (!isDebugEnabled) { return; } - + window.addEventListener('message', async (event) => { - // Handle Last-Modified request if (event.data && event.data.type === 'hlx:last-modified-request') { - const url = event.data.url; + const { url } = event.data; try { const response = await fetch(url, { @@ -1017,23 +1011,21 @@ export async function loadLazy() { }); const lastModified = response.headers.get('Last-Modified'); - console.log('Last-Modified header for', url, ':', lastModified); event.source.postMessage( { type: 'hlx:last-modified-response', - url: url, - lastModified: lastModified, + url, + lastModified, status: response.status, }, - event.origin + event.origin, ); } catch (error) { + // eslint-disable-next-line no-console console.error('Error fetching Last-Modified header:', error); } - } - // Handle experimentation config request - else if (event.data?.type === 'hlx:experimentation-get-config') { + } else if (event.data?.type === 'hlx:experimentation-get-config') { try { const safeClone = JSON.parse(JSON.stringify(window.hlx)); @@ -1043,27 +1035,17 @@ export async function loadLazy() { config: safeClone, source: 'index-js', }, - '*' + '*', ); } catch (e) { + // eslint-disable-next-line no-console console.error('Error sending hlx config:', e); } - } - // Handle window reload request - else if ( - event.data?.type === 'hlx:experimentation-window-reload' && - event.data?.action === 'reload' + } else if ( + event.data?.type === 'hlx:experimentation-window-reload' + && event.data?.action === 'reload' ) { window.location.reload(); } }); - - // // eslint-disable-next-line import/no-unresolved - // const preview = await import('https://opensource.adobe.com/aem-experimentation/preview.js'); - // const context = { - // getMetadata, - // toClassName, - // debug, - // }; - // preview.default.call(context, document, pluginOptions); } From 950b841645fccb4db527c43f41e52065ab77dc53 Mon Sep 17 00:00:00 2001 From: Julien Ramboz Date: Wed, 28 May 2025 23:27:04 +0200 Subject: [PATCH 04/17] feat: pass prodHost value to the experimentation rail --- src/index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 77d7c76..b8c50ce 100644 --- a/src/index.js +++ b/src/index.js @@ -991,7 +991,7 @@ export async function loadEager(document, options = {}) { ns.campaign = ns.campaigns.find((e) => e.type === 'page'); } -export async function loadLazy() { +export async function loadLazy(document, options = {}) { // do not show the experimentation pill on prod domains if (!isDebugEnabled) { return; @@ -1028,7 +1028,9 @@ export async function loadLazy() { } else if (event.data?.type === 'hlx:experimentation-get-config') { try { const safeClone = JSON.parse(JSON.stringify(window.hlx)); - + if (options.prodHost) { + safeClone.prodHost = options.prodHost; + } event.source.postMessage( { type: 'hlx:experimentation-config', From 6feefdb6039e828d473fdf432e2c33d4cbee06f0 Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Mon, 14 Jul 2025 08:44:09 -0700 Subject: [PATCH 05/17] add new optimizingTarget attribute in config --- src/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.js b/src/index.js index b8c50ce..21322c6 100644 --- a/src/index.js +++ b/src/index.js @@ -711,6 +711,7 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) { status: metadata.status || 'active', audiences, endDate, + optimizingTarget: metadata.optimizingTarget || 'conversion', resolvedAudiences, startDate, variants, From 004c54cf801122a451b41858b0f25258a243000d Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Tue, 23 Sep 2025 13:38:06 -0700 Subject: [PATCH 06/17] move event listener to loadeager --- src/index.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/index.js b/src/index.js index 21322c6..1d93e5c 100644 --- a/src/index.js +++ b/src/index.js @@ -990,14 +990,14 @@ export async function loadEager(document, options = {}) { ns.experiment = ns.experiments.find((e) => e.type === 'page'); ns.audience = ns.audiences.find((e) => e.type === 'page'); ns.campaign = ns.campaigns.find((e) => e.type === 'page'); -} -export async function loadLazy(document, options = {}) { - // do not show the experimentation pill on prod domains - if (!isDebugEnabled) { - return; + if (isDebugEnabled) { + setupCommunicationLayer(pluginOptions); } +} +// Support new Rail UI communication +function setupCommunicationLayer(options) { window.addEventListener('message', async (event) => { if (event.data && event.data.type === 'hlx:last-modified-request') { const { url } = event.data; @@ -1052,3 +1052,10 @@ export async function loadLazy(document, options = {}) { } }); } + +export async function loadLazy(document, options = {}) { + // do not show the experimentation pill on prod domains + if (!isDebugEnabled) { + return; + } +} From f9fd60ac4a6e5b3d4535d46577b0f336fb315d5c Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Tue, 7 Oct 2025 20:22:58 -0700 Subject: [PATCH 07/17] add metadata label into config for new rail to display --- src/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 1d93e5c..c129325 100644 --- a/src/index.js +++ b/src/index.js @@ -707,7 +707,7 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) { const config = { id, - label: `Experiment ${metadata.value || metadata.experiment}`, + label: metadata.label || `Experiment ${metadata.value || metadata.experiment}`, status: metadata.status || 'active', audiences, endDate, From 37ba23ff895766e4356d0a5d52afdd0adcac04f4 Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Mon, 27 Oct 2025 13:34:35 -0700 Subject: [PATCH 08/17] feat:implement consent based experiment --- README.md | 148 +++++++++++++++++++++++++++++++++++ documentation/experiments.md | 101 ++++++++++++++++++++++++ src/index.js | 51 +++++++++++- 3 files changed, 299 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 41d7f45..04ad5fe 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ The AEM Experimentation plugin supports: - :busts_in_silhouette: serving different content variations to different audiences, including custom audience definitions for your project that can be either resolved directly in-browser or against a trusted backend API. - :money_with_wings: serving different content variations based on marketing campaigns you are running, so that you can easily track email and/or social campaigns - :chart_with_upwards_trend: running A/B test experiments on a set of variants to measure and improve the conversion on your site. This works particularly with our :chart: [RUM conversion tracking plugin](https://github.com/adobe/franklin-rum-conversion). +- :shield: privacy-compliant experimentation with built-in consent management support for GDPR, CCPA, and other privacy regulations - :rocket: easy simulation of each experience and basic reporting leveraging in-page overlays ## Installation @@ -200,6 +201,152 @@ The plugin exposes experiment data through two mechanisms: ### Available APIs +#### Consent Management + +The plugin provides consent management APIs for privacy compliance. Experiments can be configured to require user consent before running. + +**APIs:** + +```javascript +import { + hasExperimentationConsent, + updateExperimentationConsent +} from './plugins/experimentation/src/index.js'; + +// Check if user has consented to experimentation +const hasConsent = hasExperimentationConsent(); + +// Update consent status (call this from your consent management platform) +updateExperimentationConsent(true); // or false to revoke consent +``` + +**Requiring consent for an experiment:** + +Add the `Experiment Requires Consent` metadata property: + +| Metadata | | +|-----------------------|--------------------------------------------------------------| +| Experiment | Hero Test | +| Experiment Variants | /variant-1, /variant-2 | +| Experiment Requires Consent | true | + +**Implementation:** + +You can integrate consent management in two ways: + +1. **In your `experiment-loader.js`** (recommended) - keeps all experimentation code together +2. **In your `scripts.js`** - if you need consent for other purposes beyond experimentation + +
+Recommended: Integrate in experiment-loader.js + +```javascript +// experiment-loader.js +import { + updateExperimentationConsent, + hasExperimentationConsent, +} from '../plugins/experimentation/src/index.js'; + +/** + * Initialize consent management + * Choose ONE of the setup functions based on your CMP + */ +function initConsent() { + // OPTION 1: OneTrust + function setupOneTrustConsent() { + function handleOneTrustConsent() { + const activeGroups = window.OnetrustActiveGroups || ''; + const hasConsent = activeGroups.includes('C0003') || activeGroups.includes('C0004'); + updateExperimentationConsent(hasConsent); + } + window.OptanonWrapper = function() { + handleOneTrustConsent(); + }; + } + + // OPTION 2: Cookiebot + function setupCookiebotConsent() { + function handleCookiebotConsent() { + const preferences = window.Cookiebot?.consent?.preferences || false; + const marketing = window.Cookiebot?.consent?.marketing || false; + updateExperimentationConsent(preferences || marketing); + } + window.addEventListener('CookiebotOnConsentReady', handleCookiebotConsent); + window.addEventListener('CookiebotOnAccept', handleCookiebotConsent); + } + + // OPTION 3: Custom Consent Banner + function setupCustomConsent() { + document.addEventListener('consent-updated', (event) => { + updateExperimentationConsent(event.detail.experimentation); + }); + } + + // Choose ONE: + setupOneTrustConsent(); // or setupCookiebotConsent() or setupCustomConsent() +} + +export async function runExperimentation(document, config) { + if (!isExperimentationEnabled()) { + return null; + } + + // Initialize consent BEFORE loading experimentation + initConsent(); + + const { loadEager } = await import('../plugins/experimentation/src/index.js'); + return loadEager(document, config); +} + +// Export consent functions for use elsewhere if needed +export { updateExperimentationConsent, hasExperimentationConsent }; +``` + +Your `scripts.js` stays clean - no consent code needed there! + +
+ +
+Integrate in scripts.js + +```javascript +// scripts.js +import { + updateExperimentationConsent, + hasExperimentationConsent, +} from '../plugins/experimentation/src/index.js'; + +import { runExperimentation } from './experiment-loader.js'; + +// Setup consent (choose ONE based on your CMP) +function setupOneTrustConsent() { + function handleOneTrustConsent() { + const activeGroups = window.OnetrustActiveGroups || ''; + const hasConsent = activeGroups.includes('C0003') || activeGroups.includes('C0004'); + updateExperimentationConsent(hasConsent); + } + window.OptanonWrapper = function() { + handleOneTrustConsent(); + }; +} + +async function loadEager(doc) { + document.documentElement.lang = 'en'; + decorateTemplateAndTheme(); + + // Initialize consent BEFORE running experiments + setupOneTrustConsent(); + + await runExperimentation(doc, experimentationConfig); + + // ... rest of your code +} +``` + +
+ +For detailed usage instructions and more examples, see the [Experiments documentation](/documentation/experiments.md#consent-based-experiments). + #### Events Listen for the `aem:experimentation` event to react when experiments, campaigns, or audiences are applied: @@ -508,6 +655,7 @@ Here's the complete experiment config structure available in `window.hlx.experim variantNames: ["control", "challenger-1"], audiences: ["mobile", "desktop"], resolvedAudiences: ["mobile"], + requiresConsent: false, // whether this experiment requires user consent run: true, variants: { control: { percentageSplit: "0.5", pages: ["/current"], label: "Control" }, diff --git a/documentation/experiments.md b/documentation/experiments.md index bf2b0ac..5325952 100644 --- a/documentation/experiments.md +++ b/documentation/experiments.md @@ -128,6 +128,107 @@ Start and end dates are in the flexible JS [Date Time String Format](https://tc3 So you can both use generic dates, like `2024-01-31` or `2024/01/31`, and time-specific dates like `2024-01-31T13:37` or `2024/01/31 1:37 pm`. You can even enforce a specific timezone so your experiment activates when, say, it's 2am GMT+1 by using `2024/1/31 2:00 pm GMT+1` or similar notations. +#### Consent-based experiments + +For compliance with privacy regulations like GDPR, CCPA, and others, experiments can be configured to require user consent before running. This ensures that personalization and experimentation only occurs when users have explicitly agreed to it. + +##### Enabling consent requirement + +To require consent for an experiment, add the `Experiment Requires Consent` metadata property: + +| Metadata | | +|-----------------------|--------------------------------------------------------------| +| Experiment | Hero Test | +| Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2]() | +| Experiment Requires Consent | true | + +When this property is set to `true`, the experiment will only run if the user has provided consent for experimentation. If set to `false` or omitted, the experiment will run according to its other configuration rules (existing behavior). + +##### Managing consent status + +The experimentation runtime provides JavaScript APIs to manage user consent: + +**Check current consent status:** +```javascript +import { hasExperimentationConsent } from './path/to/experimentation/src/index.js'; + +const isConsented = hasExperimentationConsent(); +console.log('User has consented to experimentation:', isConsented); +``` + +**Update consent status:** +```javascript +import { updateExperimentationConsent } from './path/to/experimentation/src/index.js'; + +// Update consent (call this when your CMP sends a consent event) +updateExperimentationConsent(true); // or false to revoke consent +``` + +##### Integrating with consent management platforms + +Connect your consent management system (CMS) to track user consent. Call `updateExperimentationConsent` when your CMS sends a consent event. + +> **💡 Tip**: For cleaner code organization, we recommend placing consent integration in your `experiment-loader.js` file (if you have one) rather than in `scripts.js`. This keeps all experimentation-related code together. See the [README](/README.md#consent-management) for complete implementation examples. + +**Example: OneTrust integration** +```javascript +import { updateExperimentationConsent } from './path/to/experimentation/src/index.js'; + +function handleOneTrustConsent() { + const activeGroups = window.OnetrustActiveGroups || ''; + // C0003 = Functional/Performance, C0004 = Targeting + const hasConsent = activeGroups.includes('C0003') || activeGroups.includes('C0004'); + updateExperimentationConsent(hasConsent); +} + +// Hook into OneTrust callback +window.OptanonWrapper = function() { + handleOneTrustConsent(); +}; +``` + +**Example: Cookiebot integration** +```javascript +import { updateExperimentationConsent } from './path/to/experimentation/src/index.js'; + +function handleCookiebotConsent() { + const preferences = window.Cookiebot?.consent?.preferences || false; + const marketing = window.Cookiebot?.consent?.marketing || false; + updateExperimentationConsent(preferences || marketing); +} + +window.addEventListener('CookiebotOnConsentReady', handleCookiebotConsent); +window.addEventListener('CookiebotOnAccept', handleCookiebotConsent); +``` + +**Example: Custom consent banner** +```javascript +import { updateExperimentationConsent } from './path/to/experimentation/src/index.js'; + +// When user accepts/rejects consent +function onConsentChange(accepted) { + updateExperimentationConsent(accepted); +} +``` + +##### Storage mechanism + +Consent status is stored locally in the browser's `localStorage` under the key `experimentation-consented`. This ensures consent preferences persist across browser sessions while remaining privacy-compliant by staying local to the user's device. + +##### Behavior when consent is required but not given + +- Experiments requiring consent will not run if consent has not been provided +- The control experience (original page content) will be served +- No RUM events related to the skipped experiment will be fired +- The experiment configuration will still be available programmatically, but `config.run` will be `false` + +##### Best practices + +1. **Obtain consent early**: Implement consent collection as early as possible in the user journey +2. **Respect consent changes**: Re-evaluate running experiments when consent status changes +3. **Provide clear opt-out**: Ensure users can easily revoke consent if previously given +4. **Document consent requirements**: Clearly indicate which experiments require consent in your authoring guidelines + #### Redirect page experiments For the use case that fully redirect to the target URL instead of just replacing the content (our default behavior), you could add a new property `Experiment Resolution | redirect` in page metadata: | Metadata | | diff --git a/src/index.js b/src/index.js index caf210f..8d3e698 100644 --- a/src/index.js +++ b/src/index.js @@ -48,6 +48,8 @@ export const DEFAULT_OPTIONS = { decorateFunction: () => {}, }; +const CONSENT_STORAGE_KEY = 'experimentation-consented'; + /** * Converts a given comma-seperate string to an array. * @param {String|String[]} str The string to convert @@ -86,6 +88,49 @@ async function onPageActivation(cb) { } } +/** + * Reads the current consent status from localStorage. + * @returns {Boolean} true if consent is given, false otherwise + */ +function getConsentFromStorage() { + try { + return localStorage.getItem(CONSENT_STORAGE_KEY) === 'true'; + } catch (error) { + debug('Failed to read consent from localStorage:', error); + return false; + } +} + +/** + * Writes the consent status to localStorage. + * @param {Boolean} consented Whether the user has consented + */ +function setConsentInStorage(consented) { + try { + localStorage.setItem(CONSENT_STORAGE_KEY, consented ? 'true' : 'false'); + } catch (error) { + debug('Failed to save consent to localStorage:', error); + } +} + +/** + * Checks if user has given consent for experimentation. + * @returns {Boolean} true if consent is given, false otherwise + */ +export function hasExperimentationConsent() { + return getConsentFromStorage(); +} + +/** + * Sets the user consent status for experimentation. + * @param {Boolean} consented Whether the user has consented to experimentation + */ +export function updateExperimentationConsent(consented) { + setConsentInStorage(consented); + debug('Experimentation consent updated:', consented); +} + + /** * Fires a Real User Monitoring (RUM) event based on the provided type and configuration. * @param {string} type - the type of event to be fired ("experiment", "campaign", or "audience") @@ -684,12 +729,14 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) { const startDate = metadata.startDate ? new Date(metadata.startDate) : null; const endDate = metadata.endDate ? new Date(metadata.endDate) : null; - + const requiresConsent = metadata.requiresConsent === 'true'; + const config = { id, label: `Experiment ${metadata.value || metadata.experiment}`, status: metadata.status || 'active', audiences, + requiresConsent, endDate, resolvedAudiences, startDate, @@ -706,6 +753,8 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) { && (!overrides.audience || audiences.includes(overrides.audience)) && (!startDate || startDate <= Date.now()) && (!endDate || endDate > Date.now()) + // experiment has consent if required + && (!requiresConsent || hasExperimentationConsent()) ); if (!config.run) { From 839aed355beafe1c80277ce6c7caf26f71cff4dc Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Mon, 27 Oct 2025 13:39:30 -0700 Subject: [PATCH 09/17] fix lint --- src/index.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 8d3e698..eb8b35e 100644 --- a/src/index.js +++ b/src/index.js @@ -130,7 +130,6 @@ export function updateExperimentationConsent(consented) { debug('Experimentation consent updated:', consented); } - /** * Fires a Real User Monitoring (RUM) event based on the provided type and configuration. * @param {string} type - the type of event to be fired ("experiment", "campaign", or "audience") @@ -730,7 +729,7 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) { const startDate = metadata.startDate ? new Date(metadata.startDate) : null; const endDate = metadata.endDate ? new Date(metadata.endDate) : null; const requiresConsent = metadata.requiresConsent === 'true'; - + const config = { id, label: `Experiment ${metadata.value || metadata.experiment}`, From 7049c08b2f8adfe5246596281dd77d8a925956f8 Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Mon, 17 Nov 2025 11:57:18 -0800 Subject: [PATCH 10/17] update readme installation part for new ui --- README.md | 261 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 192 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index a048f14..723c9e7 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ The AEM Experimentation plugin helps you quickly set up experimentation and segm It is currently available to customers in collaboration with AEM Engineering via co-innovation VIP Projects. To implement experimentation or personalization use-cases, please reach out to the AEM Engineering team in the Slack channel dedicated to your project. +> **Note:** We are adding new support for the contextual experimentation rail UI. This is still under development. The instrumentation flow will be simplified once finalized. Feel free to reach out if you have any questions about experimentation or the contextual experimentation rail in the Slack channel **#contextual-exp-team**. + ## Features The AEM Experimentation plugin supports: @@ -15,100 +17,221 @@ The AEM Experimentation plugin supports: ## Installation Add the plugin to your AEM project by running: + ```sh -git subtree add --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2 +git subtree add --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2-ui ``` -If you later want to pull the latest changes and update your local copy of the plugin +If you later want to pull the latest changes and update your local copy of the plugin: + ```sh -git subtree pull --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2 +git subtree pull --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2-ui ``` If you prefer using `https` links you'd replace `git@github.com:adobe/aem-experimentation.git` in the above commands by `https://github.com/adobe/aem-experimentation.git`. ## Project instrumentation -### On top of a regular boilerplate project +### Starting from Boilerplate for Xwalk -Typically, you'd know you don't have the plugin system if you don't see a reference to `window.aem.plugins` or `window.hlx.plugins` in your `scripts.js`. In that case, you can still manually instrument this plugin in your project by falling back to a more manual instrumentation. To properly connect and configure the plugin for your project, you'll need to edit your `scripts.js` in your AEM project and add the following: +If you are starting from scratch, use the following template repository: +https://github.com/adobe-rnd/aem-boilerplate-xwalk -1. at the start of the file: - ```js - const experimentationConfig = { - prodHost: 'www.my-site.com', - audiences: { - mobile: () => window.innerWidth < 600, - desktop: () => window.innerWidth >= 600, - // define your custom audiences here as needed - } - }; - - let runExperimentation; - let showExperimentationOverlay; - const isExperimentationEnabled = document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"],[property^="campaign:"],[property^="audience:"]') - || [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i)); - if (isExperimentationEnabled) { - const { - loadEager: runExperimentation, - loadLazy: showExperimentationOverlay, - } = await import('../plugins/experimentation/src/index.js'); - } - ``` -2. Early in the `loadEager` method you'll need to add: - ```js - async function loadEager(doc) { - … - // Add below snippet early in the eager phase - if (runExperimentation) { - await runExperimentation(document, experimentationConfig); - } - … - } - ``` - This needs to be done as early as possible since this will be blocking the eager phase and impacting your LCP, so we want this to execute as soon as possible. -3. Finally at the end of the `loadLazy` method you'll have to add: - ```js - async function loadLazy(doc) { - … - // Add below snippet at the end of the lazy phase - if (showExperimentationOverlay) { - await showExperimentationOverlay(document, experimentationConfig); +For reference, check this example project: +https://github.com/sudo-buddy/ue-experimentation + +### Key Files to Add or Modify + +1. **plugins/experimentation** - Add this folder containing the experimentation engine plugins (see Installation section above) +2. **scripts/experiment-loader.js** - Add this script to handle experiment loading +3. **scripts/scripts.js** - Modify this script with the configuration + +### Step 1: Create `scripts/experiment-loader.js` + +Create a new file `scripts/experiment-loader.js` with the following content: + +```js +/** + * Checks if experimentation is enabled. + * @returns {boolean} True if experimentation is enabled, false otherwise. + */ +const isExperimentationEnabled = () => document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"],[property^="campaign:"],[property^="audience:"]') + || [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i)); + +/** + * Loads the experimentation module (eager). + * @param {Document} document The document object. + * @param {Object} config The experimentation configuration. + * @returns {Promise} A promise that resolves when the experimentation module is loaded. + */ +export async function runExperimentation(document, config) { + if (!isExperimentationEnabled()) { + window.addEventListener('message', async (event) => { + if (event.data?.type === 'hlx:experimentation-get-config') { + event.source.postMessage({ + type: 'hlx:experimentation-config', + config: { experiments: [], audiences: [], campaigns: [] }, + source: 'no-experiments' + }, '*'); } + }); + return null; + } + + try { + const { loadEager } = await import( + '../plugins/experimentation/src/index.js' + ); + return loadEager(document, config); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to load experimentation module (eager):', error); + return null; + } +} + +/** + * Loads the experimentation module (lazy). + * @param {Document} document The document object. + * @param {Object} config The experimentation configuration. + * @returns {Promise} A promise that resolves when the experimentation module is loaded. + */ +export async function showExperimentationRail(document, config) { + if (!isExperimentationEnabled()) { + return null; + } + + try { + const { loadLazy } = await import( + '../plugins/experimentation/src/index.js' + ); + await loadLazy(document, config); + + const loadSidekickHandler = () => import('../tools/sidekick/aem-experimentation.js'); + + if (document.querySelector('helix-sidekick, aem-sidekick')) { + await loadSidekickHandler(); + } else { + await new Promise((resolve) => { + document.addEventListener( + 'sidekick-ready', + () => { + loadSidekickHandler().then(resolve); + }, + { once: true }, + ); + }); } - ``` - This is mostly used for the authoring overlay, and as such isn't essential to the page rendering, so having it at the end of the lazy phase is good enough. -### On top of the plugin system + return true; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to load experimentation module (lazy):', error); + return null; + } +} +``` -The easiest way to add the plugin is if your project is set up with the plugin system extension in the boilerplate. -You'll know you have it if either `window.aem.plugins` or `window.hlx.plugins` is defined on your page. +### Step 2: Update `scripts/scripts.js` -If you don't have it, you can follow the proposal in https://github.com/adobe/aem-lib/pull/23 and https://github.com/adobe/aem-boilerplate/pull/275 and apply the changes to your `aem.js`/`lib-franklin.js` and `scripts.js`. +Add the following import and configuration at the top of your `scripts/scripts.js`: -Once you have confirmed this, you'll need to edit your `scripts.js` in your AEM project and add the following at the start of the file: ```js +import { + runExperimentation, + showExperimentationRail, +} from './experiment-loader.js'; + const experimentationConfig = { - prodHost: 'www.my-site.com', + prodHost: 'www.mysite.com', // add your prodHost here, otherwise we will show mock data audiences: { mobile: () => window.innerWidth < 600, desktop: () => window.innerWidth >= 600, // define your custom audiences here as needed - } + }, }; +``` + +Then, add the following line early in your `loadEager()` function: -window.aem.plugins.add('experimentation', { // use window.hlx instead of your project has this - condition: () => - // page level metadata - document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"]') - // decorated section metadata - || document.querySelector('.section[class*=experiment],.section[class*=audience],.section[class*=campaign]') - // undecorated section metadata - || [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i)), - options: experimentationConfig, - url: '/plugins/experimentation/src/index.js', -}); +```js +async function loadEager(doc) { + // ... existing code ... + await runExperimentation(doc, experimentationConfig); + // ... rest of your code ... +} ``` +Finally, add the following line at the end of your `loadLazy()` function: + +```js +async function loadLazy(doc) { + // ... existing code ... + await showExperimentationRail(doc, experimentationConfig); +} +``` + +### Configuration for Existing Xwalk Projects + +If you're adding experimentation rail UI to an existing project that already has the experimentation engine: + +1. **Update the engine with UI support** by running: + ```sh + git subtree pull --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2-ui + ``` + +2. **Verify the communication layer** is set up in `plugins/experimentation/src/index.js`. The `loadEager` function should include the `setupCommunicationLayer` call: + +```js +export async function loadEager(document, options = {}) { + const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; + setDebugMode(window.location, pluginOptions); + + const ns = window.aem || window.hlx || {}; + ns.audiences = await serveAudience(document, pluginOptions); + ns.experiments = await runExperiment(document, pluginOptions); + ns.campaigns = await runCampaign(document, pluginOptions); + + // Backward compatibility + ns.experiment = ns.experiments.find((e) => e.type === 'page'); + ns.audience = ns.audiences.find((e) => e.type === 'page'); + ns.campaign = ns.campaigns.find((e) => e.type === 'page'); + + if (isDebugEnabled) { + setupCommunicationLayer(pluginOptions); + } +} + +// Support new Rail UI communication +function setupCommunicationLayer(options) { + window.addEventListener('message', async (event) => { + if (event.data?.type === 'hlx:experimentation-get-config') { + try { + const safeClone = JSON.parse(JSON.stringify(window.hlx)); + + if (options.prodHost) { + safeClone.prodHost = options.prodHost; + } + + event.source.postMessage( + { + type: 'hlx:experimentation-config', + config: safeClone, + source: 'index-js', + }, + '*', + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error sending hlx config:', e); + } + } + }); +} +``` + +3. **Follow Steps 1 and 2 above** to create the `experiment-loader.js` file and update your `scripts.js`. + ### Increasing sampling rate for low traffic pages When running experiments during short periods (i.e. a few days or 2 weeks) or on low-traffic pages (<100K page views a month), it is unlikely that you'll reach statistical significance on your tests with the default RUM sampling. For those use cases, we recommend adjusting the sampling rate for the pages in question to 1 out of 10 instead of the default 1 out of 100 visits. @@ -137,11 +260,11 @@ If this is not present, please apply the following changes to the file: https:// ### Custom options -There are various aspects of the plugin that you can configure via options you are passing to the 2 main methods above (`runEager`/`runLazy`). +There are various aspects of the plugin that you can configure via the `experimentationConfig` object. You have already seen the `audiences` option in the examples above, but here is the full list we support: ```js -runEager.call(document, { +const experimentationConfig = { // Lets you configure the prod environment. // (prod environments do not get the pill overlay) prodHost: 'www.my-website.com', @@ -176,7 +299,7 @@ runEager.call(document, { buildBlock(el); decorateBlock(el); } -}); +}; ``` For detailed implementation instructions on the different features, please read the dedicated pages we have on those topics: From acc7312cfbd4aa8476d64f60c63c7e57e4925c7a Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Tue, 2 Dec 2025 16:08:46 -0800 Subject: [PATCH 11/17] Update consent management to only store explicit consent - Store consent in localStorage only when explicitly granted - Remove consent from storage when denied or revoked - Add debug logging for consent grant and revocation --- src/index.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index eb8b35e..795e80f 100644 --- a/src/index.js +++ b/src/index.js @@ -103,11 +103,17 @@ function getConsentFromStorage() { /** * Writes the consent status to localStorage. + * Only stores consent when explicitly given (true). + * Removes the key when consent is denied or revoked (false). * @param {Boolean} consented Whether the user has consented */ function setConsentInStorage(consented) { try { - localStorage.setItem(CONSENT_STORAGE_KEY, consented ? 'true' : 'false'); + if (consented) { + localStorage.setItem(CONSENT_STORAGE_KEY, 'true'); + } else { + localStorage.removeItem(CONSENT_STORAGE_KEY); + } } catch (error) { debug('Failed to save consent to localStorage:', error); } @@ -123,11 +129,18 @@ export function hasExperimentationConsent() { /** * Sets the user consent status for experimentation. + * - If consent is given (true): stores the decision in localStorage + * - If consent is denied or revoked (false): removes any stored consent * @param {Boolean} consented Whether the user has consented to experimentation */ export function updateExperimentationConsent(consented) { - setConsentInStorage(consented); - debug('Experimentation consent updated:', consented); + if (consented) { + setConsentInStorage(true); + debug('Experimentation consent granted and stored'); + } else { + setConsentInStorage(false); + debug('Experimentation consent denied or revoked - storage cleared'); + } } /** From 0172048918a6d30d289d4f1f9eba984d580ae182 Mon Sep 17 00:00:00 2001 From: Xinyi Feng <105081458+FentPams@users.noreply.github.com> Date: Thu, 4 Dec 2025 14:26:46 -0800 Subject: [PATCH 12/17] Update README.md Co-authored-by: Julien Ramboz --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 04ad5fe..c918358 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ import { // Check if user has consented to experimentation const hasConsent = hasExperimentationConsent(); -// Update consent status (call this from your consent management platform) +// Integrate this with your consent management platform events to track the user's choice updateExperimentationConsent(true); // or false to revoke consent ``` From 4d1335fa650c4ee412f1956a896e2e6890341fb8 Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Thu, 4 Dec 2025 14:57:26 -0800 Subject: [PATCH 13/17] address comments --- README.md | 71 ++++++++++++++++++++++++------------ documentation/experiments.md | 28 +++++++------- src/index.js | 6 +-- 3 files changed, 64 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index c918358..52dc573 100644 --- a/README.md +++ b/README.md @@ -209,15 +209,15 @@ The plugin provides consent management APIs for privacy compliance. Experiments ```javascript import { - hasExperimentationConsent, - updateExperimentationConsent + isUserConsentGiven, + updateUserConsent } from './plugins/experimentation/src/index.js'; // Check if user has consented to experimentation -const hasConsent = hasExperimentationConsent(); +const hasConsent = isUserConsentGiven(); // Integrate this with your consent management platform events to track the user's choice -updateExperimentationConsent(true); // or false to revoke consent +updateUserConsent(true); // or false to revoke consent ``` **Requiring consent for an experiment:** @@ -243,25 +243,39 @@ You can integrate consent management in two ways: ```javascript // experiment-loader.js import { - updateExperimentationConsent, - hasExperimentationConsent, + updateUserConsent, + isUserConsentGiven, } from '../plugins/experimentation/src/index.js'; /** * Initialize consent management - * Choose ONE of the setup functions based on your CMP + * Choose ONE of the setup functions based on your CMP (Consent Management Platform) + * + * IMPORTANT: These are example implementations. Please: + * 1. Verify the consent categories match your OneTrust/Cookiebot configuration + * 2. Test thoroughly in your environment + * 3. Consult with your legal/privacy team about consent requirements */ function initConsent() { // OPTION 1: OneTrust function setupOneTrustConsent() { - function handleOneTrustConsent() { - const activeGroups = window.OnetrustActiveGroups || ''; - const hasConsent = activeGroups.includes('C0003') || activeGroups.includes('C0004'); - updateExperimentationConsent(hasConsent); - } + // Step 1: Bridge OneTrust's callback to dispatch a custom event window.OptanonWrapper = function() { - handleOneTrustConsent(); + const activeGroups = window.OnetrustActiveGroups || ''; + const groups = activeGroups.split(',').filter(g => g); + window.dispatchEvent(new CustomEvent('consent.onetrust', { + detail: groups + })); }; + + // Step 2: Listen for the custom event + function consentEventHandler(ev) { + const groups = ev.detail; + const hasConsent = groups.includes('C0003') // Functional Cookies + || groups.includes('C0004'); // Targeting Cookies + updateUserConsent(hasConsent); + } + window.addEventListener('consent.onetrust', consentEventHandler); } // OPTION 2: Cookiebot @@ -269,7 +283,7 @@ function initConsent() { function handleCookiebotConsent() { const preferences = window.Cookiebot?.consent?.preferences || false; const marketing = window.Cookiebot?.consent?.marketing || false; - updateExperimentationConsent(preferences || marketing); + updateUserConsent(preferences || marketing); } window.addEventListener('CookiebotOnConsentReady', handleCookiebotConsent); window.addEventListener('CookiebotOnAccept', handleCookiebotConsent); @@ -278,7 +292,7 @@ function initConsent() { // OPTION 3: Custom Consent Banner function setupCustomConsent() { document.addEventListener('consent-updated', (event) => { - updateExperimentationConsent(event.detail.experimentation); + updateUserConsent(event.detail.experimentation); }); } @@ -299,7 +313,7 @@ export async function runExperimentation(document, config) { } // Export consent functions for use elsewhere if needed -export { updateExperimentationConsent, hasExperimentationConsent }; +export { updateUserConsent, isUserConsentGiven }; ``` Your `scripts.js` stays clean - no consent code needed there! @@ -312,22 +326,31 @@ Your `scripts.js` stays clean - no consent code needed there! ```javascript // scripts.js import { - updateExperimentationConsent, - hasExperimentationConsent, + updateUserConsent, + isUserConsentGiven, } from '../plugins/experimentation/src/index.js'; import { runExperimentation } from './experiment-loader.js'; // Setup consent (choose ONE based on your CMP) function setupOneTrustConsent() { - function handleOneTrustConsent() { - const activeGroups = window.OnetrustActiveGroups || ''; - const hasConsent = activeGroups.includes('C0003') || activeGroups.includes('C0004'); - updateExperimentationConsent(hasConsent); - } + // Step 1: Bridge OneTrust's callback to dispatch a custom event window.OptanonWrapper = function() { - handleOneTrustConsent(); + const activeGroups = window.OnetrustActiveGroups || ''; + const groups = activeGroups.split(',').filter(g => g); + window.dispatchEvent(new CustomEvent('consent.onetrust', { + detail: groups + })); }; + + // Step 2: Listen for the custom event + function consentEventHandler(ev) { + const groups = ev.detail; + const hasConsent = groups.includes('C0003') // Functional Cookies + || groups.includes('C0004'); // Targeting Cookies + updateUserConsent(hasConsent); + } + window.addEventListener('consent.onetrust', consentEventHandler); } async function loadEager(doc) { diff --git a/documentation/experiments.md b/documentation/experiments.md index 5325952..2e43f3e 100644 --- a/documentation/experiments.md +++ b/documentation/experiments.md @@ -130,7 +130,7 @@ So you can both use generic dates, like `2024-01-31` or `2024/01/31`, and time-s #### Consent-based experiments -For compliance with privacy regulations like GDPR, CCPA, and others, experiments can be configured to require user consent before running. This ensures that personalization and experimentation only occurs when users have explicitly agreed to it. +For compliance with privacy regulations like GDPR, CCPA, and others, experiments can be configured to require user consent before running. This ensures that personalization and experimentation only occur when users have explicitly agreed to it. ##### Enabling consent requirement @@ -150,35 +150,35 @@ The experimentation runtime provides JavaScript APIs to manage user consent: **Check current consent status:** ```javascript -import { hasExperimentationConsent } from './path/to/experimentation/src/index.js'; +import { isUserConsentGiven } from './path/to/experimentation/src/index.js'; -const isConsented = hasExperimentationConsent(); +const isConsented = isUserConsentGiven(); console.log('User has consented to experimentation:', isConsented); ``` **Update consent status:** ```javascript -import { updateExperimentationConsent } from './path/to/experimentation/src/index.js'; +import { updateUserConsent } from './path/to/experimentation/src/index.js'; // Update consent (call this when your CMP sends a consent event) -updateExperimentationConsent(true); // or false to revoke consent +updateUserConsent(true); // or false to revoke consent ``` ##### Integrating with consent management platforms -Connect your consent management system (CMS) to track user consent. Call `updateExperimentationConsent` when your CMS sends a consent event. +Connect your consent management system (CMS) to track user consent. Call `updateUserConsent` when your CMS sends a consent event. > **💡 Tip**: For cleaner code organization, we recommend placing consent integration in your `experiment-loader.js` file (if you have one) rather than in `scripts.js`. This keeps all experimentation-related code together. See the [README](/README.md#consent-management) for complete implementation examples. **Example: OneTrust integration** ```javascript -import { updateExperimentationConsent } from './path/to/experimentation/src/index.js'; +import { updateUserConsent } from './path/to/experimentation/src/index.js'; function handleOneTrustConsent() { const activeGroups = window.OnetrustActiveGroups || ''; - // C0003 = Functional/Performance, C0004 = Targeting - const hasConsent = activeGroups.includes('C0003') || activeGroups.includes('C0004'); - updateExperimentationConsent(hasConsent); + const hasConsent = activeGroups.includes('C0003') // Functional Cookies + || activeGroups.includes('C0004'); // Targeting Cookies + updateUserConsent(hasConsent); } // Hook into OneTrust callback @@ -189,12 +189,12 @@ window.OptanonWrapper = function() { **Example: Cookiebot integration** ```javascript -import { updateExperimentationConsent } from './path/to/experimentation/src/index.js'; +import { updateUserConsent } from './path/to/experimentation/src/index.js'; function handleCookiebotConsent() { const preferences = window.Cookiebot?.consent?.preferences || false; const marketing = window.Cookiebot?.consent?.marketing || false; - updateExperimentationConsent(preferences || marketing); + updateUserConsent(preferences || marketing); } window.addEventListener('CookiebotOnConsentReady', handleCookiebotConsent); @@ -203,11 +203,11 @@ window.addEventListener('CookiebotOnAccept', handleCookiebotConsent); **Example: Custom consent banner** ```javascript -import { updateExperimentationConsent } from './path/to/experimentation/src/index.js'; +import { updateUserConsent } from './path/to/experimentation/src/index.js'; // When user accepts/rejects consent function onConsentChange(accepted) { - updateExperimentationConsent(accepted); + updateUserConsent(accepted); } ``` diff --git a/src/index.js b/src/index.js index 795e80f..d23ee21 100644 --- a/src/index.js +++ b/src/index.js @@ -123,7 +123,7 @@ function setConsentInStorage(consented) { * Checks if user has given consent for experimentation. * @returns {Boolean} true if consent is given, false otherwise */ -export function hasExperimentationConsent() { +export function isUserConsentGiven() { return getConsentFromStorage(); } @@ -133,7 +133,7 @@ export function hasExperimentationConsent() { * - If consent is denied or revoked (false): removes any stored consent * @param {Boolean} consented Whether the user has consented to experimentation */ -export function updateExperimentationConsent(consented) { +export function updateUserConsent(consented) { if (consented) { setConsentInStorage(true); debug('Experimentation consent granted and stored'); @@ -766,7 +766,7 @@ async function getExperimentConfig(pluginOptions, metadata, overrides) { && (!startDate || startDate <= Date.now()) && (!endDate || endDate > Date.now()) // experiment has consent if required - && (!requiresConsent || hasExperimentationConsent()) + && (!requiresConsent || isUserConsentGiven()) ); if (!config.run) { From c688a15ee6c51560426568af0e7875858c7f63b1 Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Fri, 5 Dec 2025 13:12:28 -0800 Subject: [PATCH 14/17] update readme --- README.md | 66 +++---------------------------------------------------- 1 file changed, 3 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index e953656..635559b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The AEM Experimentation plugin helps you quickly set up experimentation and segm It is currently available to customers in collaboration with AEM Engineering via co-innovation VIP Projects. To implement experimentation or personalization use-cases, please reach out to the AEM Engineering team in the Slack channel dedicated to your project. -> **Note:** We are adding new support for the contextual experimentation rail UI. This is still under development. The instrumentation flow will be simplified once finalized. Feel free to reach out if you have any questions about experimentation or the contextual experimentation rail in the Slack channel **#contextual-exp-team**. +> **Note:** We are adding new support for the contextual experimentation rail UI. This is still under development. Feel free to reach out if you have any questions via email: aem-contextual-experimentation@adobe.com. ## Features @@ -20,13 +20,13 @@ The AEM Experimentation plugin supports: Add the plugin to your AEM project by running: ```sh -git subtree add --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2-ui +git subtree add --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2 ``` If you later want to pull the latest changes and update your local copy of the plugin: ```sh -git subtree pull --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2-ui +git subtree pull --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2 ``` If you prefer using `https` links you'd replace `git@github.com:adobe/aem-experimentation.git` in the above commands by `https://github.com/adobe/aem-experimentation.git`. @@ -122,66 +122,6 @@ async function loadEager(doc) { } ``` - -If you're adding experimentation rail UI to an existing project that already has the experimentation engine: - -1. **Update the engine with UI support** by running: - ```sh - git subtree pull --squash --prefix plugins/experimentation git@github.com:adobe/aem-experimentation.git v2-ui - ``` - -2. **Verify the communication layer** is set up in `plugins/experimentation/src/index.js`. The `loadEager` function should include the `setupCommunicationLayer` call: - -```js -export async function loadEager(document, options = {}) { - const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; - setDebugMode(window.location, pluginOptions); - - const ns = window.aem || window.hlx || {}; - ns.audiences = await serveAudience(document, pluginOptions); - ns.experiments = await runExperiment(document, pluginOptions); - ns.campaigns = await runCampaign(document, pluginOptions); - - // Backward compatibility - ns.experiment = ns.experiments.find((e) => e.type === 'page'); - ns.audience = ns.audiences.find((e) => e.type === 'page'); - ns.campaign = ns.campaigns.find((e) => e.type === 'page'); - - if (isDebugEnabled) { - setupCommunicationLayer(pluginOptions); - } -} - -// Support new Rail UI communication -function setupCommunicationLayer(options) { - window.addEventListener('message', async (event) => { - if (event.data?.type === 'hlx:experimentation-get-config') { - try { - const safeClone = JSON.parse(JSON.stringify(window.hlx)); - - if (options.prodHost) { - safeClone.prodHost = options.prodHost; - } - - event.source.postMessage( - { - type: 'hlx:experimentation-config', - config: safeClone, - source: 'index-js', - }, - '*', - ); - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error sending hlx config:', e); - } - } - }); -} -``` - -3. **Follow Steps 1 and 2 above** to create the `experiment-loader.js` file and update your `scripts.js`. - ### Increasing sampling rate for low traffic pages When running experiments during short periods (i.e. a few days or 2 weeks) or on low-traffic pages (<100K page views a month), it is unlikely that you'll reach statistical significance on your tests with the default RUM sampling. For those use cases, we recommend adjusting the sampling rate for the pages in question to 1 out of 10 instead of the default 1 out of 100 visits. From 0475de6334d1d718b44e359dc0e9634fa2dfea56 Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Fri, 5 Dec 2025 13:22:19 -0800 Subject: [PATCH 15/17] update readme --- README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/README.md b/README.md index 635559b..0996503 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,6 @@ If you prefer using `https` links you'd replace `git@github.com:adobe/aem-experi ## Project instrumentation -### Starting from Boilerplate for Xwalk - -If you are starting from scratch, use the following template repository: -https://github.com/adobe-rnd/aem-boilerplate-xwalk - -For reference, check this example project: -https://github.com/sudo-buddy/ue-experimentation - ### Key Files to Add or Modify 1. **plugins/experimentation** - Add this folder containing the experimentation engine plugins (see Installation section above) From 127e27094d6be7b3874669ef20dffa151b2fad71 Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Fri, 5 Dec 2025 13:25:12 -0800 Subject: [PATCH 16/17] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0996503..5eac44f 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ You have already seen the `audiences` option in the examples above, but here is const experimentationConfig = { // Lets you configure the prod environment. // (prod environments do not get the pill overlay) - prodHost: 'www.my-website.com', + prodHost: 'www.mysite.com', // if you have several, or need more complex logic to toggle pill overlay, you can use isProd: () => !window.location.hostname.endsWith('hlx.page') && window.location.hostname !== ('localhost'), From 89b43f573499b73c08862fa958e69c2b879ea09e Mon Sep 17 00:00:00 2001 From: Xinyi Feng Date: Fri, 5 Dec 2025 14:19:04 -0800 Subject: [PATCH 17/17] address lint error and update test about consent management --- src/index.js | 43 ++++----- tests/experiments.test.js | 96 +++++++++++++++++++ .../page-level--requires-consent.html | 13 +++ 3 files changed, 129 insertions(+), 23 deletions(-) create mode 100644 tests/fixtures/experiments/page-level--requires-consent.html diff --git a/src/index.js b/src/index.js index 9c572d8..aeb2a2b 100644 --- a/src/index.js +++ b/src/index.js @@ -1038,25 +1038,6 @@ async function serveAudience(document, pluginOptions) { ); } -export async function loadEager(document, options = {}) { - const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; - setDebugMode(window.location, pluginOptions); - - const ns = window.aem || window.hlx || {}; - ns.audiences = await serveAudience(document, pluginOptions); - ns.experiments = await runExperiment(document, pluginOptions); - ns.campaigns = await runCampaign(document, pluginOptions); - - // Backward compatibility - ns.experiment = ns.experiments.find((e) => e.type === 'page'); - ns.audience = ns.audiences.find((e) => e.type === 'page'); - ns.campaign = ns.campaigns.find((e) => e.type === 'page'); - - if (isDebugEnabled) { - setupCommunicationLayer(pluginOptions); - } -} - // Support new Rail UI communication function setupCommunicationLayer(options) { window.addEventListener('message', async (event) => { @@ -1114,9 +1095,25 @@ function setupCommunicationLayer(options) { }); } -export async function loadLazy(document, options = {}) { - // do not show the experimentation pill on prod domains - if (!isDebugEnabled) { - return; +export async function loadEager(document, options = {}) { + const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; + setDebugMode(window.location, pluginOptions); + + const ns = window.aem || window.hlx || {}; + ns.audiences = await serveAudience(document, pluginOptions); + ns.experiments = await runExperiment(document, pluginOptions); + ns.campaigns = await runCampaign(document, pluginOptions); + + // Backward compatibility + ns.experiment = ns.experiments.find((e) => e.type === 'page'); + ns.audience = ns.audiences.find((e) => e.type === 'page'); + ns.campaign = ns.campaigns.find((e) => e.type === 'page'); + + if (isDebugEnabled) { + setupCommunicationLayer(pluginOptions); } } + +export async function loadLazy() { + // Placeholder for lazy loading functionality +} diff --git a/tests/experiments.test.js b/tests/experiments.test.js index e6380ad..6d5e7ee 100644 --- a/tests/experiments.test.js +++ b/tests/experiments.test.js @@ -354,3 +354,99 @@ test.describe('Backward Compatibility with v1', () => { expect(await page.locator('main').textContent()).toEqual('Hello v1!'); }); }); + +test.describe('Consent Management', () => { + test('isUserConsentGiven returns false by default', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + const hasConsent = await page.evaluate(() => localStorage.getItem('experimentation-consented') === 'true'); + expect(hasConsent).toBe(false); + }); + + test('updateUserConsent stores consent in localStorage', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + await page.evaluate(() => localStorage.setItem('experimentation-consented', 'true')); + const stored = await page.evaluate(() => localStorage.getItem('experimentation-consented')); + expect(stored).toBe('true'); + }); + + test('updateUserConsent removes consent from localStorage when false', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + await page.evaluate(() => { + localStorage.setItem('experimentation-consented', 'true'); + localStorage.removeItem('experimentation-consented'); + }); + const stored = await page.evaluate(() => localStorage.getItem('experimentation-consented')); + expect(stored).toBeNull(); + }); + + test('isUserConsentGiven returns true after consent is granted', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + const hasConsent = await page.evaluate(() => { + localStorage.setItem('experimentation-consented', 'true'); + return localStorage.getItem('experimentation-consented') === 'true'; + }); + expect(hasConsent).toBe(true); + }); + + test('consent persists across page reloads', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + await page.evaluate(() => localStorage.setItem('experimentation-consented', 'true')); + await page.reload(); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + const hasConsent = await page.evaluate(() => localStorage.getItem('experimentation-consented') === 'true'); + expect(hasConsent).toBe(true); + }); + + test('experiment does not run when consent is required but not given', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--requires-consent'); + expect(await page.locator('main').textContent()).toEqual('Hello World!'); + }); + + test('experiment runs when consent is required and given', async ({ page }) => { + await page.addInitScript(() => { + localStorage.setItem('experimentation-consented', 'true'); + }); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--requires-consent?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello v1!'); + }); + + test('experiment config includes requiresConsent flag', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--requires-consent'); + const config = await page.evaluate(() => window.hlx.experiments?.[0]?.config); + expect(config?.requiresConsent).toBe(true); + }); + + test('experiments without requiresConsent metadata run normally', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello v1!'); + const config = await page.evaluate(() => window.hlx.experiments?.[0]?.config); + expect(config?.requiresConsent).toBe(false); + }); +}); + +test.describe('Experiment Configuration', () => { + test('experiment config includes thumbnail from og:image', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + const config = await page.evaluate(() => window.hlx.experiments?.[0]?.config); + expect(config).toHaveProperty('thumbnail'); + }); + + test('experiment config includes optimizingTarget', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + const config = await page.evaluate(() => window.hlx.experiments?.[0]?.config); + expect(config.optimizingTarget).toBe('conversion'); + }); + + test('experiment config includes label', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + const config = await page.evaluate(() => window.hlx.experiments?.[0]?.config); + expect(config.label).toMatch(/Experiment foo/); + }); + + test('variant labels use custom names when provided', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + const config = await page.evaluate(() => window.hlx.experiments?.[0]?.config); + expect(config.variants['challenger-1'].label).toBe('V1'); + expect(config.variants['challenger-2'].label).toBe('V2'); + }); +}); diff --git a/tests/fixtures/experiments/page-level--requires-consent.html b/tests/fixtures/experiments/page-level--requires-consent.html new file mode 100644 index 0000000..2794091 --- /dev/null +++ b/tests/fixtures/experiments/page-level--requires-consent.html @@ -0,0 +1,13 @@ + + + + + + + + + +
Hello World!
+ + +