From 97b7862928d5b8e797e6dfa8676757dea527dc39 Mon Sep 17 00:00:00 2001 From: Julien Ramboz Date: Wed, 22 Jul 2026 09:16:45 -0700 Subject: [PATCH 1/4] feat: load simulation panel UI in loadLazy for AEM Sidekick v2 moved the experiment simulation UI to a hosted MFE opened by the AEM Sidekick extension, but nothing actually loaded it: loadLazy() was an empty placeholder, so clicking the Sidekick button was a silent no-op. loadLazy() now wires the Sidekick toolbar button to the panel, lazy-loads the MFE on first use, honors the ?experiment=/ deep-link, and restores the panel after a variant-switch reload. It is gated to preview/dev only and never runs in production. setupCommunicationLayer is now idempotent so eager and lazy phases can both ensure the postMessage handshake is available. A new `simulationUI` option ('auto' | 'sidekick' | 'universal-editor' | false) distinguishes the EDS + Sidekick surface from the Universal Editor, where the panel is delivered as a UE extension instead. This supersedes the manual copy-paste listener script approach; docs updated to just register the Sidekick plugin and wire runExperimentationLazy() into the project's loadLazy(). Refs SITES-48456 Co-Authored-By: Claude Opus 4.8 --- README.md | 105 ++++++++++++++++- documentation/experiments.md | 10 +- documentation/sidekick/config.json | 17 +++ src/index.js | 179 ++++++++++++++++++++++++++++- 4 files changed, 306 insertions(+), 5 deletions(-) create mode 100644 documentation/sidekick/config.json diff --git a/README.md b/README.md index 48767bdb..0a8e4526 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,37 @@ export async function runExperimentation(document, config) { } } +/** + * Loads the experimentation simulation UI (lazy). + * The simulation panel is an authoring aid, so this is a no-op in production — + * the coarse check below avoids even fetching the plugin there, and the plugin + * re-checks authoritatively before showing anything. + * @param {Document} document The document object. + * @param {Object} config The experimentation configuration. + * @returns {Promise} A promise that resolves when the simulation UI is loaded. + */ +export async function runExperimentationLazy(document, config) { + const { host, hostname, origin } = window.location; + const isPreview = hostname === 'localhost' + || hostname.endsWith('.page') + || (typeof config.isProd === 'function' && !config.isProd()) + || (config.prodHost && ![host, hostname, origin].includes(config.prodHost)); + if (!isPreview) { + return null; + } + + try { + const { loadLazy } = await import( + '../plugins/experimentation/src/index.js' + ); + return loadLazy(document, config); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to load experimentation module (lazy):', error); + return null; + } +} + ``` > **Note:** Add the following line to your `head.html` to preload the experiment loader script: @@ -97,6 +128,7 @@ Add the following import and configuration at the top of your `scripts/scripts.j ```js import { runExperimentation, + runExperimentationLazy, } from './experiment-loader.js'; const experimentationConfig = { @@ -119,6 +151,17 @@ async function loadEager(doc) { } ``` +And add the following line to your `loadLazy()` function to enable the simulation +panel (see [Enabling the simulation panel](#enabling-the-simulation-panel-aem-sidekick) below): + +```js +async function loadLazy(doc) { + // ... existing code ... + await runExperimentationLazy(doc, experimentationConfig); + // ... rest of your code ... +} +``` + ### 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. @@ -185,7 +228,14 @@ const experimentationConfig = { /* handle custom decoration here, for example: */ buildBlock(el); decorateBlock(el); - } + }, + + /* Which simulation UI to wire up in preview/dev (see the section below) */ + // - 'auto' (default): wire up the AEM Sidekick panel if/when the Sidekick is present + // - 'sidekick': same as 'auto', but explicit + // - 'universal-editor': the panel is delivered as a UE extension, so stay out of the way + // - false: do not load any simulation UI + simulationUI: 'auto', }; ``` @@ -201,6 +251,59 @@ Fragment replacement is handled by async observer, which may execute before or a 3. Have a `.section` selector and need to redecorate => call `decorateBlocks(el)` 4. Have a `main` selector and need to redecorate => call `decorateMain(el)` +### Enabling the simulation panel (AEM Sidekick) + +Starting with v2, the plugin no longer injects its own in-page overlay to simulate and switch +between experiment variants. That panel now ships as a micro-frontend (MFE) that Adobe's +[AEM Sidekick browser extension](https://chromewebstore.google.com/detail/aem-sidekick/igkmdomcgoebiipaifhmpfjhbjccggml) +opens on demand. + +The plugin loads and toggles that panel for you from `loadLazy()` — you do **not** need to copy any +listener script into your project or add anything to `head.html`. Make sure you've wired +`runExperimentationLazy` into your `scripts.js` `loadLazy()` as shown in +[Step 2](#step-2-update-scriptsscriptsjs) above. The panel only loads in preview/development +environments, never in production, and only when the Sidekick is present (see the `simulationUI` +option to change this — for example in the Universal Editor, where the panel is delivered as a UE +extension instead). + +The one thing the plugin can't do for you is register the Sidekick toolbar button, since that lives +in your project's Sidekick config, not in page code. Add the following plugin entry to +`tools/sidekick/config.json` in your project (a copy also lives at +[`documentation/sidekick/config.json`](./documentation/sidekick/config.json)): + +```json +{ + "plugins": [ + { + "id": "aem-experimentation", + "titleI18n": { + "en": "Experimentation" + }, + "environments": [ + "preview" + ], + "includePaths": [ + "**.docx**" + ], + "event": "aem-experimentation-sidekick" + } + ] +} +``` + +> **Note:** if your project has been migrated to AEM's unified ("helix5") config format — for +> instance via [Experience Workspace](https://docs.da.live/about/early-access/experience-workspace) — +> committing this file alone is not enough. Sidekick reads its config from your persisted site config +> (`https://admin.hlx.page/config/{org}/sites/{site}.json`), not a live read of +> `tools/sidekick/config.json` off the code bus, so you need to merge a matching `sidekick.plugins` +> array into that persisted config directly. Note also that this admin API's verbs are inverted from +> typical REST conventions: **`PUT`** only *creates* a config that doesn't exist yet (and fails with +> `config already exists` if one is already there) — **`POST`** is what updates an existing config. + +Once the button is registered and `runExperimentationLazy` is wired up, clicking the +"Experimentation" button in Sidekick opens the panel. A shared simulation link +(`?experiment=/`) also opens the panel automatically on page load. + ## Extensibility & integrations The experimentation plugin exposes APIs that allow you to integrate with analytics platforms and other 3rd-party libraries. diff --git a/documentation/experiments.md b/documentation/experiments.md index 2e43f3e8..055862b4 100644 --- a/documentation/experiments.md +++ b/documentation/experiments.md @@ -282,11 +282,17 @@ The same spreadsheet can also contain the configuration for several pages at onc ### Simulation -Once all of this is set up, authors will have access to an overlay on `localhost` and on the stage environments (i.e. `*.hlx.stage`) that lets them see what experiment and variants have been configured for the page and switch between each to visualize the content variations accordingly. +> **Note:** the screenshot below and the "overlay pill" reflect the v1 plugin. In v2 that overlay is +> no longer injected by the plugin — the simulation panel is now delivered by the +> [AEM Sidekick extension](https://chromewebstore.google.com/detail/aem-sidekick/igkmdomcgoebiipaifhmpfjhbjccggml) +> and loaded by the plugin's `loadLazy()`. See +> [Enabling the simulation panel](../README.md#enabling-the-simulation-panel-aem-sidekick) in the main README. + +Once all of this is set up, authors will have access, in preview/development environments, to a panel that lets them see what experiment and variants have been configured for the page and switch between each to visualize the content variations accordingly. ![audience overlay](./images/experiments-overlay.png) -The simulation capabilities leverage the `audience` query parameter that is appended to the URL and forcibly let you see the specific content variant. +The simulation capabilities leverage the `experiment` query parameter (`?experiment=/`, e.g. `?experiment=hero-test/challenger-1`) that is appended to the URL and forcibly let you see the specific content variant. ### Inline Reporting diff --git a/documentation/sidekick/config.json b/documentation/sidekick/config.json new file mode 100644 index 00000000..86062232 --- /dev/null +++ b/documentation/sidekick/config.json @@ -0,0 +1,17 @@ +{ + "plugins": [ + { + "id": "aem-experimentation", + "titleI18n": { + "en": "Experimentation" + }, + "environments": [ + "preview" + ], + "includePaths": [ + "**.docx**" + ], + "event": "aem-experimentation-sidekick" + } + ] +} diff --git a/src/index.js b/src/index.js index eb528c0b..71c7959f 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ */ let isDebugEnabled; +let isCommunicationLayerInitialized = false; export function setDebugMode(url, pluginOptions) { const { host, hostname, origin } = url; const { isProd, prodHost } = pluginOptions; @@ -48,10 +49,26 @@ export const DEFAULT_OPTIONS = { // Redecoration function for fragments decorateFunction: () => {}, + + // Which simulation UI to wire up in preview/dev environments: + // - 'auto' (default): wire up the AEM Sidekick panel if/when the Sidekick is present + // - 'sidekick': same as 'auto', but explicit + // - 'universal-editor': the panel is delivered as a UE extension, so stay out of the way + // - false: do not load any simulation UI + simulationUI: 'auto', }; const CONSENT_STORAGE_KEY = 'experimentation-consented'; +// Simulation UI (AEM Sidekick) integration. +// The panel itself ships as a hosted micro-frontend that the Sidekick extension +// loads on demand; the plugin only wires the toolbar button to it. +const SIMULATION_MFE_URL = 'https://experience.adobe.com/solutions/ExpSuccess-aem-experimentation-mfe/static-assets/resources/sidekick/client.js?source=plugin'; +const SIMULATION_SIDEKICK_EVENT = 'custom:aem-experimentation-sidekick'; +const SIMULATION_PANEL_ID = 'aemExperimentation'; +const SIMULATION_PANEL_HIDDEN_CLASS = 'aemExperimentationHidden'; +const SIMULATION_PANEL_REOPEN_KEY = 'aem-experimentation-simulation-open'; + /** * Converts a given comma-seperate string to an array. * @param {String|String[]} str The string to convert @@ -1042,6 +1059,11 @@ async function serveAudience(document, pluginOptions) { // Support new Rail UI communication function setupCommunicationLayer(options) { + // Both loadEager and loadLazy may call this; only ever register once. + if (isCommunicationLayerInitialized) { + return; + } + isCommunicationLayerInitialized = true; window.addEventListener('message', async (event) => { if (event.data && event.data.type === 'hlx:last-modified-request') { const { url } = event.data; @@ -1092,11 +1114,129 @@ function setupCommunicationLayer(options) { event.data?.type === 'hlx:experimentation-window-reload' && event.data?.action === 'reload' ) { + // Preserve the panel's open state across the reload so it re-opens once + // the page comes back (see setupSimulationUI). + try { + window.sessionStorage.setItem(SIMULATION_PANEL_REOPEN_KEY, 'true'); + } catch (e) { + debug('Failed to persist simulation panel state:', e); + } window.location.reload(); } }); } +/** + * Creates a controller for the hosted simulation panel that loads the MFE once + * (on first use) and then just toggles its visibility. + * @param {Document} doc The document object + * @returns {{ open: Function, toggle: Function }} the panel controller + */ +function createSimulationPanelController(doc) { + let loadPromise = null; + + const togglePanel = (forceShow = false) => { + const container = doc.getElementById(SIMULATION_PANEL_ID); + if (!container) { + return; + } + if (forceShow) { + container.classList.remove(SIMULATION_PANEL_HIDDEN_CLASS); + } else { + container.classList.toggle(SIMULATION_PANEL_HIDDEN_CLASS); + } + }; + + const load = () => { + if (loadPromise) { + return loadPromise; + } + loadPromise = new Promise((resolve, reject) => { + const script = doc.createElement('script'); + script.src = SIMULATION_MFE_URL; + script.onload = () => { + // The MFE injects its own container asynchronously, so wait for it to + // appear (bounded) before resolving. + let tries = 0; + const waitForContainer = () => { + if (doc.getElementById(SIMULATION_PANEL_ID) || tries >= 20) { + resolve(); + } else { + tries += 1; + setTimeout(waitForContainer, 200); + } + }; + waitForContainer(); + }; + script.onerror = reject; + doc.head.appendChild(script); + }); + return loadPromise; + }; + + const open = () => load() + .then(() => togglePanel(true)) + .catch((e) => debug('Failed to open simulation panel:', e)); + + const toggle = () => { + // The first interaction loads the MFE and reveals the panel; afterwards we + // just show/hide the already-loaded panel. + if (!loadPromise) { + return open(); + } + togglePanel(false); + return loadPromise; + }; + + return { open, toggle }; +} + +/** + * Wires up the AEM Sidekick simulation panel: binds the toolbar button to the + * panel, opens it on a simulation deep-link, and restores it after a reload. + * @param {Object} pluginOptions the plugin options + * @param {Document} doc The document object + */ +function setupSimulationUI(pluginOptions, doc) { + const panel = createSimulationPanelController(doc); + + const attachToSidekick = (sk) => { + sk.addEventListener(SIMULATION_SIDEKICK_EVENT, () => panel.toggle()); + }; + + // The Sidekick custom element may be injected by the extension after this + // runs, so fall back to its ready event. + const sidekick = doc.querySelector('aem-sidekick, helix-sidekick'); + if (sidekick) { + attachToSidekick(sidekick); + } else { + doc.addEventListener('sidekick-ready', () => { + const sk = doc.querySelector('aem-sidekick, helix-sidekick'); + if (sk) { + attachToSidekick(sk); + } + }, { once: true }); + } + + // Open the panel straight away when the page is loaded with a simulation + // deep-link (e.g. ?experiment=/ shared from the panel). + const usp = new URLSearchParams(window.location.search); + const [experimentId, variantId] = (usp.get(pluginOptions.experimentsQueryParameter) || '').split('/'); + if (experimentId && variantId) { + panel.open(); + } + + // Re-open the panel after a variant switch forced a full-page reload. + try { + if (window.sessionStorage.getItem(SIMULATION_PANEL_REOPEN_KEY) === 'true') { + window.sessionStorage.removeItem(SIMULATION_PANEL_REOPEN_KEY); + panel.open(); + } + } catch (e) { + debug('Failed to read simulation panel state:', e); + } +} + export async function loadEager(document, options = {}) { const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; setDebugMode(window.location, pluginOptions); @@ -1117,6 +1257,41 @@ export async function loadEager(document, options = {}) { } } -export async function loadLazy() { - // Placeholder for lazy loading functionality +/** + * Loads the simulation UI used to preview and switch experiment variants. + * + * Since v2 the simulation panel is a hosted micro-frontend that the AEM Sidekick + * extension opens on demand. This wires the Sidekick toolbar button to that + * panel and lazy-loads it on first use. It is an authoring aid, so it only runs + * in preview/development environments, never in production. + * + * In the Universal Editor the panel is delivered as a UE extension instead, so + * pass `simulationUI: 'universal-editor'` (or `false`) to keep the plugin out of + * the way there. The default `'auto'` only activates when the Sidekick is present. + * + * Call this from your project's `loadLazy()` in `scripts.js`, alongside the + * `loadEager()` call in `loadEager()`. + * + * @param {Document} document The document object. + * @param {Object} options The experimentation configuration. + */ +export async function loadLazy(document, options = {}) { + const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; + + // Authoring aid only — never surface the simulation UI in production. + if (!setDebugMode(window.location, pluginOptions)) { + return; + } + + // In the Universal Editor a dedicated UE extension owns the panel. + if (pluginOptions.simulationUI === false || pluginOptions.simulationUI === 'universal-editor') { + return; + } + + // Ensure the postMessage handshake the panel talks over is available, even on + // preview pages that have no experiment configured yet (where loadEager's own + // call to it never ran). This is a no-op if already set up. + setupCommunicationLayer(pluginOptions); + + setupSimulationUI(pluginOptions, document); } From a8606dad7e54dbd93e2bd60566eeba1484603aa8 Mon Sep 17 00:00:00 2001 From: Julien Ramboz Date: Wed, 22 Jul 2026 14:05:40 -0700 Subject: [PATCH 2/4] refactor: move simulation UI into a lazy-loaded module Keep the simulation/preview code out of the core engine so it never ships or parses on the eager/critical path in production. The panel controller, Sidekick button wiring, deep-link/reload handling, and the postMessage handshake now live in a dedicated src/simulation.js that loadLazy pulls in via dynamic import() only in preview environments. index.js (the engine) shrinks by ~200 lines and no longer carries any UI code; production pages never fetch simulation.js. The handshake (setupCommunicationLayer) moves from the eager phase to the lazy module alongside the panel it serves. It was already preview-gated, and the panel that uses it is itself lazy/user-triggered, so this is behavior-preserving in practice; the simulation panel continues to require the loadLazy wiring. No public API change: loadLazy, runExperimentationLazy, and the simulationUI option are unchanged. Co-Authored-By: Claude Opus 4.8 --- src/index.js | 211 ++--------------------------------------- src/simulation.js | 233 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 241 insertions(+), 203 deletions(-) create mode 100644 src/simulation.js diff --git a/src/index.js b/src/index.js index 71c7959f..59be1957 100644 --- a/src/index.js +++ b/src/index.js @@ -11,7 +11,6 @@ */ let isDebugEnabled; -let isCommunicationLayerInitialized = false; export function setDebugMode(url, pluginOptions) { const { host, hostname, origin } = url; const { isProd, prodHost } = pluginOptions; @@ -60,15 +59,6 @@ export const DEFAULT_OPTIONS = { const CONSENT_STORAGE_KEY = 'experimentation-consented'; -// Simulation UI (AEM Sidekick) integration. -// The panel itself ships as a hosted micro-frontend that the Sidekick extension -// loads on demand; the plugin only wires the toolbar button to it. -const SIMULATION_MFE_URL = 'https://experience.adobe.com/solutions/ExpSuccess-aem-experimentation-mfe/static-assets/resources/sidekick/client.js?source=plugin'; -const SIMULATION_SIDEKICK_EVENT = 'custom:aem-experimentation-sidekick'; -const SIMULATION_PANEL_ID = 'aemExperimentation'; -const SIMULATION_PANEL_HIDDEN_CLASS = 'aemExperimentationHidden'; -const SIMULATION_PANEL_REOPEN_KEY = 'aem-experimentation-simulation-open'; - /** * Converts a given comma-seperate string to an array. * @param {String|String[]} str The string to convert @@ -1057,186 +1047,6 @@ async function serveAudience(document, pluginOptions) { ); } -// Support new Rail UI communication -function setupCommunicationLayer(options) { - // Both loadEager and loadLazy may call this; only ever register once. - if (isCommunicationLayerInitialized) { - return; - } - isCommunicationLayerInitialized = true; - window.addEventListener('message', async (event) => { - if (event.data && event.data.type === 'hlx:last-modified-request') { - const { url } = event.data; - - try { - const response = await fetch(url, { - method: 'HEAD', - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache', - }, - }); - - const lastModified = response.headers.get('Last-Modified'); - - event.source.postMessage( - { - type: 'hlx:last-modified-response', - url, - lastModified, - status: response.status, - }, - event.origin, - ); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error fetching Last-Modified header:', error); - } - } 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', - config: safeClone, - source: 'index-js', - }, - '*', - ); - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error sending hlx config:', e); - } - } else if ( - event.data?.type === 'hlx:experimentation-window-reload' - && event.data?.action === 'reload' - ) { - // Preserve the panel's open state across the reload so it re-opens once - // the page comes back (see setupSimulationUI). - try { - window.sessionStorage.setItem(SIMULATION_PANEL_REOPEN_KEY, 'true'); - } catch (e) { - debug('Failed to persist simulation panel state:', e); - } - window.location.reload(); - } - }); -} - -/** - * Creates a controller for the hosted simulation panel that loads the MFE once - * (on first use) and then just toggles its visibility. - * @param {Document} doc The document object - * @returns {{ open: Function, toggle: Function }} the panel controller - */ -function createSimulationPanelController(doc) { - let loadPromise = null; - - const togglePanel = (forceShow = false) => { - const container = doc.getElementById(SIMULATION_PANEL_ID); - if (!container) { - return; - } - if (forceShow) { - container.classList.remove(SIMULATION_PANEL_HIDDEN_CLASS); - } else { - container.classList.toggle(SIMULATION_PANEL_HIDDEN_CLASS); - } - }; - - const load = () => { - if (loadPromise) { - return loadPromise; - } - loadPromise = new Promise((resolve, reject) => { - const script = doc.createElement('script'); - script.src = SIMULATION_MFE_URL; - script.onload = () => { - // The MFE injects its own container asynchronously, so wait for it to - // appear (bounded) before resolving. - let tries = 0; - const waitForContainer = () => { - if (doc.getElementById(SIMULATION_PANEL_ID) || tries >= 20) { - resolve(); - } else { - tries += 1; - setTimeout(waitForContainer, 200); - } - }; - waitForContainer(); - }; - script.onerror = reject; - doc.head.appendChild(script); - }); - return loadPromise; - }; - - const open = () => load() - .then(() => togglePanel(true)) - .catch((e) => debug('Failed to open simulation panel:', e)); - - const toggle = () => { - // The first interaction loads the MFE and reveals the panel; afterwards we - // just show/hide the already-loaded panel. - if (!loadPromise) { - return open(); - } - togglePanel(false); - return loadPromise; - }; - - return { open, toggle }; -} - -/** - * Wires up the AEM Sidekick simulation panel: binds the toolbar button to the - * panel, opens it on a simulation deep-link, and restores it after a reload. - * @param {Object} pluginOptions the plugin options - * @param {Document} doc The document object - */ -function setupSimulationUI(pluginOptions, doc) { - const panel = createSimulationPanelController(doc); - - const attachToSidekick = (sk) => { - sk.addEventListener(SIMULATION_SIDEKICK_EVENT, () => panel.toggle()); - }; - - // The Sidekick custom element may be injected by the extension after this - // runs, so fall back to its ready event. - const sidekick = doc.querySelector('aem-sidekick, helix-sidekick'); - if (sidekick) { - attachToSidekick(sidekick); - } else { - doc.addEventListener('sidekick-ready', () => { - const sk = doc.querySelector('aem-sidekick, helix-sidekick'); - if (sk) { - attachToSidekick(sk); - } - }, { once: true }); - } - - // Open the panel straight away when the page is loaded with a simulation - // deep-link (e.g. ?experiment=/ shared from the panel). - const usp = new URLSearchParams(window.location.search); - const [experimentId, variantId] = (usp.get(pluginOptions.experimentsQueryParameter) || '').split('/'); - if (experimentId && variantId) { - panel.open(); - } - - // Re-open the panel after a variant switch forced a full-page reload. - try { - if (window.sessionStorage.getItem(SIMULATION_PANEL_REOPEN_KEY) === 'true') { - window.sessionStorage.removeItem(SIMULATION_PANEL_REOPEN_KEY); - panel.open(); - } - } catch (e) { - debug('Failed to read simulation panel state:', e); - } -} - export async function loadEager(document, options = {}) { const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; setDebugMode(window.location, pluginOptions); @@ -1251,19 +1061,16 @@ 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'); - - if (isDebugEnabled) { - setupCommunicationLayer(pluginOptions); - } } /** * Loads the simulation UI used to preview and switch experiment variants. * * Since v2 the simulation panel is a hosted micro-frontend that the AEM Sidekick - * extension opens on demand. This wires the Sidekick toolbar button to that - * panel and lazy-loads it on first use. It is an authoring aid, so it only runs - * in preview/development environments, never in production. + * extension opens on demand. It is an authoring aid, so it only runs in + * preview/development environments, never in production. The actual UI lives in + * a separate `simulation.js` module that is dynamically imported only when + * needed, so its code never ships or parses with the core engine. * * In the Universal Editor the panel is delivered as a UE extension instead, so * pass `simulationUI: 'universal-editor'` (or `false`) to keep the plugin out of @@ -1288,10 +1095,8 @@ export async function loadLazy(document, options = {}) { return; } - // Ensure the postMessage handshake the panel talks over is available, even on - // preview pages that have no experiment configured yet (where loadEager's own - // call to it never ran). This is a no-op if already set up. - setupCommunicationLayer(pluginOptions); - - setupSimulationUI(pluginOptions, document); + // Load the simulation/preview UI on demand so it never ships with the engine. + // eslint-disable-next-line import/extensions + const { default: setupSimulation } = await import('./simulation.js'); + setupSimulation(pluginOptions, document); } diff --git a/src/simulation.js b/src/simulation.js new file mode 100644 index 00000000..5e4838e0 --- /dev/null +++ b/src/simulation.js @@ -0,0 +1,233 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/* + * Simulation / preview UI for the experimentation plugin. + * + * This module is intentionally kept out of the core engine (index.js) and is + * only ever loaded lazily, in preview/development environments, via a dynamic + * import from `loadLazy`. Production pages never download or parse it. + */ + +/** + * Logs a debug message. This module is only ever imported in preview/dev (from + * `loadLazy`, behind the debug-mode check), so logging is always enabled here. + * @param {...*} args the values to log + */ +function debug(...args) { + // eslint-disable-next-line no-console + console.debug('[aem-experimentation]', ...args); +} + +// The panel itself ships as a hosted micro-frontend that the Sidekick extension +// loads on demand; this module only wires the toolbar button to it. +const SIMULATION_MFE_URL = 'https://experience.adobe.com/solutions/ExpSuccess-aem-experimentation-mfe/static-assets/resources/sidekick/client.js?source=plugin'; +const SIMULATION_SIDEKICK_EVENT = 'custom:aem-experimentation-sidekick'; +const SIMULATION_PANEL_ID = 'aemExperimentation'; +const SIMULATION_PANEL_HIDDEN_CLASS = 'aemExperimentationHidden'; +const SIMULATION_PANEL_REOPEN_KEY = 'aem-experimentation-simulation-open'; + +let isCommunicationLayerInitialized = false; + +/** + * Sets up the postMessage handshake the hosted panel/rail UI talks over. + * @param {Object} options the plugin options + */ +function setupCommunicationLayer(options) { + // Only ever register once. + if (isCommunicationLayerInitialized) { + return; + } + isCommunicationLayerInitialized = true; + window.addEventListener('message', async (event) => { + if (event.data && event.data.type === 'hlx:last-modified-request') { + const { url } = event.data; + + try { + const response = await fetch(url, { + method: 'HEAD', + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + }, + }); + + const lastModified = response.headers.get('Last-Modified'); + + event.source.postMessage( + { + type: 'hlx:last-modified-response', + url, + lastModified, + status: response.status, + }, + event.origin, + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching Last-Modified header:', error); + } + } 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', + config: safeClone, + source: 'index-js', + }, + '*', + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error sending hlx config:', e); + } + } else if ( + event.data?.type === 'hlx:experimentation-window-reload' + && event.data?.action === 'reload' + ) { + // Preserve the panel's open state across the reload so it re-opens once + // the page comes back (see setupSimulationUI). + try { + window.sessionStorage.setItem(SIMULATION_PANEL_REOPEN_KEY, 'true'); + } catch (e) { + debug('Failed to persist simulation panel state:', e); + } + window.location.reload(); + } + }); +} + +/** + * Creates a controller for the hosted simulation panel that loads the MFE once + * (on first use) and then just toggles its visibility. + * @param {Document} doc The document object + * @returns {{ open: Function, toggle: Function }} the panel controller + */ +function createSimulationPanelController(doc) { + let loadPromise = null; + + const togglePanel = (forceShow = false) => { + const container = doc.getElementById(SIMULATION_PANEL_ID); + if (!container) { + return; + } + if (forceShow) { + container.classList.remove(SIMULATION_PANEL_HIDDEN_CLASS); + } else { + container.classList.toggle(SIMULATION_PANEL_HIDDEN_CLASS); + } + }; + + const load = () => { + if (loadPromise) { + return loadPromise; + } + loadPromise = new Promise((resolve, reject) => { + const script = doc.createElement('script'); + script.src = SIMULATION_MFE_URL; + script.onload = () => { + // The MFE injects its own container asynchronously, so wait for it to + // appear (bounded) before resolving. + let tries = 0; + const waitForContainer = () => { + if (doc.getElementById(SIMULATION_PANEL_ID) || tries >= 20) { + resolve(); + } else { + tries += 1; + setTimeout(waitForContainer, 200); + } + }; + waitForContainer(); + }; + script.onerror = reject; + doc.head.appendChild(script); + }); + return loadPromise; + }; + + const open = () => load() + .then(() => togglePanel(true)) + .catch((e) => debug('Failed to open simulation panel:', e)); + + const toggle = () => { + // The first interaction loads the MFE and reveals the panel; afterwards we + // just show/hide the already-loaded panel. + if (!loadPromise) { + return open(); + } + togglePanel(false); + return loadPromise; + }; + + return { open, toggle }; +} + +/** + * Wires up the AEM Sidekick simulation panel: binds the toolbar button to the + * panel, opens it on a simulation deep-link, and restores it after a reload. + * @param {Object} pluginOptions the plugin options + * @param {Document} doc The document object + */ +function setupSimulationUI(pluginOptions, doc) { + const panel = createSimulationPanelController(doc); + + const attachToSidekick = (sk) => { + sk.addEventListener(SIMULATION_SIDEKICK_EVENT, () => panel.toggle()); + }; + + // The Sidekick custom element may be injected by the extension after this + // runs, so fall back to its ready event. + const sidekick = doc.querySelector('aem-sidekick, helix-sidekick'); + if (sidekick) { + attachToSidekick(sidekick); + } else { + doc.addEventListener('sidekick-ready', () => { + const sk = doc.querySelector('aem-sidekick, helix-sidekick'); + if (sk) { + attachToSidekick(sk); + } + }, { once: true }); + } + + // Open the panel straight away when the page is loaded with a simulation + // deep-link (e.g. ?experiment=/ shared from the panel). + const usp = new URLSearchParams(window.location.search); + const [experimentId, variantId] = (usp.get(pluginOptions.experimentsQueryParameter) || '').split('/'); + if (experimentId && variantId) { + panel.open(); + } + + // Re-open the panel after a variant switch forced a full-page reload. + try { + if (window.sessionStorage.getItem(SIMULATION_PANEL_REOPEN_KEY) === 'true') { + window.sessionStorage.removeItem(SIMULATION_PANEL_REOPEN_KEY); + panel.open(); + } + } catch (e) { + debug('Failed to read simulation panel state:', e); + } +} + +/** + * Entry point for the lazily-loaded simulation UI. Sets up the postMessage + * handshake and wires up the Sidekick panel. + * @param {Object} pluginOptions the plugin options + * @param {Document} doc The document object + */ +export default function setupSimulation(pluginOptions, doc) { + setupCommunicationLayer(pluginOptions); + setupSimulationUI(pluginOptions, doc); +} From 133c22bb1a09db6a236f6882989996d213996f26 Mon Sep 17 00:00:00 2001 From: Dereje Dilnesaw Date: Wed, 22 Jul 2026 15:57:35 -0700 Subject: [PATCH 3/4] fix: harden simulation panel loading and keep the preview handshake eager Address review findings on PR #62 (multi-perspective review + @FentPams' approval notes): - Keep the tiny, UI-less postMessage handshake (setupCommunicationLayer) in index.js and register it eagerly from loadEager under setDebugMode, so a Sidekick bookmarklet that injects the MFE early in page load doesn't race past the listener (one-shot config request -> blank/stale panel). The idempotent guard makes loadLazy's re-call a no-op. Only the handler is eager; the heavy MFE client.js still loads lazily on click, so production pages download/parse no simulation code. Restores v2 behavior for projects that bump the plugin without adding runExperimentationLazy. - Reset loadPromise on script.onerror so a transient MFE load failure retries on the next toggle instead of permanently disabling the button. - Make setupSimulationUI idempotent; bind each sidekick element once via a dataset marker and add a bounded re-query so a missed sidekick-ready event isn't silent. - Debug-log the waitForContainer timeout so a missing MFE container is distinguishable from an unwired button. - Bump the exported VERSION const to 1.2.0; README documents the eager-handshake / lazy-UI split (no consumer migration needed). Co-Authored-By: Claude Opus 4.8 --- README.md | 9 +++ src/index.js | 105 +++++++++++++++++++++++++++++++- src/simulation.js | 152 +++++++++++++++++++--------------------------- 3 files changed, 176 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 0a8e4526..fac6e3cd 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,15 @@ environments, never in production, and only when the Sidekick is present (see th option to change this — for example in the Universal Editor, where the panel is delivered as a UE extension instead). +> **Note on performance and preview timing.** The panel's UI and MFE loader live in a separate +> `simulation.js` chunk that `loadLazy` pulls in via a dynamic `import()` **only** in +> preview/development, so production pages never download or parse any simulation code. The small, +> UI-less `postMessage` handshake the panel talks over is the one exception: it's registered eagerly +> (preview-only, from `loadEager`) so that a Sidekick bookmarklet that injects the panel early in page +> load still finds a listener. Bumping the plugin therefore keeps the preview handshake working +> exactly as before — no migration needed — while still keeping the heavy panel UI off the production +> path. + The one thing the plugin can't do for you is register the Sidekick toolbar button, since that lives in your project's Sidekick config, not in page code. Add the following plugin entry to `tools/sidekick/config.json` in your project (a copy also lives at diff --git a/src/index.js b/src/index.js index 59be1957..acdb1225 100644 --- a/src/index.js +++ b/src/index.js @@ -29,7 +29,7 @@ export function debug(...args) { } } -export const VERSION = '1.1.0'; +export const VERSION = '1.2.0'; export const DEFAULT_OPTIONS = { @@ -1047,6 +1047,96 @@ async function serveAudience(document, pluginOptions) { ); } +// sessionStorage key used to re-open the simulation panel after a variant +// switch forces a full-page reload. The reload handler below writes it; the +// lazily-loaded simulation UI reads it (see src/simulation.js). +const SIMULATION_PANEL_REOPEN_KEY = 'aem-experimentation-simulation-open'; + +let isCommunicationLayerInitialized = false; + +/** + * Sets up the `postMessage` handshake the hosted simulation panel talks over. + * + * This is deliberately the *only* piece of simulation wiring that lives in the + * eager engine: it is a tiny message listener (no UI, no heavy MFE code) and it + * must be registered as early as possible. The AEM Sidekick bookmarklet can + * inject the panel's `client.js` during page load — before the lazy phase runs + * — and the MFE's config request is one-shot, so a listener registered only in + * `loadLazy` would miss it and the panel would come up blank/stale. The heavy + * MFE loader still lives in the lazily-imported `simulation.js`, so production + * pages neither download nor parse any of the panel UI. + * + * Both `loadEager` and `loadLazy` may call this; the guard makes every call + * after the first a no-op. + * @param {Object} options the plugin options + */ +function setupCommunicationLayer(options) { + if (isCommunicationLayerInitialized) { + return; + } + isCommunicationLayerInitialized = true; + window.addEventListener('message', async (event) => { + if (event.data && event.data.type === 'hlx:last-modified-request') { + const { url } = event.data; + + try { + const response = await fetch(url, { + method: 'HEAD', + cache: 'no-store', + headers: { + 'Cache-Control': 'no-cache', + }, + }); + + const lastModified = response.headers.get('Last-Modified'); + + event.source.postMessage( + { + type: 'hlx:last-modified-response', + url, + lastModified, + status: response.status, + }, + event.origin, + ); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error fetching Last-Modified header:', error); + } + } 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', + config: safeClone, + source: 'index-js', + }, + '*', + ); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error sending hlx config:', e); + } + } else if ( + event.data?.type === 'hlx:experimentation-window-reload' + && event.data?.action === 'reload' + ) { + // Preserve the panel's open state across the reload so it re-opens once + // the page comes back (see setupSimulationUI in src/simulation.js). + try { + window.sessionStorage.setItem(SIMULATION_PANEL_REOPEN_KEY, 'true'); + } catch (e) { + debug('Failed to persist simulation panel state:', e); + } + window.location.reload(); + } + }); +} + export async function loadEager(document, options = {}) { const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; setDebugMode(window.location, pluginOptions); @@ -1061,6 +1151,13 @@ 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'); + + // Register the (tiny, UI-less) simulation handshake as early as possible so + // an eagerly-injected Sidekick bookmarklet doesn't race past it. Preview/dev + // only; the heavy panel UI is still deferred to loadLazy. + if (isDebugEnabled) { + setupCommunicationLayer(pluginOptions); + } } /** @@ -1095,8 +1192,12 @@ export async function loadLazy(document, options = {}) { return; } + // Ensure the postMessage handshake is available even on preview pages that had + // no experiment configured when loadEager ran. No-op if already registered. + setupCommunicationLayer(pluginOptions); + // Load the simulation/preview UI on demand so it never ships with the engine. // eslint-disable-next-line import/extensions const { default: setupSimulation } = await import('./simulation.js'); - setupSimulation(pluginOptions, document); + setupSimulation(pluginOptions, document, SIMULATION_PANEL_REOPEN_KEY); } diff --git a/src/simulation.js b/src/simulation.js index 5e4838e0..36dd3379 100644 --- a/src/simulation.js +++ b/src/simulation.js @@ -16,6 +16,11 @@ * This module is intentionally kept out of the core engine (index.js) and is * only ever loaded lazily, in preview/development environments, via a dynamic * import from `loadLazy`. Production pages never download or parse it. + * + * The tiny, UI-less `postMessage` handshake the panel talks over lives in + * index.js instead (setupCommunicationLayer), so it can be registered eagerly + * and never miss a bookmarklet-injected MFE. This module owns only the heavy + * part: loading the hosted MFE and wiring the Sidekick toolbar button to it. */ /** @@ -34,81 +39,8 @@ const SIMULATION_MFE_URL = 'https://experience.adobe.com/solutions/ExpSuccess-ae const SIMULATION_SIDEKICK_EVENT = 'custom:aem-experimentation-sidekick'; const SIMULATION_PANEL_ID = 'aemExperimentation'; const SIMULATION_PANEL_HIDDEN_CLASS = 'aemExperimentationHidden'; -const SIMULATION_PANEL_REOPEN_KEY = 'aem-experimentation-simulation-open'; - -let isCommunicationLayerInitialized = false; -/** - * Sets up the postMessage handshake the hosted panel/rail UI talks over. - * @param {Object} options the plugin options - */ -function setupCommunicationLayer(options) { - // Only ever register once. - if (isCommunicationLayerInitialized) { - return; - } - isCommunicationLayerInitialized = true; - window.addEventListener('message', async (event) => { - if (event.data && event.data.type === 'hlx:last-modified-request') { - const { url } = event.data; - - try { - const response = await fetch(url, { - method: 'HEAD', - cache: 'no-store', - headers: { - 'Cache-Control': 'no-cache', - }, - }); - - const lastModified = response.headers.get('Last-Modified'); - - event.source.postMessage( - { - type: 'hlx:last-modified-response', - url, - lastModified, - status: response.status, - }, - event.origin, - ); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Error fetching Last-Modified header:', error); - } - } 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', - config: safeClone, - source: 'index-js', - }, - '*', - ); - } catch (e) { - // eslint-disable-next-line no-console - console.error('Error sending hlx config:', e); - } - } else if ( - event.data?.type === 'hlx:experimentation-window-reload' - && event.data?.action === 'reload' - ) { - // Preserve the panel's open state across the reload so it re-opens once - // the page comes back (see setupSimulationUI). - try { - window.sessionStorage.setItem(SIMULATION_PANEL_REOPEN_KEY, 'true'); - } catch (e) { - debug('Failed to persist simulation panel state:', e); - } - window.location.reload(); - } - }); -} +let isSimulationUIInitialized = false; /** * Creates a controller for the hosted simulation panel that loads the MFE once @@ -143,7 +75,13 @@ function createSimulationPanelController(doc) { // appear (bounded) before resolving. let tries = 0; const waitForContainer = () => { - if (doc.getElementById(SIMULATION_PANEL_ID) || tries >= 20) { + if (doc.getElementById(SIMULATION_PANEL_ID)) { + resolve(); + } else if (tries >= 20) { + // Resolve anyway so we don't hang, but flag it: a subsequent + // togglePanel() will silently no-op without a container, so this + // distinguishes "MFE never injected its panel" from "button not wired". + debug('Simulation panel container did not appear after loading the MFE; the panel will not toggle.'); resolve(); } else { tries += 1; @@ -152,7 +90,12 @@ function createSimulationPanelController(doc) { }; waitForContainer(); }; - script.onerror = reject; + script.onerror = (e) => { + // Drop the cached (rejected) promise so the next toggle retries the + // load instead of permanently bricking the button on a transient blip. + loadPromise = null; + reject(e); + }; doc.head.appendChild(script); }); return loadPromise; @@ -180,26 +123,58 @@ function createSimulationPanelController(doc) { * panel, opens it on a simulation deep-link, and restores it after a reload. * @param {Object} pluginOptions the plugin options * @param {Document} doc The document object + * @param {string} reopenKey sessionStorage key used to re-open after a reload */ -function setupSimulationUI(pluginOptions, doc) { +function setupSimulationUI(pluginOptions, doc, reopenKey) { + // Guard against a consumer's loadLazy running more than once (e.g. on + // client-side navigation), which would otherwise stack duplicate button + // listeners and make each click toggle the panel twice — appearing dead. + if (isSimulationUIInitialized) { + return; + } + isSimulationUIInitialized = true; + const panel = createSimulationPanelController(doc); + const SIDEKICK_SELECTOR = 'aem-sidekick, helix-sidekick'; + const SIDEKICK_BOUND_ATTR = 'data-aem-experimentation-bound'; + const attachToSidekick = (sk) => { + // Multiple discovery paths (sync query, sidekick-ready, and the poll below) + // may all fire, so only bind the toggle listener once per element. + if (sk.hasAttribute(SIDEKICK_BOUND_ATTR)) { + return; + } + sk.setAttribute(SIDEKICK_BOUND_ATTR, ''); sk.addEventListener(SIMULATION_SIDEKICK_EVENT, () => panel.toggle()); }; - // The Sidekick custom element may be injected by the extension after this - // runs, so fall back to its ready event. - const sidekick = doc.querySelector('aem-sidekick, helix-sidekick'); + // The Sidekick custom element is injected by the extension asynchronously and + // may appear before or after this runs. Bind if it's already here; otherwise + // listen for its ready event AND poll briefly, since the event can fire before + // our listener is attached and would otherwise be missed silently. + const sidekick = doc.querySelector(SIDEKICK_SELECTOR); if (sidekick) { attachToSidekick(sidekick); } else { doc.addEventListener('sidekick-ready', () => { - const sk = doc.querySelector('aem-sidekick, helix-sidekick'); + const sk = doc.querySelector(SIDEKICK_SELECTOR); if (sk) { attachToSidekick(sk); } }, { once: true }); + + let tries = 0; + const pollForSidekick = () => { + const sk = doc.querySelector(SIDEKICK_SELECTOR); + if (sk) { + attachToSidekick(sk); + } else if (tries < 20) { + tries += 1; + setTimeout(pollForSidekick, 200); + } + }; + pollForSidekick(); } // Open the panel straight away when the page is loaded with a simulation @@ -212,8 +187,8 @@ function setupSimulationUI(pluginOptions, doc) { // Re-open the panel after a variant switch forced a full-page reload. try { - if (window.sessionStorage.getItem(SIMULATION_PANEL_REOPEN_KEY) === 'true') { - window.sessionStorage.removeItem(SIMULATION_PANEL_REOPEN_KEY); + if (window.sessionStorage.getItem(reopenKey) === 'true') { + window.sessionStorage.removeItem(reopenKey); panel.open(); } } catch (e) { @@ -222,12 +197,13 @@ function setupSimulationUI(pluginOptions, doc) { } /** - * Entry point for the lazily-loaded simulation UI. Sets up the postMessage - * handshake and wires up the Sidekick panel. + * Entry point for the lazily-loaded simulation UI. Wires up the Sidekick panel. + * The postMessage handshake it talks over is set up eagerly in index.js + * (setupCommunicationLayer), so it already exists by the time this runs. * @param {Object} pluginOptions the plugin options * @param {Document} doc The document object + * @param {string} reopenKey sessionStorage key used to re-open after a reload */ -export default function setupSimulation(pluginOptions, doc) { - setupCommunicationLayer(pluginOptions); - setupSimulationUI(pluginOptions, doc); +export default function setupSimulation(pluginOptions, doc, reopenKey) { + setupSimulationUI(pluginOptions, doc, reopenKey); } From f43e05b07c64628779c0cec0480bb5592bcae72c Mon Sep 17 00:00:00 2001 From: Julien Ramboz Date: Thu, 23 Jul 2026 08:45:00 -0700 Subject: [PATCH 4/4] test: cover the simulation UI module Add tests/simulation.test.js exercising src/simulation.js, which Codecov flagged as low-coverage after the extraction. Covers the Sidekick button toggle, the sidekick-ready fallback, the ?experiment deep-link, the get-config and last-modified postMessage handshakes, the reload + panel-reopen flow, and the production / simulationUI=false / universal-editor gates. The hosted MFE script is stubbed via page.route (injecting the #aemExperimentation container), and loadLazy is exposed on window through a string-content module script so the runner's Babel transform doesn't rewrite the dynamic import. Brings simulation.js to ~88% statement coverage. Co-Authored-By: Claude Opus 4.8 --- tests/simulation.test.js | 278 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 tests/simulation.test.js diff --git a/tests/simulation.test.js b/tests/simulation.test.js new file mode 100644 index 00000000..96851670 --- /dev/null +++ b/tests/simulation.test.js @@ -0,0 +1,278 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { test, expect } from '@playwright/test'; +import { track } from './coverage.js'; + +track(test); + +const MFE_GLOB = '**/ExpSuccess-aem-experimentation-mfe/**'; +const REOPEN_KEY = 'aem-experimentation-simulation-open'; + +/** + * Intercepts the hosted MFE script and fulfills it with a stub that injects the + * `#aemExperimentation` container the panel controller waits for. Returns a + * getter that reports whether the MFE was ever requested. + */ +async function stubMfe(page) { + let requested = false; + await page.route(MFE_GLOB, async (route) => { + requested = true; + await route.fulfill({ + contentType: 'application/javascript', + body: `(function () { + var el = document.createElement('div'); + el.id = 'aemExperimentation'; + el.classList.add('aemExperimentationHidden'); + document.body.appendChild(el); + })();`, + }); + }); + return () => requested; +} + +/** + * Exposes the plugin's `loadLazy` on `window.aemExpLoadLazy`. The import lives in a + * string (not a transformed `import()` expression) so the test runner's Babel + * transform leaves it alone and the browser resolves it natively. + */ +async function exposePlugin(page) { + await page.addScriptTag({ + type: 'module', + content: 'import { loadLazy } from "/src/index.js"; window.aemExpLoadLazy = loadLazy;', + }); + await page.waitForFunction(() => typeof window.aemExpLoadLazy === 'function'); +} + +/** + * Runs the plugin's lazy phase. `debug` forces the preview gate on by supplying + * `isProd: () => false` (built browser-side, since functions can't cross evaluate). + */ +async function runLoadLazy(page, options = {}, debug = true) { + await page.evaluate(async ({ opts, dbg }) => { + const config = dbg ? { isProd: () => false, ...opts } : { ...opts }; + await window.aemExpLoadLazy(document, config); + }, { opts: options, dbg: debug }); +} + +const panel = (page) => page.locator('#aemExperimentation'); + +test.describe('Simulation UI', () => { + test('opens the panel automatically from an ?experiment deep-link', async ({ page }) => { + const wasRequested = await stubMfe(page); + await page.goto('/tests/fixtures/global?experiment=foo/challenger-1'); + await exposePlugin(page); + await runLoadLazy(page); + + await expect(panel(page)).toHaveCount(1); + await expect(panel(page)).not.toHaveClass(/aemExperimentationHidden/); + expect(wasRequested()).toBe(true); + // The MFE is loaded with the plugin source marker. + await expect(page.locator('script[src*="client.js?source=plugin"]')).toHaveCount(1); + }); + + test('toggles the panel when the Sidekick button is clicked', async ({ page }) => { + await stubMfe(page); + await page.goto('/tests/fixtures/global'); + await page.evaluate(() => { + document.body.appendChild(document.createElement('aem-sidekick')); + }); + await exposePlugin(page); + await runLoadLazy(page); + + const dispatch = () => page.evaluate(() => { + document.querySelector('aem-sidekick') + .dispatchEvent(new CustomEvent('custom:aem-experimentation-sidekick')); + }); + + // First click loads the MFE and reveals the panel. + await dispatch(); + await expect(panel(page)).not.toHaveClass(/aemExperimentationHidden/); + + // Second click hides it again. + await dispatch(); + await expect(panel(page)).toHaveClass(/aemExperimentationHidden/); + }); + + test('wires the button via the sidekick-ready event when injected late', async ({ page }) => { + await stubMfe(page); + await page.goto('/tests/fixtures/global'); + await exposePlugin(page); + // No Sidekick element present when loadLazy runs. + await runLoadLazy(page); + + await page.evaluate(() => { + document.body.appendChild(document.createElement('aem-sidekick')); + document.dispatchEvent(new CustomEvent('sidekick-ready')); + }); + await page.evaluate(() => { + document.querySelector('aem-sidekick') + .dispatchEvent(new CustomEvent('custom:aem-experimentation-sidekick')); + }); + + await expect(panel(page)).not.toHaveClass(/aemExperimentationHidden/); + }); + + test('answers the get-config handshake with the current hlx config', async ({ page }) => { + await stubMfe(page); + await page.goto('/tests/fixtures/global'); + await exposePlugin(page); + await runLoadLazy(page, { prodHost: 'www.example.com' }); + + const response = await page.evaluate(() => new Promise((resolve) => { + window.hlx = { experiments: [], audiences: [], campaigns: [] }; + window.addEventListener('message', (e) => { + if (e.data?.type === 'hlx:experimentation-config') resolve(e.data); + }); + window.postMessage({ type: 'hlx:experimentation-get-config' }, '*'); + })); + + expect(response.source).toBe('index-js'); + expect(response.config).toBeDefined(); + expect(response.config.experiments).toEqual([]); + expect(response.config.prodHost).toBe('www.example.com'); + }); + + test('answers the last-modified handshake', async ({ page }) => { + await stubMfe(page); + await page.route('**/lastmod-probe', (route) => route.fulfill({ + status: 200, + headers: { 'Last-Modified': 'Wed, 21 Oct 2026 07:28:00 GMT' }, + contentType: 'text/plain', + body: 'x', + })); + await page.goto('/tests/fixtures/global'); + await exposePlugin(page); + await runLoadLazy(page); + + const response = await page.evaluate(() => new Promise((resolve) => { + window.addEventListener('message', (e) => { + if (e.data?.type === 'hlx:last-modified-response') resolve(e.data); + }); + window.postMessage({ type: 'hlx:last-modified-request', url: '/lastmod-probe' }, '*'); + })); + + expect(response.status).toBe(200); + expect(response.lastModified).toBe('Wed, 21 Oct 2026 07:28:00 GMT'); + expect(response.url).toBe('/lastmod-probe'); + }); + + test('reloads and re-opens the panel after a variant switch', async ({ page }) => { + await stubMfe(page); + await page.goto('/tests/fixtures/global'); + await exposePlugin(page); + await runLoadLazy(page); + + // The MFE asks the page to reload; the panel state is persisted across it. + await Promise.all([ + page.waitForEvent('load'), + page.evaluate(() => window.postMessage( + { type: 'hlx:experimentation-window-reload', action: 'reload' }, + '*', + )), + ]); + expect(await page.evaluate((k) => sessionStorage.getItem(k), REOPEN_KEY)).toBe('true'); + + // Running the lazy phase again after the reload re-opens the panel and clears the flag. + await exposePlugin(page); + await runLoadLazy(page); + await expect(panel(page)).not.toHaveClass(/aemExperimentationHidden/); + expect(await page.evaluate((k) => sessionStorage.getItem(k), REOPEN_KEY)).toBeNull(); + }); + + test('does not load the UI in production', async ({ page }) => { + const wasRequested = await stubMfe(page); + await page.goto('/tests/fixtures/global'); + await page.evaluate(() => { + document.body.appendChild(document.createElement('aem-sidekick')); + }); + await exposePlugin(page); + // No isProd/prodHost override => 127.0.0.1 is treated as production. + await runLoadLazy(page, {}, false); + await page.evaluate(() => { + document.querySelector('aem-sidekick') + .dispatchEvent(new CustomEvent('custom:aem-experimentation-sidekick')); + }); + + await expect(panel(page)).toHaveCount(0); + expect(wasRequested()).toBe(false); + }); + + test('does not load the UI when simulationUI is disabled', async ({ page }) => { + const wasRequested = await stubMfe(page); + await page.goto('/tests/fixtures/global?experiment=foo/challenger-1'); + await exposePlugin(page); + await runLoadLazy(page, { simulationUI: false }); + + await expect(panel(page)).toHaveCount(0); + expect(wasRequested()).toBe(false); + }); + + test('stays out of the way in the Universal Editor', async ({ page }) => { + const wasRequested = await stubMfe(page); + await page.goto('/tests/fixtures/global?experiment=foo/challenger-1'); + await exposePlugin(page); + await runLoadLazy(page, { simulationUI: 'universal-editor' }); + + await expect(panel(page)).toHaveCount(0); + expect(wasRequested()).toBe(false); + }); + + test('binds the Sidekick button only once across repeated loadLazy calls', async ({ page }) => { + await stubMfe(page); + await page.goto('/tests/fixtures/global'); + await page.evaluate(() => { + document.body.appendChild(document.createElement('aem-sidekick')); + }); + await exposePlugin(page); + await runLoadLazy(page); + // A second lazy phase (e.g. a client-side navigation) must be a no-op, not a + // second binding — otherwise one click would toggle twice and appear dead. + await runLoadLazy(page); + + await page.evaluate(() => { + document.querySelector('aem-sidekick') + .dispatchEvent(new CustomEvent('custom:aem-experimentation-sidekick')); + }); + await expect(panel(page)).not.toHaveClass(/aemExperimentationHidden/); + }); + + test('retries loading the MFE after a transient script failure', async ({ page }) => { + let calls = 0; + await page.route(MFE_GLOB, async (route) => { + calls += 1; + if (calls === 1) { + await route.abort(); + return; + } + await route.fulfill({ + contentType: 'application/javascript', + body: `(function () { + var el = document.createElement('div'); + el.id = 'aemExperimentation'; + el.classList.add('aemExperimentationHidden'); + document.body.appendChild(el); + })();`, + }); + }); + await page.goto('/tests/fixtures/global'); + await page.evaluate(() => { + document.body.appendChild(document.createElement('aem-sidekick')); + }); + await exposePlugin(page); + await runLoadLazy(page); + + const dispatch = () => page.evaluate(() => { + document.querySelector('aem-sidekick') + .dispatchEvent(new CustomEvent('custom:aem-experimentation-sidekick')); + }); + + // First click: the MFE request aborts, which resets the cached promise. + await Promise.all([ + page.waitForEvent('requestfailed', (req) => req.url().includes('aem-experimentation-mfe')), + dispatch(), + ]); + // Second click retries the load rather than staying permanently bricked. + await dispatch(); + await expect(panel(page)).not.toHaveClass(/aemExperimentationHidden/); + expect(calls).toBe(2); + }); +});