diff --git a/README.md b/README.md index 48767bdb..fac6e3cd 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,68 @@ 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). + +> **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 +[`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..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 = { @@ -48,6 +48,13 @@ 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'; @@ -1040,8 +1047,34 @@ async function serveAudience(document, pluginOptions) { ); } -// Support new Rail UI communication +// 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; @@ -1092,6 +1125,13 @@ 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 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(); } }); @@ -1112,11 +1152,52 @@ export async function loadEager(document, options = {}) { 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); } } -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. 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 + * 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 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, SIMULATION_PANEL_REOPEN_KEY); } diff --git a/src/simulation.js b/src/simulation.js new file mode 100644 index 00000000..36dd3379 --- /dev/null +++ b/src/simulation.js @@ -0,0 +1,209 @@ +/* + * 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. + * + * 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. + */ + +/** + * 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'; + +let isSimulationUIInitialized = false; + +/** + * 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)) { + 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; + setTimeout(waitForContainer, 200); + } + }; + waitForContainer(); + }; + 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; + }; + + 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 + * @param {string} reopenKey sessionStorage key used to re-open after a reload + */ +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 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(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 + // 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(reopenKey) === 'true') { + window.sessionStorage.removeItem(reopenKey); + panel.open(); + } + } catch (e) { + debug('Failed to read simulation panel state:', e); + } +} + +/** + * 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, reopenKey) { + setupSimulationUI(pluginOptions, doc, reopenKey); +} 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); + }); +});