diff --git a/README.md b/README.md index e7f06cb1..8521399d 100644 --- a/README.md +++ b/README.md @@ -38,11 +38,13 @@ Before you begin, ensure you have the [AEM Sidekick Chrome extension](https://ch - To see how this experiment is configured, right-click on the AEM Sidekick extension and select "View document source" - Scroll down to the Metadata block at the bottom of the document -- Note the Experiment, and Instant Experiment properties -- The Instant Experiment property defines the pages used for the "challenger" variants in the Experimentation UI +- Note the Experiment, and Experiment Vairants properties +- The Experiment Vairants property defines the pages used for the "challenger" variants in the Experimentation UI The document source which configures the experiments +For more details about Experimentation, please check out this repo : [aem-experimentation](https://github.com/adobe/aem-experimentation/) + ### Conversion Tracking [RUM docs](https://www.hlx.live/developer/rum) diff --git a/docs/images/experiment-metadata.png b/docs/images/experiment-metadata.png index 10e0277e..92f3f81d 100644 Binary files a/docs/images/experiment-metadata.png and b/docs/images/experiment-metadata.png differ diff --git a/plugins/experimentation/.eslintignore b/plugins/experimentation/.eslintignore deleted file mode 100644 index 0a87cad0..00000000 --- a/plugins/experimentation/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -src/ued.js \ No newline at end of file diff --git a/plugins/experimentation/.github/workflows/release.yaml b/plugins/experimentation/.github/workflows/release.yaml new file mode 100644 index 00000000..a72f6cd6 --- /dev/null +++ b/plugins/experimentation/.github/workflows/release.yaml @@ -0,0 +1,36 @@ +name: Release +on: + push: + branches: + - main + +permissions: + contents: read + +jobs: + release: + name: Release + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + pull-requests: write + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "lts/*" + - name: Install dependencies + run: npm ci + - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies + run: npm audit signatures + - name: Release + env: + # Token taken from: https://app.codecov.io/github/adobe/aem-experimentation/settings + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npx semantic-release \ No newline at end of file diff --git a/plugins/experimentation/.github/workflows/run-tests.yaml b/plugins/experimentation/.github/workflows/run-tests.yaml new file mode 100644 index 00000000..5eb55c09 --- /dev/null +++ b/plugins/experimentation/.github/workflows/run-tests.yaml @@ -0,0 +1,28 @@ +name: Linting + +on: [pull_request] + +jobs: + build: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.44.1-jammy + steps: + - uses: actions/checkout@v3 + - name: Use Node.js + uses: actions/setup-node@v3 + with: + node-version: '20' + - run: npm ci + - run: npm run lint + env: + CI: true + - run: npm run test + env: + CI: true + - name: Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: adobe/aem-experimentation + files: ./coverage/codecov.json diff --git a/plugins/experimentation/.gitignore b/plugins/experimentation/.gitignore new file mode 100644 index 00000000..c059a6ff --- /dev/null +++ b/plugins/experimentation/.gitignore @@ -0,0 +1,3 @@ +coverage/ +playwright-report/ +test-results/ \ No newline at end of file diff --git a/plugins/experimentation/.releaserc b/plugins/experimentation/.releaserc new file mode 100644 index 00000000..904333e4 --- /dev/null +++ b/plugins/experimentation/.releaserc @@ -0,0 +1,13 @@ +{ + "branches": ["main"], + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/changelog", + "@semantic-release/npm", + ["@semantic-release/git", { + "assets": ["CHANGELOG.md", "package.json"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + }] + ] +} \ No newline at end of file diff --git a/plugins/experimentation/CHANGELOG.md b/plugins/experimentation/CHANGELOG.md new file mode 100644 index 00000000..27eb2494 --- /dev/null +++ b/plugins/experimentation/CHANGELOG.md @@ -0,0 +1,6 @@ +## [1.0.1](https://github.com/adobe/aem-experimentation/compare/v1.0.0...v1.0.1) (2024-05-24) + + +### Bug Fixes + +* semantic release ([5040fa8](https://github.com/adobe/aem-experimentation/commit/5040fa88c7a01b032431967e230abaaf6d69f9d6)) diff --git a/plugins/experimentation/README.md b/plugins/experimentation/README.md index 1a9efb14..daf5a5d1 100644 --- a/plugins/experimentation/README.md +++ b/plugins/experimentation/README.md @@ -26,113 +26,115 @@ git subtree pull --squash --prefix plugins/experimentation git@github.com:adobe/ If you prefer using `https` links you'd replace `git@github.com:adobe/aem-experimentation.git` in the above commands by `https://github.com/adobe/aem-experimentation.git`. -If the `subtree pull` command is failing with an error like: -``` -fatal: can't squash-merge: 'plugins/experimentation' was never added -``` -you can just delete the folder and re-add the plugin via the `git subtree add` command above. - ## Project instrumentation -:warning: The plugin requires that you have a recent RUM instrumentation from the AEM boilerplate that supports `sampleRUM.always`. If you are getting errors that `.on` cannot be called on an `undefined` object, please apply the changes from https://github.com/adobe/aem-boilerplate/pull/247/files to your `lib-franklin.js`. - -### On top of the plugin system - -The easiest way to add the plugin is if your project is set up with the plugin system extension in the boilerplate. -You'll know you have it if `window.hlx.plugins` is defined on your page. - -If you don't have it, you can follow the proposal in https://github.com/adobe/aem-lib/pull/23 and https://github.com/adobe/aem-boilerplate/pull/275 and apply the changes to your `aem.js`/`lib-franklin.js` and `scripts.js`. - -Once you have confirmed this, you'll need to edit your `scripts.js` in your AEM project and add the following at the start of the file: -```js -const AUDIENCES = { - mobile: () => window.innerWidth < 600, - desktop: () => window.innerWidth >= 600, - // define your custom audiences here as needed -}; - -window.hlx.plugins.add('experimentation', { - condition: () => getMetadata('experiment') - || Object.keys(getAllMetadata('campaign')).length - || Object.keys(getAllMetadata('audience')).length, - options: { audiences: AUDIENCES }, - url: '/plugins/experimentation/src/index.js', -}); -``` - ### On top of a regular boilerplate project -Typically, you'd know you don't have the plugin system if you don't see a reference to `window.hlx.plugins` in your `scripts.js`. In that case, you can still manually instrument this plugin in your project by falling back to a more manual instrumentation. To properly connect and configure the plugin for your project, you'll need to edit your `scripts.js` in your AEM project and add the following: +Typically, you'd know you don't have the plugin system if you don't see a reference to `window.aem.plugins` or `window.hlx.plugins` in your `scripts.js`. In that case, you can still manually instrument this plugin in your project by falling back to a more manual instrumentation. To properly connect and configure the plugin for your project, you'll need to edit your `scripts.js` in your AEM project and add the following: 1. at the start of the file: ```js - const AUDIENCES = { - mobile: () => window.innerWidth < 600, - desktop: () => window.innerWidth >= 600, - // define your custom audiences here as needed + const experimentationConfig = { + prodHost: 'www.my-site.com', + audiences: { + mobile: () => window.innerWidth < 600, + desktop: () => window.innerWidth >= 600, + // define your custom audiences here as needed + } }; - /** - * Gets all the metadata elements that are in the given scope. - * @param {String} scope The scope/prefix for the metadata - * @returns an array of HTMLElement nodes that match the given scope - */ - export function getAllMetadata(scope) { - return [...document.head.querySelectorAll(`meta[property^="${scope}:"],meta[name^="${scope}-"]`)] - .reduce((res, meta) => { - const id = toClassName(meta.name - ? meta.name.substring(scope.length + 1) - : meta.getAttribute('property').split(':')[1]); - res[id] = meta.getAttribute('content'); - return res; - }, {}); + let runExperimentation; + let showExperimentationOverlay; + const isExperimentationEnabled = document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"]') + || [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i)); + if (isExperimentationEnabled) { + const { + loadEager: runExperimentation, + loadLazy: showExperimentationOverlay, + } = await import('../plugins/experimentation/src/index.js'); } ``` -2. if this is the first plugin you add to your project, you'll also need to add: - ```js - // Define an execution context - const pluginContext = { - getAllMetadata, - getMetadata, - loadCSS, - loadScript, - sampleRUM, - toCamelCase, - toClassName, - }; - ``` -3. Early in the `loadEager` method you'll need to add: +2. Early in the `loadEager` method you'll need to add: ```js async function loadEager(doc) { … // Add below snippet early in the eager phase - if (getMetadata('experiment') - || Object.keys(getAllMetadata('campaign')).length - || Object.keys(getAllMetadata('audience')).length) { - // eslint-disable-next-line import/no-relative-packages - const { loadEager: runEager } = await import('../plugins/experimentation/src/index.js'); - await runEager(document, { audiences: AUDIENCES }, pluginContext); + if (runExperimentation) { + await runExperimentation(document, experimentationConfig); } … } ``` This needs to be done as early as possible since this will be blocking the eager phase and impacting your LCP, so we want this to execute as soon as possible. -4. Finally at the end of the `loadLazy` method you'll have to add: +3. Finally at the end of the `loadLazy` method you'll have to add: ```js async function loadLazy(doc) { … // Add below snippet at the end of the lazy phase - if ((getMetadata('experiment') - || Object.keys(getAllMetadata('campaign')).length - || Object.keys(getAllMetadata('audience')).length)) { - // eslint-disable-next-line import/no-relative-packages - const { loadLazy: runLazy } = await import('../plugins/experimentation/src/index.js'); - await runLazy(document, { audiences: AUDIENCES }, pluginContext); + if (showExperimentationOverlay) { + await showExperimentationOverlay(document, experimentationConfig); } } ``` This is mostly used for the authoring overlay, and as such isn't essential to the page rendering, so having it at the end of the lazy phase is good enough. +### On top of the plugin system + +The easiest way to add the plugin is if your project is set up with the plugin system extension in the boilerplate. +You'll know you have it if either `window.aem.plugins` or `window.hlx.plugins` is defined on your page. + +If you don't have it, you can follow the proposal in https://github.com/adobe/aem-lib/pull/23 and https://github.com/adobe/aem-boilerplate/pull/275 and apply the changes to your `aem.js`/`lib-franklin.js` and `scripts.js`. + +Once you have confirmed this, you'll need to edit your `scripts.js` in your AEM project and add the following at the start of the file: +```js +const experimentationConfig = { + prodHost: 'www.my-site.com', + audiences: { + mobile: () => window.innerWidth < 600, + desktop: () => window.innerWidth >= 600, + // define your custom audiences here as needed + } +}; + +window.aem.plugins.add('experimentation', { // use window.hlx instead of your project has this + condition: () => + // page level metadata + document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"]') + // decorated section metadata + || document.querySelector('.section[class*=experiment],.section[class*=audience],.section[class*=campaign]') + // undecorated section metadata + || [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i)), + options: experimentationConfig, + url: '/plugins/experimentation/src/index.js', +}); +``` + +### 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. + +Edit your html `` and set configure the RUM sampling like: +```html + +... + + + + +``` + +Then double-check your `aem.js` file around line 20 and look for: +```js +const weight = new URLSearchParams(window.location.search).get('rum') === 'on' ? 1 : defaultSamplingRate; +``` + +If this is not present, please apply the following changes to the file: https://github.com/adobe/helix-rum-js/pull/159/files#diff-bfe9874d239014961b1ae4e89875a6155667db834a410aaaa2ebe3cf89820556 + ### Custom options There are various aspects of the plugin that you can configure via options you are passing to the 2 main methods above (`runEager`/`runLazy`). @@ -140,22 +142,12 @@ You have already seen the `audiences` option in the examples above, but here is ```js runEager.call(document, { - // Overrides the base path if the plugin was installed in a sub-directory - basePath: '', - // Lets you configure the prod environment. // (prod environments do not get the pill overlay) prodHost: 'www.my-website.com', // if you have several, or need more complex logic to toggle pill overlay, you can use - isProd: () => window.location.hostname.endsWith('hlx.page') - || window.location.hostname === ('localhost'), - - /* Generic properties */ - // RUM sampling rate on regular AEM pages is 1 out of 100 page views - // but we increase this by default for audiences, campaigns and experiments - // to 1 out of 10 page views so we can collect metrics faster of the relative - // short durations of those campaigns/experiments - rumSamplingRate: 10, + isProd: () => !window.location.hostname.endsWith('hlx.page') + && window.location.hostname !== ('localhost'), // the storage type used to persist data between page views // (for instance to remember what variant in an experiment the user was served) @@ -174,14 +166,56 @@ runEager.call(document, { /* Experimentation related properties */ // See more details on the dedicated Experiments page linked below - experimentsRoot: '/experiments', - experimentsConfigFile: 'manifest.json', - experimentsMetaTag: 'experiment', + experimentsMetaTagPrefix: 'experiment', experimentsQueryParameter: 'experiment', -}, pluginContext); + + /* Fragment experiment needs redecoration */ + // See more details below + decorationFunction: (el) => { + /* handle custom decoration here, for example: */ + buildBlock(el); + decorateBlock(el); + } +}); ``` For detailed implementation instructions on the different features, please read the dedicated pages we have on those topics: -- [Audiences](https://github.com/adobe/aem-experimentation/wiki/Audiences) -- [Campaigns](https://github.com/adobe/aem-experimentation/wiki/Campaigns) -- [Experiments](https://github.com/adobe/aem-experimentation/wiki/Experiments) +- [Audiences](/documentation/audiences.md) +- [Campaigns](/documentation/campaigns.md) +- [Experiments](/documentation/experiments.md) + +**Cases of passing `decorationFunction`** +Fragment replacement is handled by async observer, which may execute before or after default decoration complete. So, you need to provide a decoration method to redecorate. There are several common cases: +1. Have a selector for an element inside a block and the block needs to be redecorated => sample code above +2. Have a `.block` selector and need to redecorate => switch block status to `"loading"` and call `loadBlock(el)` +3. Have a `.section` selector and need to redecorate => call `decorateBlocks(el)` +4. Have a `main` selector and need to redecorate => call `decorateMain(el)` + +## Extensibility & integrations + +If you need to further integrate the experimentation plugin with custom analytics reporting or other 3rd-party libraries, you can listen for the `aem:experimentation` event: +```js +document.addEventListener('aem:experimentation', (ev) => console.log(ev.detail)); +``` + +The event details will contain one of 3 possible sets of properties: +- For experiments: + - `type`: `experiment` + - `element`: the DOM element that was modified + - `experiment`: the experiment name + - `variant`: the variant name that was served +- For audiences: + - `type`: `audience` + - `element`: the DOM element that was modified + - `audience`: the audience that was resolved +- For campaigns: + - `type`: `campaign` + - `element`: the DOM element that was modified + - `campaign`: the campaign that was resolved + +Additionally, you can leverage the following global JS objects `window.hlx.experiments`, `window.hlx.audiences` and `window.hlx.campaigns`. +Those will each be an array of objects containing: + - `type`: one of `page`, `section` or `fragment` + - `el`: the DOM element that was modified + - `servedExperience`: the URL for the content that was inlined for that experience + - `config`: an object containing the config details diff --git a/plugins/experimentation/documentation/audiences.md b/plugins/experimentation/documentation/audiences.md new file mode 100644 index 00000000..563fbc1c --- /dev/null +++ b/plugins/experimentation/documentation/audiences.md @@ -0,0 +1,124 @@ +# Using audiences to personalize the experience + +## Overview + +With audiences you can serve different versions of your content to different groups of users based on the information you can glean from there current session. For instance, you can optimize the experience for: +- mobile vs. desktop +- Chrome vs. Firefox +- 1st vs. returning visitor +- fast vs slow connections +- different geographies +- etc. + +## Set up + +First, you need to define audiences for the project. This is done directly in the project codebase. Audiences are defined as a `Map` of audience names and boolean evaluating (async) functions that check whether the given audience is resolved in the current browsing session. + +You'd typically define the mapping in your AEM's `scripts.js` as follows: +```js +const geoPromise = (async () => { + const resp = await fetch(/* some geo service*/); + return resp.json(); +})(); + +const AUDIENCES = { + mobile: () => window.innerWidth < 600, + desktop: () => window.innerWidth >= 600, + us: async () => (await geoPromise).region === 'us', + eu: async () => (await geoPromise).region === 'eu', +} +``` + +As you can see in the example above, functions need to return a boolean value. If the value is truthy, the audience is considered resolved, and if it's falsy then it isn't. You can also use any browser API directly, or rely on external services to resolve an audience. + +:warning: Using external services will have a performance impact on the initial page load as the call will be blocking the page rendering until the async function is fully evaluated. + +The audiences for the project then need to be passed to the plugin initialization as follows: + +```js +const { loadEager } = await import('../plugins/experimentation/src/index.js'); +await loadEager(document, { audiences: AUDIENCES }); +``` + +### Custom options + +By default, the audience feature looks at the `Audience` metadata tags and `audience` query parameters, but if this clashes with your existing codebase or doesn't feel intuitive to your authors, you can adjust this by passing new options to the plugin. + +For instance, here is an alternate configuration that would use `segment` instead of `audience`: +```js +const { loadEager } = await import('../plugins/experimentation/src/index.js'); +await loadEager(document, { + audiences: AUDIENCES, + audiencesMetaTagPrefix: 'segment', + audiencesQueryParameter: 'segment', +}); +``` + +## Authoring + +Once the above steps are done, your authors are ready to start using audiences for their experiences. + +### Page-level audiences + +Each Page can have several page-level audiences defined in the page metadata. +The audiences are set up directly in the page metadata block as follows: + +| Metadata | | +|-------------------|---------------------------------------------------------------| +| Audience: Mobile | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-mobile]() | +| Audience: Desktop | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-desktop]() | + +The notation is pretty flexible and authors can also use `Audience (Mobile)` or `Audience Mobile` if this is a preferred notation. + +#### Page redirect + +If you aim to direct your audience to a target URL instead of just replacing the content, you can do so by adding the `Audience Resolution | redirect` property to the page metadata: + +| Metadata | | +|---------------------|---------------------------------------------------------------| +| Audience: Mobile | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-mobile]() | +| Audience: Desktop | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-desktop]() | +| Audience Resolution | redirect | + +### Section-level audiences + +Each section in a page can also run any number of audiences. Section-level audiences are run after the page-level audiences have run, i.e. after the variants have been processed and their markup was pulled into the main page, so the section-level audiences that will run are dictated by the document from the current page-level experiment/audience/campaign, and not necessarily just the main page. + +Section-level audiences are authored essentially the same way that page-level audiences are, but leverage the `Section Metadata` block instead: + +| Section Metadata | | +|-------------------|---------------------------------------------------------------| +| Audience: Mobile | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-mobile]() | +| Audience: Desktop | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-desktop]() | + +### Fragment-level audiences + +Fragment-level audiences are handled differently than page and section-level audiences. They target a specific CSS selector instead of the whole page or the section. Whenever the desired CSS `selector` is resolved in the DOM tree (i.e. whenever the element is added to the page), the audiences will be run. For AEM, this typically happens even before the `decorate` method from the block's JS file is run. + +Fragment-level audiences are also authored differently than page and section-level audiences. First, you need to specify a new metadata entry: + +| Metadata | | +|---------------------|-------------------------------------------------------------------------------| +| Audience Manifest | [https://{ref}--{repo}--{org}.hlx.page/my-audiences.json?sheet=mobile]() | + +The spreadsheet then needs to be defined as follows: + +| Page | Audience | Selector | Url | +|-----------|----------|----------|---------------------------------| +| /my-page/ | Mobile | .hero | /fragments/my-page-hero-mobile | +| /my-page/ | Desktop | .hero | /fragments/my-page-hero-desktop | + +The same spreadsheet can also contain the configuration for several pages at once. The engine will filter out the entries in the spreadsheet that match the current page. + + +### 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.page`) that lets them see what audiences have been configured for the page and switch between each to visualize the content variations accordingly. + +![audience overlay](./images/audiences-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. + +## Development + +To help developers in designing variants for each audience, when audiences are resolved on the page it will automatically add a new CSS class named `audience-` for each to the `` element, i.e. `audience-mobile audience-iphone`. diff --git a/plugins/experimentation/documentation/campaigns.md b/plugins/experimentation/documentation/campaigns.md new file mode 100644 index 00000000..a3f392eb --- /dev/null +++ b/plugins/experimentation/documentation/campaigns.md @@ -0,0 +1,108 @@ +# Running marketing campaigns that personalize the experience + +## Overview + +With campaigns you can send out emails or social media posts that link back to your site and that will serve specific offers or versions of your content to the targeted audience. + +## Set up + +The setup is pretty minimal. Once you've instrumented the experimentation plugin in your AEM website, you are essentially good to go. + +Just keep in mind that if you want to only target specific audiences for that campaign, you'll also need to set up the [audiences](Audiences) accordingly for your project. + +### Custom options + +By default, the campaigns feature looks at the `Campaign` metadata tags and `campaign` query parameter, but if this clashes with your existing codebase or doesn't feel intuitive to your authors, you can adjust this by passing new options to the plugin. + +For instance, here is an alternate configuration that would use `sale` instead of `campaign`: +```js +const { loadEager } = await import('../plugins/experimentation/src/index.js'); +await loadEager(document, { + campaignsMetaTagPrefix: 'sale', + campaignsQueryParameter: 'sale', +}); +``` + +:mega: The campaign feature also supports the industry-standard Urchin Tracking Module (UTM) `utm_campaign` as query parameter. There is nothing special you need to do to get this working and it will be seamlessly handled the same way as the `campaignsQueryParameter`. This means that both: + +- [https://{ref}--{repo}--{org}.hlx.page/my-page?campaign=xmas]() +- [https://{ref}--{repo}--{org}.hlx.page/my-page?utm_campaign=xmas]() + +would essentially serve you the `xmas` variant of the experience. + +## Authoring + +Once the above steps are done, your authors are ready to start using campaigns for their experiences. + +### Page-level campaigns + +Each Page can have several page-level campaigns defined in the page metadata. +The campaigns are set up directly in the page metadata block as follows: + +| Metadata | | +|---------------------|-----------------------------------------------------------------| +| Campaign: Xmas | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-xmas]() | +| Campaign: Halloween | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-halloween]() | + +The notation is pretty flexible and authors can also use `Campaign (Xmas)` or `Campaign Halloween` if this is a preferred notation. + +If you wanted to additionally restrict the campaign to specific audiences, so that for instance your campaigns are only accessible on mobile phone or on iPhone, you'd leverage the [audiences](Audiences) feature and use the following metadata: + +| Metadata | | +|---------------------|-----------------------------------------------------------------| +| Campaign: Xmas | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-xmas]() | +| Campaign: Halloween | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-halloween]() | +| Campaign Audience | mobile, iphone | + +If any of the listed audiences is resolved, then the campaign will run and the matching content will be served. +If you needed both audiences to be resolved, you'd define a new `mobile-iphone` audience in your project and use that in the metadata instead. + +#### Page Redirect + +If you aim to fully direct a campaign page to a target URL instead of just replacing the content, you can do so by adding the `Campaign Resolution | redirect` property to the page metadata: + +| Metadata | | +|----------------------|-----------------------------------------------------------------| +| Campaign: Xmas | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-xmas]() | +| Campaign: Halloween | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-halloween]() | +| Campaign Resolution | redirect | + +### Section-level audiences + +Each section in a page can also run any number of campaigns. Section-level campaigns are run after the page-level campaigns have run, i.e. after the variants have been processed and their markup was pulled into the main page, so the section-level campaigns that will run are dictated by the document from the current page-level experiment/audience/campaign, and not necessarily just the main page. + +Section-level campaigns are authored essentially the same way that page-level campaigns are, but leverage the `Section Metadata` block instead: + +| Section Metadata | | +|---------------------|-----------------------------------------------------------------| +| Campaign: Xmas | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-xmas]() | +| Campaign: Halloween | [https://{ref}--{repo}--{org}.hlx.page/my-page-for-halloween]() | + +### Fragment-level campaigns + +Fragment-level campaigns are handled differently than page and section-level campaigns. They target a specific CSS selector instead of the whole page or the section. Whenever the desired CSS `selector` is resolved in the DOM tree (i.e. whenever the element is added to the page), the campaigns will be run. For AEM, this typically happens even before the `decorate` method from the block's JS file is run. + +Fragment-level campaigns are also authored differently than page and section-level campaigns. First, you need to specify a new metadata entry: + +| Metadata | | +|-------------------|------------------------------------------------------------------------| +| Campaign Manifest | [https://{ref}--{repo}--{org}.hlx.page/my-campaigns.json?sheet=2024]() | + +The spreadsheet then needs to be defined as follows: + +| Page | Campaign | Selector | Url | +|-----------|--------------|----------|---------------------------------| +| /my-page/ | XMas | .hero | /fragments/my-page-hero-xmas | +| /my-page/ | ThanksGiving | .hero | /fragments/my-page-hero-thxgivn | + +The same spreadsheet can also contain the configuration for several pages at once. The engine will filter out the entries in the spreadsheet that match the current page. + +### 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 campaigns have been configured for the page and switch between each to visualize the content variations accordingly. + +![audience overlay](./images/campaigns-overlay.png) + +## Development + +To help developers in designing variants for each campaign, when a campaign is resolved on the page it will automatically add a new CSS class named `campaign-` to the `` element, i.e. `campaign-xmas`. diff --git a/plugins/experimentation/documentation/experiments.md b/plugins/experimentation/documentation/experiments.md new file mode 100644 index 00000000..bf2b0acc --- /dev/null +++ b/plugins/experimentation/documentation/experiments.md @@ -0,0 +1,200 @@ +# Running A/B tests to compare variants of an experience + +## Overview + +With experiments (also called A/B tests) you can randomly serve different versions of your content to your end users to test out alternate experiences or validate conversion hypotheses. For instance, you can: +- compare how the wording in a hero block impacts the conversion on the call-to-action element +- compare how 2 different implementations of a specific block impact the overall performance, engagement and/or user conversion + +## Set up + +The setup is pretty minimal. Once you've instrumented the experimentation plugin in your AEM website, you are essentially good to go. + +Just keep in mind that if you want to only target specific audiences for that experiment, you'll also need to set up the [audiences](Audiences) accordingly for your project. + +### Custom options + +By default, the experiments feature looks at the `Experiment` metadata tags and the `experiment` query parameter, but if this clashes with your existing codebase or doesn't feel intuitive to your authors, you can adjust this by passing new options to the plugin. + +For instance, here is an alternate configuration that would use `abtest` instead of `experiment`: +```js +const { loadEager } = await import('../plugins/experimentation/src/index.js'); +await loadEager(document, { + experimentsMetaTagPrefix: 'abtest', + experimentsQueryParameter: 'abtest', +}); +``` + +### Tracking custom conversion events + +By default, the engine will consider any `click` a conversion. If you want to be more granular in your tests, the plugin integrates with the https://github.com/adobe/aem-rum-conversion plugin as well and will automatically detect its `Conversion Name` property (for both page level and section level metadata). + +## Authoring + +Once the above steps are done, your authors are ready to start using experiments for their experiences. + +### Page-level experiments + +Each Page can have 1 page-level experiment that is controlled via the page metadata. +The experiment is set up directly in the page metadata block as follows: + +| Metadata | | +|---------------------|--------------------------------------------------------------| +| Experiment | Hero Test | +| Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | + +The page that is instrumented is called the `control`, and the content variations are `variants` or `challengers`. +Variants are evenly split by default, so the above would give us: +- 25% for the control (the page that has the metadata) +- 25% for each of the 3 variants that are defined + +If you want to control the split ratio, you can use: + +| Metadata | | +|---------------------|--------------------------------------------------------------| +| Experiment | Hero Test | +| Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | +| Experiment Split | 10, 20, 30 | + +This would give us the following split: + +- 10% on variant 1 +- 20% on variant 2 +- 30% on variant 3 +- 40% on the control (i.e 100% - 10% - 20% - 30% = 40%) + +A `30, 30, 40` split, would respectively give us: + +- 30% on variant 1 +- 30% on variant 2 +- 40% on variant 3 +- 0% on the control (i.e 100% - 30% - 30% - 40% = 0%) + +which would essentially disable the control page. + +If you need to be really granular, decimal numbers are also supported, like `33.34, 33.33, 33.33`. + +#### Custom Variant Labels +This feature allows authors to specify custom labels for both page and section experiment variants via metadata. While the internal variant IDs remain in a predefined format (e.g., challenger-1, challenger-2), the labels in the overlay pill can be customized to provide more meaningful names. + +To customize the labels, add a new entry in the page metadata or section metadata(below takes page metadata as an example): + +| Metadata | | +|---------------------|--------------------------------------------------------------| +| Experiment | Hero Test | +| Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | +| Experiment Variant Names | foo1, foo2, foo3 | + +The names defined will match with the corresponding variants in sequence. If the number of names provided is less than the number of variants, the default naming will be applied for the remaining variants. + +#### Code-level experiments + +Note that the above assumes you have different content variants to serve, but if you want to run a pure code-based A/B Test, this is also achievable via: + +| Metadata | | +|---------------------|-----------| +| Experiment | Hero Test | +| Experiment Variants | 2 | + +This will create just 2 variants, without touching the content, and you'll be able to target those based on the `experiment-hero-test` and `variant-control`/`variant-challenger-1`/`variant-challenger-2` CSS classes that will be set on the `` element + +#### Audience-based experiments + +If you wanted to additionally restrict the experiments to specific audiences, so that for instance your experiment is only run on iPad or on iPhone, you'd leverage the [audiences](Audiences) feature and use the following metadata: + +| Metadata | | +|---------------------|--------------------------------------------------------------| +| Experiment | Hero Test | +| Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | +| Experiment Audience | iPad, iPhone | + +If any of the listed audiences is resolved, then the experiment will run and the matching content will be served. The list is essentially treated as an "or". +If you needed both audiences to be resolved (i.e. treated as "and"), for say a "US" audience and the "iPad" audience, you'd define a new custom "us-ipad" audience in your project that checks for both conditions and use that in the metadata instead. + +#### Time bound experiments + +You can also specify start and end dates, as well as toggle the experiment status. + +| Metadata | | +|-----------------------|--------------------------------------------------------------| +| Experiment | Hero Test | +| Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | +| Experiment Status | Active | +| Experiment Start Date | 2024-01-01 | +| Experiment End Date | 2024-03-31 | + +The status defaults to `Active`, and supports `Active`/`True`/`On` as well as `Inactive`/`False`/`Off`. +Start and end dates are in the flexible JS [Date Time String Format](https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-date-time-string-format). If the start and/or end dates are specified, the experiment will not run if the user's time lies outside the given window. + +So you can both use generic dates, like `2024-01-31` or `2024/01/31`, and time-specific dates like `2024-01-31T13:37` or `2024/01/31 1:37 pm`. You can even enforce a specific timezone so your experiment activates when, say, it's 2am GMT+1 by using `2024/1/31 2:00 pm GMT+1` or similar notations. + +#### Redirect page experiments +For the use case that fully redirect to the target URL instead of just replacing the content (our default behavior), you could add a new property `Experiment Resolution | redirect` in page metadata: +| Metadata | | +|-----------------------|--------------------------------------------------------------| +| Experiment | Hero Test | +| Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | +| Experiment Resolution | redirect + +In this example, the Hero Test experiment will redirect to one of the specified URLs based on the selected variant. + +Similarly, the redirects for audience personalization and campaign personalization could be enabled by adding: + +`Audience Resolution | redirect` +`Campaign Resolution | redirect` + +For more details, refer to: [audiences](./audiences.md#page-redirect), [campaigns](./campaigns.md#page-redirect) + +### Section-level experiments + +Each section in a page can also run 1 experiment, so you can have as many section-level experiments as you have sections. +Section-level experiments are run after the page-level experiment has run, i.e. after the variant has been processed and its markup pulled into the main page, so the section-level experiments that will run are dictated by the document from the current page-level experiment/audience/campaign, and not necessarily just the control page. + +Section-level experiments are authored essentially the same way that page-level experiments are, but leverage the `Section Metadata` block instead: + +| Section Metadata | | +|---------------------|--------------------------------------------------------------| +| Experiment | Hero Test | +| Experiment Variants | [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-1](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-2](), [https://{ref}--{repo}--{org}.hlx.page/my-page-variant-3]() | + +### Fragment-level experiments + +Fragment-level experiments are handled differently than page and section-level experiments. You can essentially have as many you want and they can target any CSS selector. Whenever the desired CSS `selector` is resolved in the DOM tree (i.e. whenever the element is added to the page), the experiment will be run. For AEM, this typically happens even before the `decorate` method from the block's JS file is run. + +Fragment-level experiments are also authored differently than page and section-level experiments. First, you need to specify a new metadata entry: + +| Metadata | | +|---------------------|-------------------------------------------------------------------------------| +| Experiment Manifest | [https://{ref}--{repo}--{org}.hlx.page/my-experiments.json?sheet=hero-test]() | + +The spreadsheet then needs to be defined as follows: + +| Page | Experiment | Variant | Selector | Url | +|-----------|------------|--------------|----------|-----------------------------------| +| /my-page/ | Hero Test | Challenger 1 | .hero | /fragments/my-page-hero-variant-1 | +| /my-page/ | Hero Test | Challenger 2 | .hero | /fragments/my-page-hero-variant-2 | +| /my-page/ | Hero Test | Challenger 3 | .hero | /fragments/my-page-hero-variant-3 | + +Like with regular experiments, you can also define the `Split`, `Start Date`, `End Date`, etc. + +The same spreadsheet can also contain the configuration for several pages at once. The engine will filter out the entries in the spreadsheet that match the current page + +### 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. + +![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. + +### Inline Reporting + +AEM Experiments performance is automatically tracked via RUM data, and can be reported directly in the overlay pill UI. Since the RUM data is not public, you'll need to obtain a **domain key** for your website and configure the pill accordingly for the data to show up. + +1. Generate a domain key for your site using https://aemcs-workspace.adobe.com/rum/generate-domain-key (make sure to use exactly the same domain name that you used in your project config for go-live) +2. Click the ⚙️ icon in the pill header, and paste the provided domain key in the popup dialog + - alternatively, you can also just run `window.localStorage.setItem('aem-domainkey', )` in the JS console + +## Development + +To help developers in designing variants for each experiment, when an experiment is running on the page it will automatically add new CSS classes named `experiment-` and `variant-` to the `` element, i.e. `experiment-hero variant-fullpage`. diff --git a/plugins/experimentation/documentation/images/audiences-overlay.png b/plugins/experimentation/documentation/images/audiences-overlay.png new file mode 100644 index 00000000..7788a28d Binary files /dev/null and b/plugins/experimentation/documentation/images/audiences-overlay.png differ diff --git a/plugins/experimentation/documentation/images/campaigns-overlay.png b/plugins/experimentation/documentation/images/campaigns-overlay.png new file mode 100644 index 00000000..c2b41cd7 Binary files /dev/null and b/plugins/experimentation/documentation/images/campaigns-overlay.png differ diff --git a/plugins/experimentation/documentation/images/experiments-overlay.png b/plugins/experimentation/documentation/images/experiments-overlay.png new file mode 100644 index 00000000..a8e19ae1 Binary files /dev/null and b/plugins/experimentation/documentation/images/experiments-overlay.png differ diff --git a/plugins/experimentation/package.json b/plugins/experimentation/package.json index 97fb757c..75ba0520 100644 --- a/plugins/experimentation/package.json +++ b/plugins/experimentation/package.json @@ -1,11 +1,13 @@ { "name": "@adobe/aem-experimentation", - "version": "1.0.0", + "version": "1.0.1", "main": "src/index.js", "scripts": { - "lint:js": "eslint src", - "lint:css": "stylelint src/**/*.css", - "lint": "npm run lint:js && npm run lint:css" + "lint:js": "eslint src tests", + "lint:css": "stylelint src/**/*.css --allow-empty-input", + "lint": "npm run lint:js && npm run lint:css", + "start": "http-server . -p 3000", + "test": "playwright test" }, "repository": { "type": "git", @@ -28,10 +30,18 @@ "homepage": "https://github.com/adobe/aem-experimentation#readme", "devDependencies": { "@babel/eslint-parser": "7.22.15", + "@playwright/test": "1.44.0", + "@semantic-release/changelog": "6.0.3", + "@semantic-release/git": "10.0.1", + "@semantic-release/npm": "12.0.1", "eslint": "8.48.0", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-import": "2.28.1", + "http-server": "14.1.1", + "monocart-coverage-reports": "2.8.2", + "semantic-release": "23.1.1", "stylelint": "15.10.3", "stylelint-config-standard": "34.0.0" - } -} + }, + "private": true +} \ No newline at end of file diff --git a/plugins/experimentation/playwright.config.js b/plugins/experimentation/playwright.config.js new file mode 100644 index 00000000..b3a6d4a2 --- /dev/null +++ b/plugins/experimentation/playwright.config.js @@ -0,0 +1,54 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? 'line' : 'html', + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://127.0.0.1:3000/', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + globalSetup: './tests/setup.js', + globalTeardown: './tests/teardown.js', + + /* Configure the tests for the top 3 browsers that access the site */ + projects: [ + { + name: 'Desktop Chrome', + use: { ...devices['Desktop Chrome'] }, + }, + ], + + timeout: 10000, + + // Run your local dev server before starting the tests + webServer: { + command: 'npm run start', + url: 'http://127.0.0.1:3000', + reuseExistingServer: !process.env.CI, + stdout: 'ignore', + stderr: 'pipe', + }, +}); diff --git a/plugins/experimentation/src/index.js b/plugins/experimentation/src/index.js index 5ee2136a..8c387a63 100644 --- a/plugins/experimentation/src/index.js +++ b/plugins/experimentation/src/index.js @@ -1,5 +1,5 @@ /* - * Copyright 2022 Adobe. All rights reserved. + * 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 @@ -9,11 +9,27 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -const MAX_SAMPLING_RATE = 10; // At a maximum we sample 1 in 10 requests + +let isDebugEnabled; +export function setDebugMode(url, pluginOptions) { + const { host, hostname, origin } = url; + const { isProd, prodHost } = pluginOptions; + isDebugEnabled = (url.hostname === 'localhost' + || url.hostname.endsWith('.page') + || (typeof isProd === 'function' && !isProd()) + || (prodHost && ![host, hostname, origin].includes(prodHost)) + || false); + return isDebugEnabled; +} + +export function debug(...args) { + if (isDebugEnabled) { + // eslint-disable-next-line no-console + console.debug.call(this, '[aem-experimentation]', ...args); + } +} export const DEFAULT_OPTIONS = { - // Generic properties - rumSamplingRate: MAX_SAMPLING_RATE, // 1 in 10 requests // Audiences related properties audiences: {}, @@ -25,60 +41,200 @@ export const DEFAULT_OPTIONS = { campaignsQueryParameter: 'campaign', // Experimentation related properties - experimentsRoot: '/experiments', - experimentsConfigFile: 'manifest.json', - experimentsMetaTag: 'experiment', + experimentsMetaTagPrefix: 'experiment', experimentsQueryParameter: 'experiment', + + // Redecoration function for fragments + decorateFunction: () => {}, }; /** - * Checks if the current engine is detected as being a bot. - * @returns `true` if the current engine is detected as being, `false` otherwise + * Converts a given comma-seperate string to an array. + * @param {String|String[]} str The string to convert + * @returns an array representing the converted string */ -function isBot() { - return navigator.userAgent.match(/bot|crawl|spider/i); +export function stringToArray(str) { + if (Array.isArray(str)) { + return str; + } + return str ? str.split(/[,\n]/).filter((s) => s.trim()) : []; } /** - * Checks if any of the configured audiences on the page can be resolved. - * @param {string[]} applicableAudiences a list of configured audiences for the page - * @param {object} options the plugin options - * @returns Returns the names of the resolved audiences, or `null` if no audience is configured + * Sanitizes a name for use as class name. + * @param {String} name The unsanitized name + * @returns {String} The class name */ -export async function getResolvedAudiences(applicableAudiences, options, context) { - if (!applicableAudiences.length || !Object.keys(options.audiences).length) { - return null; - } - // If we have a forced audience set in the query parameters (typically for simulation purposes) - // we check if it is applicable +export function toClassName(name) { + return typeof name === 'string' + ? name.toLowerCase().replace(/[^0-9a-z]/gi, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') + : ''; +} + +/** + * Fires a Real User Monitoring (RUM) event based on the provided type and configuration. + * @param {string} type - the type of event to be fired ("experiment", "campaign", or "audience") + * @param {Object} config - contains details about the experience + * @param {Object} pluginOptions - default plugin options with custom options + * @param {string} result - the URL of the served experience. + */ +function fireRUM(type, config, pluginOptions, result) { + const { selectedCampaign = 'default', selectedAudience = 'default' } = config; + + const typeHandlers = { + experiment: () => ({ + source: config.id, + target: result ? config.selectedVariant : config.variantNames[0], + }), + campaign: () => ({ + source: result ? toClassName(selectedCampaign) : 'default', + target: Object.keys(pluginOptions.audiences).join(':'), + }), + audience: () => ({ + source: result ? toClassName(selectedAudience) : 'default', + target: Object.keys(pluginOptions.audiences).join(':'), + }), + }; + + const { source, target } = typeHandlers[type](); + const rumType = type === 'experiment' ? 'experiment' : 'audience'; + window.hlx?.rum?.sampleRUM(rumType, { source, target }); +} + +/** + * Sanitizes a name for use as a js property name. + * @param {String} name The unsanitized name + * @returns {String} The camelCased name + */ +export function toCamelCase(name) { + return toClassName(name).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); +} + +/** + * Removes all leading hyphens from a string. + * @param {String} after the string to remove the leading hyphens from, usually is colon + * @returns {String} The string without leading hyphens + */ +export function removeLeadingHyphens(inputString) { + // Remove all leading hyphens which are converted from the space in metadata + return inputString.replace(/^(-+)/, ''); +} + +/** + * Retrieves the content of metadata tags. + * @param {String} name The metadata name (or property) + * @returns {String} The metadata value(s) + */ +export function getMetadata(name) { + const meta = [...document.head.querySelectorAll(`meta[name="${name}"]`)].map((m) => m.content).join(', '); + return meta || ''; +} + +/** + * Gets all the metadata elements that are in the given scope. + * @param {String} scope The scope/prefix for the metadata + * @returns a map of key/value pairs for the given scope + */ +export function getAllMetadata(scope) { + const value = getMetadata(scope); + const metaTags = document.head.querySelectorAll(`meta[name^="${scope}"], meta[property^="${scope}:"]`); + return [...metaTags].reduce((res, meta) => { + const key = removeLeadingHyphens( + meta.getAttribute('name') + ? meta.getAttribute('name').substring(scope.length) + : meta.getAttribute('property').substring(scope.length + 1), + ); + + const camelCaseKey = toCamelCase(key); + res[camelCaseKey] = meta.getAttribute('content'); + return res; + }, value ? { value } : {}); +} + +/** + * Gets all the data attributes that are in the given scope. + * @param {String} scope The scope/prefix for the metadata + * @returns a map of key/value pairs for the given scope + */ +// eslint-disable-next-line no-unused-vars +function getAllDataAttributes(el, scope) { + return el.getAttributeNames() + .filter((attr) => attr === `data-${scope}` || attr.startsWith(`data-${scope}-`)) + .reduce((res, attr) => { + const key = attr === `data-${scope}` ? 'value' : attr.replace(`data-${scope}-`, ''); + res[key] = el.getAttribute(attr); + return res; + }, {}); +} + +/** + * Gets all the query parameters that are in the given scope. + * @param {String} scope The scope/prefix for the metadata + * @returns a map of key/value pairs for the given scope + */ +function getAllQueryParameters(scope) { const usp = new URLSearchParams(window.location.search); - const forcedAudience = usp.has(options.audiencesQueryParameter) - ? context.toClassName(usp.get(options.audiencesQueryParameter)) - : null; - if (forcedAudience) { - return applicableAudiences.includes(forcedAudience) ? [forcedAudience] : []; - } + return [...usp.entries()] + .filter(([param]) => param === scope || param.startsWith(`${scope}-`)) + .reduce((res, [param, value]) => { + const key = param === scope ? 'value' : param.replace(`${scope}-`, ''); + if (res[key]) { + res[key] = [].concat(res[key], value); + } else { + res[key] = value; + } + return res; + }, {}); +} - // Otherwise, return the list of audiences that are resolved on the page - const results = await Promise.all( - applicableAudiences - .map((key) => { - if (options.audiences[key] && typeof options.audiences[key] === 'function') { - return options.audiences[key](); +/** + * Extracts the config from a block that is in the given scope. + * @param {HTMLElement} block The block element + * @returns a map of key/value pairs for the given scope + */ +// eslint-disable-next-line import/prefer-default-export +function getAllSectionMeta(block, scope) { + const config = {}; + block.querySelectorAll(':scope > div').forEach((row) => { + if (row.children) { + const cols = [...row.children]; + if (cols[1]) { + const col = cols[1]; + let key = toClassName(cols[0].textContent); + if (key !== scope && !key.startsWith(`${scope}-`)) { + return; } - return false; - }), - ); - return applicableAudiences.filter((_, i) => results[i]); + key = key === scope ? 'value' : key.replace(`${scope}-`, ''); + let value = ''; + if (col.querySelector('a')) { + const as = [...col.querySelectorAll('a')]; + if (as.length === 1) { + value = as[0].href; + } else { + value = as.map((a) => a.href); + } + } else if (col.querySelector('p')) { + const ps = [...col.querySelectorAll('p')]; + if (ps.length === 1) { + value = ps[0].textContent; + } else { + value = ps.map((p) => p.textContent); + } + } else value = row.children[1].textContent; + config[key] = value; + } + } + }); + return config; } /** * Replaces element with content from path - * @param {string} path - * @param {HTMLElement} main + * @param {String} path + * @param {HTMLElement} el * @return Returns the path that was loaded or null if the loading failed */ -async function replaceInner(path, main) { +async function replaceInner(path, el, selector) { try { const resp = await fetch(path); if (!resp.ok) { @@ -89,9 +245,15 @@ async function replaceInner(path, main) { const html = await resp.text(); // parse with DOMParser to guarantee valid HTML, and no script execution(s) const dom = new DOMParser().parseFromString(html, 'text/html'); - // do not use replaceWith API here since this would replace the main reference - // in scripts.js as well and prevent proper decoration of the sections/blocks - main.innerHTML = dom.querySelector('main').innerHTML; + // eslint-disable-next-line no-param-reassign + let newEl; + if (selector) { + newEl = dom.querySelector(selector); + } + if (!newEl) { + newEl = dom.querySelector(el.tagName === 'MAIN' ? 'main' : 'main > div'); + } + el.innerHTML = newEl.innerHTML; return path; } catch (e) { // eslint-disable-next-line no-console @@ -101,93 +263,36 @@ async function replaceInner(path, main) { } /** - * Parses the experimentation configuration sheet and creates an internal model. - * - * Output model is expected to have the following structure: - * { - * id: , - * label: , - * blocks: , - * audiences: [], - * status: Active | Inactive, - * variantNames: [], - * variants: { - * [variantName]: { - * label: - * percentageSplit: , - * pages: , - * blocks: , - * } - * } - * }; + * Checks if any of the configured audiences on the page can be resolved. + * @param {String[]} pageAudiences a list of configured audiences for the page + * @param {Object} options the plugin options + * @returns Returns the names of the resolved audiences, or `null` if no audience is configured */ -function parseExperimentConfig(json, context) { - const config = {}; - try { - json.settings.data.forEach((line) => { - const key = context.toCamelCase(line.Name); - if (key === 'audience' || key === 'audiences') { - config.audiences = line.Value ? line.Value.split(',').map((str) => str.trim()) : []; - } else if (key === 'experimentName') { - config.label = line.Value; - } else { - config[key] = line.Value; - } - }); - const variants = {}; - let variantNames = Object.keys(json.experiences.data[0]); - variantNames.shift(); - variantNames = variantNames.map((vn) => context.toCamelCase(vn)); - variantNames.forEach((variantName) => { - variants[variantName] = {}; - }); - let lastKey = 'default'; - json.experiences.data.forEach((line) => { - let key = context.toCamelCase(line.Name); - if (!key) key = lastKey; - lastKey = key; - const vns = Object.keys(line); - vns.shift(); - vns.forEach((vn) => { - const camelVN = context.toCamelCase(vn); - if (key === 'pages' || key === 'blocks') { - variants[camelVN][key] = variants[camelVN][key] || []; - if (key === 'pages') variants[camelVN][key].push(new URL(line[vn]).pathname); - else variants[camelVN][key].push(line[vn]); - } else { - variants[camelVN][key] = line[vn]; - } - }); - }); - config.variants = variants; - config.variantNames = variantNames; - return config; - } catch (e) { - // eslint-disable-next-line no-console - console.log('error parsing experiment config:', e, json); +export async function getResolvedAudiences(pageAudiences, options) { + if (!pageAudiences.length || !Object.keys(options.audiences).length) { + return null; } - return null; -} - -/** - * Checks if the given config is a valid experimentation configuration. - * @param {object} config the config to check - * @returns `true` if it is valid, `false` otherwise - */ -export function isValidExperimentationConfig(config) { - if (!config.variantNames - || !config.variantNames.length - || !config.variants - || !Object.values(config.variants).length - || !Object.values(config.variants).every((v) => ( - typeof v === 'object' - && !!v.blocks - && !!v.pages - && (v.percentageSplit === '' || !!v.percentageSplit) - ))) { - return false; + // If we have a forced audience set in the query parameters (typically for simulation purposes) + // we check if it is applicable + const usp = new URLSearchParams(window.location.search); + const forcedAudience = usp.has(options.audiencesQueryParameter) + ? toClassName(usp.get(options.audiencesQueryParameter)) + : null; + if (forcedAudience) { + return pageAudiences.includes(forcedAudience) ? [forcedAudience] : []; } - return true; + + // Otherwise, return the list of audiences that are resolved on the page + const results = await Promise.all( + pageAudiences + .map((key) => { + if (options.audiences[key] && typeof options.audiences[key] === 'function') { + return options.audiences[key](); + } + return false; + }), + ); + return pageAudiences.filter((_, i) => results[i]); } /** @@ -211,124 +316,17 @@ function inferEmptyPercentageSplits(variants) { if (variantsWithoutPercentage.length) { const missingPercentage = remainingPercentage / variantsWithoutPercentage.length; variantsWithoutPercentage.forEach((v) => { - v.percentageSplit = missingPercentage.toFixed(2); + v.percentageSplit = missingPercentage.toFixed(4); }); } } /** - * Gets experiment config from the metadata. - * - * @param {string} experimentId The experiment identifier - * @param {string} instantExperiment The list of varaints - * @returns {object} the experiment manifest + * Converts the experiment config to a decision policy + * @param {Object} config The experiment config + * @returns a decision policy for the experiment config */ -function getConfigForInstantExperiment( - experimentId, - instantExperiment, - pluginOptions, - context, -) { - const audience = context.getMetadata(`${pluginOptions.experimentsMetaTag}-audience`); - const config = { - label: `Instant Experiment: ${experimentId}`, - audiences: audience ? audience.split(',').map(context.toClassName) : [], - status: context.getMetadata(`${pluginOptions.experimentsMetaTag}-status`) || 'Active', - startDate: context.getMetadata(`${pluginOptions.experimentsMetaTag}-start-date`), - endDate: context.getMetadata(`${pluginOptions.experimentsMetaTag}-end-date`), - id: experimentId, - variants: {}, - variantNames: [], - }; - - const nbOfVariants = Number(instantExperiment); - const pages = Number.isNaN(nbOfVariants) - ? instantExperiment.split(',').map((p) => new URL(p.trim(), window.location).pathname) - : new Array(nbOfVariants).fill(window.location.pathname); - - const splitString = context.getMetadata(`${pluginOptions.experimentsMetaTag}-split`); - const splits = splitString - // custom split - ? splitString.split(',').map((i) => parseInt(i, 10) / 100) - // even split fallback - : [...new Array(pages.length)].map(() => 1 / (pages.length + 1)); - - config.variantNames.push('control'); - config.variants.control = { - percentageSplit: '', - pages: [window.location.pathname], - blocks: [], - label: 'Control', - }; - - pages.forEach((page, i) => { - const vname = `challenger-${i + 1}`; - config.variantNames.push(vname); - config.variants[vname] = { - percentageSplit: `${splits[i].toFixed(2)}`, - pages: [page], - blocks: [], - label: `Challenger ${i + 1}`, - }; - }); - inferEmptyPercentageSplits(Object.values(config.variants)); - return (config); -} - -/** - * Gets experiment config from the manifest and transforms it to more easily - * consumable structure. - * - * the manifest consists of two sheets "settings" and "experiences", by default - * - * "settings" is applicable to the entire test and contains information - * like "Audience", "Status" or "Blocks". - * - * "experience" hosts the experiences in rows, consisting of: - * a "Percentage Split", "Label" and a set of "Links". - * - * - * @param {string} experimentId The experiment identifier - * @param {object} pluginOptions The plugin options - * @returns {object} containing the experiment manifest - */ -async function getConfigForFullExperiment(experimentId, pluginOptions, context) { - let path; - if (experimentId.includes(`/${pluginOptions.experimentsConfigFile}`)) { - path = new URL(experimentId, window.location.origin).href; - // eslint-disable-next-line no-param-reassign - [experimentId] = path.split('/').splice(-2, 1); - } else { - path = `${pluginOptions.experimentsRoot}/${experimentId}/${pluginOptions.experimentsConfigFile}`; - } - try { - const resp = await fetch(path); - if (!resp.ok) { - // eslint-disable-next-line no-console - console.log('error loading experiment config:', resp); - return null; - } - const json = await resp.json(); - const config = pluginOptions.parser - ? pluginOptions.parser(json, context) - : parseExperimentConfig(json, context); - if (!config) { - return null; - } - config.id = experimentId; - config.manifest = path; - config.basePath = `${pluginOptions.experimentsRoot}/${experimentId}`; - inferEmptyPercentageSplits(Object.values(config.variants)); - config.status = context.getMetadata(`${pluginOptions.experimentsMetaTag}-status`) || config.status; - return config; - } catch (e) { - // eslint-disable-next-line no-console - console.log(`error loading experiment manifest: ${path}`, e); - } - return null; -} - -function getDecisionPolicy(config) { +function toDecisionPolicy(config) { const decisionPolicy = { id: 'content-experimentation-policy', rootDecisionNodeId: 'n1', @@ -349,364 +347,625 @@ function getDecisionPolicy(config) { return decisionPolicy; } -async function getConfig(experiment, instantExperiment, pluginOptions, context) { - const usp = new URLSearchParams(window.location.search); - const [forcedExperiment, forcedVariant] = usp.has(pluginOptions.experimentsQueryParameter) - ? usp.get(pluginOptions.experimentsQueryParameter).split('/') - : []; +/** + * Creates an instance of a modification handler that will be responsible for applying the desired + * personalized experience. + * + * @param {String} type The type of modifications to apply + * @param {Object} overrides The config overrides + * @param {Function} metadataToConfig a function that will handle the parsing of the metadata + * @param {Function} getExperienceUrl a function that returns the URL to the experience + * @param {Object} pluginOptions the plugin options + * @param {Function} cb the callback to handle the final steps + * @returns the modification handler + */ +function createModificationsHandler( + type, + overrides, + metadataToConfig, + getExperienceUrl, + pluginOptions, + cb, +) { + return async (el, metadata) => { + const config = await metadataToConfig(pluginOptions, metadata, overrides); + if (!config) { + return null; + } + const ns = { config, el }; + const url = await getExperienceUrl(ns.config); + let res; + if (url && new URL(url, window.location.origin).pathname !== window.location.pathname) { + if (toClassName(metadata?.resolution) === 'redirect') { + // Firing RUM event early since redirection will stop the rest of the JS execution + fireRUM(type, config, pluginOptions, url); + window.location.replace(url); + // eslint-disable-next-line consistent-return + return; + } + // eslint-disable-next-line no-await-in-loop + res = await replaceInner(new URL(url, window.location.origin).pathname, el); + } else { + res = url; + } + cb(el.tagName === 'MAIN' ? document.body : ns.el, ns.config, res ? url : null); + if (res) { + ns.servedExperience = url; + } + return ns; + }; +} - const experimentConfig = instantExperiment - ? await getConfigForInstantExperiment(experiment, instantExperiment, pluginOptions, context) - : await getConfigForFullExperiment(experiment, pluginOptions, context); +/** + * Rename plural properties on the object to singular. + * @param {Object} obj The object + * @param {String[]} props The properties to rename. + * @returns the object with plural properties renamed. + */ +function depluralizeProps(obj, props = []) { + props.forEach((prop) => { + if (obj[`${prop}s`]) { + obj[prop] = obj[`${prop}s`]; + delete obj[`${prop}s`]; + } + }); + return obj; +} - // eslint-disable-next-line no-console - console.debug(experimentConfig); - if (!experimentConfig) { - return null; +/** + * Fetch the configuration entries from a JSON manifest. + * @param {String} urlString the URL to load + * @returns the list of entries that apply to the current page + */ +async function getManifestEntriesForCurrentPage(urlString) { + try { + const url = new URL(urlString, window.location.origin); + const response = await fetch(url.pathname); + const json = await response.json(); + return json.data + .map((entry) => Object.keys(entry).reduce((res, k) => { + res[k.toLowerCase()] = entry[k]; + return res; + }, {})) + .filter((entry) => (!entry.page && !entry.pages) + || entry.page === window.location.pathname + || entry.pages === window.location.pathname) + .filter((entry) => entry.selector || entry.selectors) + .filter((entry) => entry.url || entry.urls) + .map((entry) => depluralizeProps(entry, ['page', 'selector', 'url'])); + } catch (err) { + // eslint-disable-next-line no-console + console.warn('Cannot apply manifest: ', urlString, err); + } + return null; +} + +/** + * Watches the page for injected DOM elements and automatically applies the fragment customizations + */ +function watchMutationsAndApplyFragments( + ns, + scope, + entries, + aggregator, + getExperienceUrl, + pluginOptions, + metadataToConfig, + overrides, + cb, +) { + if (!entries.length) { + return; } - const forcedAudience = usp.has(pluginOptions.audiencesQueryParameter) - ? context.toClassName(usp.get(pluginOptions.audiencesQueryParameter)) - : null; + new MutationObserver(async (_, observer) => { + // eslint-disable-next-line no-restricted-syntax + for (const entry of entries) { + // eslint-disable-next-line no-await-in-loop + const config = await metadataToConfig(pluginOptions, entry, overrides); + if (!config || entry.isApplied) { + return; + } + const el = scope.querySelector(entry.selector); + if (!el) { + return; + } + entry.isApplied = true; + const fragmentNS = { config, el, type: 'fragment' }; + // eslint-disable-next-line no-await-in-loop + const url = await getExperienceUrl(fragmentNS.config); + let res; + if (url && new URL(url, window.location.origin).pathname !== window.location.pathname) { + // eslint-disable-next-line no-await-in-loop + res = await replaceInner(new URL(url, window.location.origin).pathname, el, entry.selector); + // eslint-disable-next-line no-await-in-loop + await pluginOptions.decorateFunction(el); + } else { + res = url; + } + cb(el.tagName === 'MAIN' ? document.body : fragmentNS.el, fragmentNS.config, res ? url : null); + if (res) { + fragmentNS.servedExperience = url; + } + debug('fragment', ns, fragmentNS); + aggregator.push(fragmentNS); + } + if (entries.every((entry) => entry.isApplied)) { + observer.disconnect(); + } + }).observe(scope, { childList: true, subtree: true }); +} - experimentConfig.resolvedAudiences = await getResolvedAudiences( - experimentConfig.audiences.map(context.toClassName), +/** + * Apply the page modifications for the specified type. + * + * @param {String} ns the type of modifications to do + * @param {String} paramNS the query parameter namespace + * @param {Object} pluginOptions the plugin options + * @param {Function} metadataToConfig a function that will handle the parsing of the metadata + * @param {Function} manifestToConfig a function that will handle the parsing of the manifest + * @param {Function} getExperienceUrl a function that returns the URL to the experience + * @param {Function} cb the callback to handle the final steps + * @returns an object containing the details of the page modifications that where applied + */ +async function applyAllModifications( + type, + paramNS, + pluginOptions, + metadataToConfig, + manifestToConfig, + getExperienceUrl, + cb, +) { + const modificationsHandler = createModificationsHandler( + type, + getAllQueryParameters(paramNS), + metadataToConfig, + getExperienceUrl, pluginOptions, - context, - ); - experimentConfig.run = ( - // experiment is active or forced - (['active', 'on', 'true'].includes(context.toClassName(experimentConfig.status)) || forcedExperiment) - // experiment has resolved audiences if configured - && (!experimentConfig.resolvedAudiences || experimentConfig.resolvedAudiences.length) - // forced audience resolves if defined - && (!forcedAudience || experimentConfig.audiences.includes(forcedAudience)) - && (!experimentConfig.startDate || new Date(experimentConfig.startDate) <= Date.now()) - && (!experimentConfig.endDate || new Date(experimentConfig.endDate) > Date.now()) + cb, ); - window.hlx = window.hlx || {}; - window.hlx.experiment = experimentConfig; + const configs = []; - // eslint-disable-next-line no-console - console.debug('run', experimentConfig.run, experimentConfig.audiences); - if (forcedVariant && experimentConfig.variantNames.includes(forcedVariant)) { - experimentConfig.selectedVariant = forcedVariant; - } else { - // eslint-disable-next-line import/extensions - const { ued } = await import('./ued.js'); - const decision = ued.evaluateDecisionPolicy(getDecisionPolicy(experimentConfig), {}); - experimentConfig.selectedVariant = decision.items[0].id; + // Full-page modifications + const pageMetadata = getAllMetadata(type); + const pageNS = await modificationsHandler( + document.querySelector('main'), + pageMetadata, + ); + if (pageNS) { + pageNS.type = 'page'; + configs.push(pageNS); + debug('page', type, pageNS); + } + + // Section-level modifications + let sectionMetadata; + await Promise.all([...document.querySelectorAll('.section-metadata')] + .map(async (sm) => { + sectionMetadata = getAllSectionMeta(sm, type); + const sectionNS = await modificationsHandler( + sm.parentElement, + sectionMetadata, + ); + if (sectionNS) { + sectionNS.type = 'section'; + debug('section', type, sectionNS); + configs.push(sectionNS); + } + })); + + // Fragment-level modifications + if (pageMetadata.manifest) { + let entries = await getManifestEntriesForCurrentPage(pageMetadata.manifest); + if (entries) { + entries = manifestToConfig(entries); + watchMutationsAndApplyFragments( + type, + document.body, + entries, + configs, + getExperienceUrl, + pluginOptions, + metadataToConfig, + getAllQueryParameters(paramNS), + cb, + ); + } } - return experimentConfig; + + return configs; } -export async function runExperiment(document, options, context) { - if (isBot()) { - return false; - } +function aggregateEntries(type, allowedMultiValuesProperties) { + return (entries) => entries.reduce((aggregator, entry) => { + Object.entries(entry).forEach(([key, value]) => { + if (!aggregator[key]) { + aggregator[key] = value; + } else if (aggregator[key] !== value) { + if (allowedMultiValuesProperties.includes(key)) { + aggregator[key] = [].concat(aggregator[key], value); + } else { + // eslint-disable-next-line no-console + console.warn(`Key "${key}" in the ${type} manifest must be the same for every variant on the page.`); + } + } + }); + return aggregator; + }, {}); +} - const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; - const experiment = context.getMetadata(pluginOptions.experimentsMetaTag); - if (!experiment) { - return false; - } - const variants = context.getMetadata('instant-experiment') - || context.getMetadata(`${pluginOptions.experimentsMetaTag}-variants`); - let experimentConfig; - try { - experimentConfig = await getConfig(experiment, variants, pluginOptions, context); - } catch (err) { - // eslint-disable-next-line no-console - console.error('Invalid experiment config.', err); - } - if (!experimentConfig || !isValidExperimentationConfig(experimentConfig)) { - // eslint-disable-next-line no-console - console.warn('Invalid experiment config. Please review your metadata, sheet and parser.'); - return false; +/** + * Parses the experiment configuration from the metadata + */ +async function getExperimentConfig(pluginOptions, metadata, overrides) { + const id = toClassName(metadata.value || metadata.experiment); + if (!id) { + return null; } - const usp = new URLSearchParams(window.location.search); - const forcedVariant = usp.has(pluginOptions.experimentsQueryParameter) - ? usp.get(pluginOptions.experimentsQueryParameter).split('/')[1] - : null; - if (!experimentConfig.run && !forcedVariant) { - // eslint-disable-next-line no-console - console.warn('Experiment will not run. It is either not active or its configured audiences are not resolved.'); - return false; + let pages = metadata.variants || metadata.url; + + // Backward compatibility + if (!pages) { + pages = getMetadata('instant-experiment'); } - // eslint-disable-next-line no-console - console.debug(`running experiment (${window.hlx.experiment.id}) -> ${window.hlx.experiment.selectedVariant}`); - - if (experimentConfig.selectedVariant === experimentConfig.variantNames[0]) { - document.body.classList.add(`experiment-${context.toClassName(experimentConfig.id)}`); - document.body.classList.add(`variant-${context.toClassName(experimentConfig.selectedVariant)}`); - context.sampleRUM('experiment', { - source: experimentConfig.id, - target: experimentConfig.selectedVariant, - }); - return false; + if (metadata.audience) { + metadata.audiences = metadata.audience; } - const { pages } = experimentConfig.variants[experimentConfig.selectedVariant]; + const nbOfVariants = Number(pages); + pages = Number.isNaN(nbOfVariants) + ? stringToArray(pages).map((p) => new URL(p.trim(), window.location).pathname) + : new Array(nbOfVariants).fill(window.location.pathname); if (!pages.length) { - return false; + return null; } - const currentPath = window.location.pathname; - const control = experimentConfig.variants[experimentConfig.variantNames[0]]; - const index = control.pages.indexOf(currentPath); - if (index < 0) { - return false; - } + const audiences = stringToArray(metadata.audiences).map(toClassName); - // Fullpage content experiment - document.body.classList.add(`experiment-${context.toClassName(experimentConfig.id)}`); - let result; - if (pages[index] !== currentPath) { - result = await replaceInner(pages[index], document.querySelector('main')); - } else { - result = currentPath; - } - experimentConfig.servedExperience = result || currentPath; - if (!result) { - // eslint-disable-next-line no-console - console.debug(`failed to serve variant ${window.hlx.experiment.selectedVariant}. Falling back to ${experimentConfig.variantNames[0]}.`); - } - document.body.classList.add(`variant-${context.toClassName(result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0])}`); - context.sampleRUM('experiment', { - source: experimentConfig.id, - target: result ? experimentConfig.selectedVariant : experimentConfig.variantNames[0], + const splits = metadata.split + // custom split + ? stringToArray(metadata.split).map((i) => parseFloat(i) / 100) + // even split + : [...new Array(pages.length)].map(() => 1 / (pages.length + 1)); + + const variantNames = []; + variantNames.push('control'); + + const variants = {}; + variants.control = { + percentageSplit: '', + pages: [window.location.pathname], + label: 'Control', + }; + + // get the customized name for the variant in page metadata and manifest + const labelNames = stringToArray(metadata.name)?.length + ? stringToArray(metadata.name) + : stringToArray(depluralizeProps(metadata, ['variantName']).variantName); + + pages.forEach((page, i) => { + const vname = `challenger-${i + 1}`; + // label with custom name or default + const customLabel = labelNames.length > i ? labelNames[i] : `Challenger ${i + 1}`; + + variantNames.push(vname); + variants[vname] = { + percentageSplit: `${splits[i].toFixed(4)}`, + pages: [page], + blocks: [], + label: customLabel, + }; }); - return result; -} + inferEmptyPercentageSplits(Object.values(variants)); -export async function runCampaign(document, options, context) { - if (isBot()) { - return false; - } + const resolvedAudiences = await getResolvedAudiences( + audiences, + pluginOptions, + ); - const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; - const usp = new URLSearchParams(window.location.search); - const campaign = (usp.has(pluginOptions.campaignsQueryParameter) - ? context.toClassName(usp.get(pluginOptions.campaignsQueryParameter)) - : null) - || (usp.has('utm_campaign') ? context.toClassName(usp.get('utm_campaign')) : null); - if (!campaign) { - return false; - } + const startDate = metadata.startDate ? new Date(metadata.startDate) : null; + const endDate = metadata.endDate ? new Date(metadata.endDate) : null; - let audiences = context.getMetadata(`${pluginOptions.campaignsMetaTagPrefix}-audience`); - let resolvedAudiences = null; - if (audiences) { - audiences = audiences.split(',').map(context.toClassName); - resolvedAudiences = await getResolvedAudiences(audiences, pluginOptions, context); - if (!!resolvedAudiences && !resolvedAudiences.length) { - return false; - } - } + const config = { + id, + label: `Experiment ${metadata.value || metadata.experiment}`, + status: metadata.status || 'active', + audiences, + endDate, + resolvedAudiences, + startDate, + variants, + variantNames, + }; - const allowedCampaigns = context.getAllMetadata(pluginOptions.campaignsMetaTagPrefix); - if (!Object.keys(allowedCampaigns).includes(campaign)) { - return false; - } + config.run = ( + // experiment is active or forced + (['active', 'on', 'true'].includes(toClassName(config.status)) || overrides.value) + // experiment has resolved audiences if configured + && (!resolvedAudiences || resolvedAudiences.length) + // forced audience resolves if defined + && (!overrides.audience || audiences.includes(overrides.audience)) + && (!startDate || startDate <= Date.now()) + && (!endDate || endDate > Date.now()) + ); - const urlString = allowedCampaigns[campaign]; - if (!urlString) { - return false; + if (!config.run) { + return config; } - window.hlx.campaign = { selectedCampaign: campaign }; - if (resolvedAudiences) { - window.hlx.campaign.resolvedAudiences = window.hlx.campaign; + const [, forcedVariant] = (Array.isArray(overrides.value) + ? overrides.value + : stringToArray(overrides.value)) + .map((value) => value?.split('/')) + .find(([experiment]) => toClassName(experiment) === config.id) || []; + if (variantNames.includes(toClassName(forcedVariant))) { + config.selectedVariant = toClassName(forcedVariant); + } else if (overrides.variant && variantNames.includes(overrides.variant)) { + config.selectedVariant = toClassName(overrides.variant); + } else { + // eslint-disable-next-line import/extensions + const { ued } = await import('./ued.js'); + const decision = ued.evaluateDecisionPolicy(toDecisionPolicy(config), {}); + config.selectedVariant = decision.items[0].id; } - try { - const url = new URL(urlString); - const result = await replaceInner(url.pathname, document.querySelector('main')); - window.hlx.campaign.servedExperience = result || window.location.pathname; - if (!result) { - // eslint-disable-next-line no-console - console.debug(`failed to serve campaign ${campaign}. Falling back to default content.`); - } - document.body.classList.add(`campaign-${campaign}`); - context.sampleRUM('campaign', { - source: window.location.href, - target: result ? campaign : 'default', - }); - return result; - } catch (err) { - // eslint-disable-next-line no-console - console.error(err); - return false; - } + return config; } -export async function serveAudience(document, options, context) { - if (isBot()) { - return false; - } +/** + * Parses the campaign manifest. + */ +function parseExperimentManifest(entries) { + return Object.values(Object.groupBy( + entries.map((e) => depluralizeProps(e, ['experiment', 'variant', 'split', 'name'])), + ({ experiment }) => experiment, + )).map(aggregateEntries('experiment', ['split', 'url', 'variant', 'name'])); +} - const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; - const configuredAudiences = context.getAllMetadata(pluginOptions.audiencesMetaTagPrefix); - if (!Object.keys(configuredAudiences).length) { - return false; - } +function getUrlFromExperimentConfig(config) { + return config.run + ? config.variants[config.selectedVariant].pages[0] + : null; +} - const audiences = await getResolvedAudiences( - Object.keys(configuredAudiences).map(context.toClassName), +async function runExperiment(document, pluginOptions) { + return applyAllModifications( + pluginOptions.experimentsMetaTagPrefix, + pluginOptions.experimentsQueryParameter, pluginOptions, - context, + getExperimentConfig, + parseExperimentManifest, + getUrlFromExperimentConfig, + (el, config, result) => { + fireRUM('experiment', config, pluginOptions, result); + // dispatch event + const { id, selectedVariant, variantNames } = config; + const variant = result ? selectedVariant : variantNames[0]; + el.dataset.experiment = id; + el.dataset.variant = variant; + el.classList.add(`experiment-${toClassName(id)}`); + el.classList.add(`variant-${toClassName(variant)}`); + document.dispatchEvent(new CustomEvent('aem:experimentation', { + detail: { + element: el, + type: 'experiment', + experiment: id, + variant, + }, + })); + }, ); - if (!audiences || !audiences.length) { - return false; - } +} - const usp = new URLSearchParams(window.location.search); - const forcedAudience = usp.has(pluginOptions.audiencesQueryParameter) - ? context.toClassName(usp.get(pluginOptions.audiencesQueryParameter)) - : null; +/** + * Parses the campaign configuration from the metadata + */ +async function getCampaignConfig(pluginOptions, metadata, overrides) { + if (!Object.keys(metadata).length || (Object.keys(metadata).length === 1 && metadata.manifest)) { + return null; + } - const selectedAudience = forcedAudience || audiences[0]; - const urlString = configuredAudiences[selectedAudience]; - if (!urlString) { - return false; + // Check UTM parameters + let campaign = overrides.value; + if (!campaign) { + const usp = new URLSearchParams(window.location.search); + if (usp.has('utm_campaign')) { + campaign = toClassName(usp.get('utm_campaign')); + } + } else { + campaign = toClassName(campaign); } - window.hlx.audience = { selectedAudience }; + if (metadata.audience) { + metadata.audiences = metadata.audience; + } - try { - const url = new URL(urlString); - const result = await replaceInner(url.pathname, document.querySelector('main')); - window.hlx.audience.servedExperience = result || window.location.pathname; - if (!result) { - // eslint-disable-next-line no-console - console.debug(`failed to serve audience ${selectedAudience}. Falling back to default content.`); - } - document.body.classList.add(audiences.map((audience) => `audience-${audience}`)); - context.sampleRUM('audiences', { - source: window.location.href, - target: result ? forcedAudience || audiences.join(',') : 'default', - }); - return result; - } catch (err) { - // eslint-disable-next-line no-console - console.error(err); - return false; + const audiences = stringToArray(metadata.audiences).map(toClassName); + const resolvedAudiences = await getResolvedAudiences( + audiences, + pluginOptions, + ); + if (resolvedAudiences && !resolvedAudiences.length) { + return null; } + + const configuredCampaigns = Object.fromEntries(Object.entries(metadata.campaigns || metadata) + .filter(([key]) => !['audience', 'audiences'].includes(key))); + + return { + audiences, + configuredCampaigns, + resolvedAudiences, + selectedCampaign: campaign && (metadata.campaigns || metadata)[campaign] + ? campaign + : null, + }; } -window.hlx.patchBlockConfig?.push((config) => { - const { experiment } = window.hlx; +/** + * Parses the campaign manifest. + */ +function parseCampaignManifest(entries) { + return Object.values(Object.groupBy( + entries.map((e) => depluralizeProps(e, ['campaign'])), + ({ selector }) => selector, + )) + .map(aggregateEntries('campaign', ['campaign', 'url'])) + .map((e) => { + const campaigns = e.campaign; + delete e.campaign; + e.campaigns = {}; + campaigns.forEach((a, i) => { + e.campaigns[toClassName(a)] = e.url[i]; + }); + delete e.url; + return e; + }); +} - // No experiment is running - if (!experiment || !experiment.run) { - return config; - } +function getUrlFromCampaignConfig(config) { + return config.selectedCampaign + ? config.configuredCampaigns[config.selectedCampaign] + : null; +} - // The current experiment does not modify the block - if (experiment.selectedVariant === experiment.variantNames[0] - || !experiment.variants[experiment.variantNames[0]].blocks - || !experiment.variants[experiment.variantNames[0]].blocks.includes(config.blockName)) { - return config; - } +async function runCampaign(document, pluginOptions) { + return applyAllModifications( + pluginOptions.campaignsMetaTagPrefix, + pluginOptions.campaignsQueryParameter, + pluginOptions, + getCampaignConfig, + parseCampaignManifest, + getUrlFromCampaignConfig, + (el, config, result) => { + fireRUM('campaign', config, pluginOptions, result); + // dispatch event + const { selectedCampaign = 'default' } = config; + const campaign = result ? toClassName(selectedCampaign) : 'default'; + el.dataset.audience = selectedCampaign; + el.dataset.audiences = Object.keys(pluginOptions.audiences).join(','); + el.classList.add(`campaign-${campaign}`); + document.dispatchEvent(new CustomEvent('aem:experimentation', { + detail: { + element: el, + type: 'campaign', + campaign, + }, + })); + }, + ); +} - // The current experiment does not modify the block code - const variant = experiment.variants[experiment.selectedVariant]; - if (!variant.blocks.length) { - return config; +/** + * Parses the audience configuration from the metadata + */ +async function getAudienceConfig(pluginOptions, metadata, overrides) { + if (!Object.keys(metadata).length || (Object.keys(metadata).length === 1 && metadata.manifest)) { + return null; } - let index = experiment.variants[experiment.variantNames[0]].blocks.indexOf(''); - if (index < 0) { - index = experiment.variants[experiment.variantNames[0]].blocks.indexOf(config.blockName); - } - if (index < 0) { - index = experiment.variants[experiment.variantNames[0]].blocks.indexOf(`/blocks/${config.blockName}`); - } - if (index < 0) { - return config; + const configuredAudiencesName = Object.keys(metadata.audiences || metadata).map(toClassName); + const resolvedAudiences = await getResolvedAudiences( + configuredAudiencesName, + pluginOptions, + ); + if (resolvedAudiences && !resolvedAudiences.length) { + return false; } - let origin = ''; - let path; - if (/^https?:\/\//.test(variant.blocks[index])) { - const url = new URL(variant.blocks[index]); - // Experimenting from a different branch - if (url.origin !== window.location.origin) { - origin = url.origin; - } - // Experimenting from a block path - if (url.pathname !== '/') { - path = url.pathname; - } else { - path = `/blocks/${config.blockName}`; - } - } else { // Experimenting from a different branch on the same branch - path = `/blocks/${variant.blocks[index]}`; - } - if (!origin && !path) { - return config; - } + const selectedAudience = overrides.audience || resolvedAudiences[0]; - const { codeBasePath } = window.hlx; return { - ...config, - cssPath: `${origin}${codeBasePath}${path}/${config.blockName}.css`, - jsPath: `${origin}${codeBasePath}${path}/${config.blockName}.js`, - }; -}); - -let isAdjusted = false; -function adjustedRumSamplingRate(checkpoint, options, context) { - const pluginOptions = { ...DEFAULT_OPTIONS, ...(options || {}) }; - return (data) => { - if (!window.hlx.rum.isSelected && !isAdjusted) { - isAdjusted = true; - // adjust sampling rate based on project config … - window.hlx.rum.weight = Math.min( - window.hlx.rum.weight, - // … but limit it to the 10% sampling at max to avoid losing anonymization - // and reduce burden on the backend - Math.max(pluginOptions.rumSamplingRate, MAX_SAMPLING_RATE), - ); - window.hlx.rum.isSelected = (window.hlx.rum.random * window.hlx.rum.weight < 1); - if (window.hlx.rum.isSelected) { - context.sampleRUM(checkpoint, data); - } - } - return true; + configuredAudiences: metadata.audiences || metadata, + resolvedAudiences, + selectedAudience, }; } -export async function loadEager(document, options, context) { - context.sampleRUM.always.on('audiences', adjustedRumSamplingRate('audiences', options, context)); - context.sampleRUM.always.on('campaign', adjustedRumSamplingRate('campaign', options, context)); - context.sampleRUM.always.on('experiment', adjustedRumSamplingRate('experiment', options, context)); - let res = await runCampaign(document, options, context); - if (!res) { - res = await runExperiment(document, options, context); - } - if (!res) { - res = await serveAudience(document, options, context); - } +/** + * Parses the audience manifest. + */ +function parseAudienceManifest(entries) { + return Object.values(Object.groupBy( + entries.map((e) => depluralizeProps(e, ['audience'])), + ({ selector }) => selector, + )) + .map(aggregateEntries('audience', ['audience', 'url'])) + .map((e) => { + const audiences = e.audience; + delete e.audience; + e.audiences = {}; + audiences.forEach((a, i) => { + e.audiences[toClassName(a)] = e.url[i]; + }); + delete e.url; + return e; + }); } -export async function loadLazy(document, options, context) { - const pluginOptions = { - ...DEFAULT_OPTIONS, - ...(options || {}), - }; +function getUrlFromAudienceConfig(config) { + return config.selectedAudience + ? config.configuredAudiences[config.selectedAudience] + : null; +} + +async function serveAudience(document, pluginOptions) { + document.body.dataset.audiences = Object.keys(pluginOptions.audiences).join(','); + return applyAllModifications( + pluginOptions.audiencesMetaTagPrefix, + pluginOptions.audiencesQueryParameter, + pluginOptions, + getAudienceConfig, + parseAudienceManifest, + getUrlFromAudienceConfig, + (el, config, result) => { + fireRUM('audience', config, pluginOptions, result); + // dispatch event + const { selectedAudience = 'default' } = config; + const audience = result ? toClassName(selectedAudience) : 'default'; + el.dataset.audience = audience; + el.classList.add(`audience-${audience}`); + document.dispatchEvent(new CustomEvent('aem:experimentation', { + detail: { + element: el, + type: 'audience', + audience, + }, + })); + }, + ); +} + +export async function loadEager(document, options = {}) { + const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; + setDebugMode(window.location, pluginOptions); + + const ns = window.aem || window.hlx || {}; + ns.audiences = await serveAudience(document, pluginOptions); + ns.experiments = await runExperiment(document, pluginOptions); + ns.campaigns = await runCampaign(document, pluginOptions); + + // Backward compatibility + ns.experiment = ns.experiments.find((e) => e.type === 'page'); + ns.audience = ns.audiences.find((e) => e.type === 'page'); + ns.campaign = ns.campaigns.find((e) => e.type === 'page'); +} + +export async function loadLazy(document, options = {}) { + const pluginOptions = { ...DEFAULT_OPTIONS, ...options }; // do not show the experimentation pill on prod domains - if (window.location.hostname.endsWith('.live') - || (typeof options.isProd === 'function' && options.isProd()) - || (options.prodHost - && (options.prodHost === window.location.host - || options.prodHost === window.location.hostname - || options.prodHost === window.location.origin))) { + if (!isDebugEnabled) { return; } - // eslint-disable-next-line import/no-cycle - const preview = await import('./preview.js'); - preview.default(document, pluginOptions, { ...context, getResolvedAudiences }); + // eslint-disable-next-line import/no-unresolved + const preview = await import('https://opensource.adobe.com/aem-experimentation/preview.js'); + const context = { + getMetadata, + toClassName, + debug, + }; + preview.default.call(context, document, pluginOptions); } diff --git a/plugins/experimentation/src/preview.css b/plugins/experimentation/src/preview.css deleted file mode 100644 index 9b02fbd7..00000000 --- a/plugins/experimentation/src/preview.css +++ /dev/null @@ -1,276 +0,0 @@ -/* - * Copyright 2022 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. - */ -[hidden] { - display: none !important; -} - -.hlx-highlight { - --highlight-size: .5rem; - - outline-color: #888; - outline-offset: calc(-1 * var(--highlight-size)); - outline-style: dashed; - outline-width: var(--highlight-size); - background-color: #8882; -} - -.hlx-preview-overlay { - z-index: 99; - position: fixed; - color: #eee; - font-size: 1rem; - font-weight: 600; - display: flex; - flex-direction: column; - gap: .5rem; - inset: auto auto 1em; - align-items: center; - justify-content: flex-end; - width: 100%; -} - -.hlx-badge { - --color: #888; - - border-radius: 2em; - background-color: var(--color); - border-style: solid; - border-color: #fff; - color: #eee; - padding: 1em 1.5em; - cursor: pointer; - display: flex; - align-items: center; - position: relative; - font-size: inherit; - overflow: initial; - margin: 0; - justify-content: space-between; - text-transform: none; -} - -.hlx-badge:focus, -.hlx-badge:hover { - --color: #888; -} - -.hlx-badge:focus-visible { - outline-style: solid; - outline-width: .25em; -} - -.hlx-badge > span { - user-select: none; -} - -.hlx-badge .hlx-open { - box-sizing: border-box; - position: relative; - display: block; - width: 22px; - height: 22px; - border: 2px solid; - border-radius: 100px; - margin-left: 16px; -} - -.hlx-badge .hlx-open::after { - content: ""; - display: block; - box-sizing: border-box; - position: absolute; - width: 6px; - height: 6px; - border-top: 2px solid; - border-right: 2px solid; - transform: rotate(-45deg); - left: 6px; - bottom: 5px; -} - -.hlx-badge.hlx-testing { - background-color: #fa0f00; - color: #fff; -} - -.hlx-popup { - position: absolute; - bottom: 6.5em; - left: 50%; - transform: translateX(-50%); - max-width: calc(100vw - 2em); - min-width: calc(300px - 2em); - background-color: #444; - border-radius: 16px; - box-shadow: 0 0 10px #000; - font-size: 12px; - text-align: initial; - white-space: initial; -} - -.hlx-popup a:any-link { - color: #eee; - border: 2px solid; - padding: 5px 12px; - display: inline-block; - border-radius: 20px; - text-decoration: none; -} - -.hlx-popup-header { - display: grid; - grid-template: - "label actions" - "description actions" - / 1fr min-content; - background-color: #222; - border-radius: 16px 16px 0 0; - padding: 24px 16px; -} - -.hlx-popup-header-label { - grid-area: label; -} - -.hlx-popup-header-description { - grid-area: description; -} - -.hlx-popup-header-actions { - grid-area: actions; - display: flex; - flex-direction: column; -} - -.hlx-popup h4, .hlx-popup h5 { - margin: 0; -} - -.hlx-popup h4 { - font-size: 16px; -} - -.hlx-popup h5 { - font-size: 14px; -} - - -.hlx-popup p { - margin: 0; -} - -.hlx-popup::before { - content: ''; - width: 0; - height: 0; - position: absolute; - border-left: 15px solid transparent; - border-right: 15px solid transparent; - border-top: 15px solid #444; - bottom: -15px; - right: 50%; - transform: translateX(50%); -} - -.hlx-hidden { - display: none; -} - -.hlx-badge.is-active, -.hlx-badge[aria-pressed="true"] { - --color: #280; -} - -.hlx-badge.is-inactive, -.hlx-badge[aria-pressed="false"] { - --color: #fa0f00; -} - -.hlx-popup-item { - display: grid; - grid-template: - "label actions" - "description actions" - / 1fr min-content; - margin: 1em; - padding: 1em; - border-radius: 1em; - gap: 1em; -} - -.hlx-popup-item-label { - grid-area: label; - white-space: nowrap; -} - -.hlx-popup-item-description { - grid-area: description; -} - -.hlx-popup-item-actions { - grid-area: actions; - display: flex; - flex-direction: column; -} - -.hlx-popup-item.is-selected { - background-color: #666; -} - -.hlx-popup-item .hlx-button { - flex: 0 0 auto; -} - -@media (width >= 600px) { - .hlx-highlight { - --highlight-size: .75rem; - } - - .hlx-preview-overlay { - right: 1em; - align-items: end; - font-size: 1.25rem; - } - - .hlx-popup { - right: 0; - left: auto; - transform: none; - min-width: 300px; - bottom: 8em; - } - - .hlx-popup::before { - right: 26px; - transform: none; - } -} - -@media (width >= 900px) { - .hlx-highlight { - --highlight-size: 1rem; - } - - .hlx-preview-overlay { - flex-flow: row wrap-reverse; - justify-content: flex-end; - font-size: 1.5rem; - } - - .hlx-popup { - bottom: 9em; - } - - .hlx-popup::before { - right: 32px; - } -} diff --git a/plugins/experimentation/src/preview.js b/plugins/experimentation/src/preview.js deleted file mode 100644 index 5c871eb8..00000000 --- a/plugins/experimentation/src/preview.js +++ /dev/null @@ -1,518 +0,0 @@ -/* - * Copyright 2022 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -const DOMAIN_KEY_NAME = 'aem-domainkey'; - -class AemExperimentationBar extends HTMLElement { - connectedCallback() { - // Create a shadow root - const shadow = this.attachShadow({ mode: 'open' }); - - const cssPath = new URL(new Error().stack.split('\n')[2].match(/[a-z]+:[^:]+/)[0]).pathname.replace('preview.js', 'preview.css'); - const link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = cssPath; - link.onload = () => { - shadow.querySelector('.hlx-preview-overlay').removeAttribute('hidden'); - }; - shadow.append(link); - - const el = document.createElement('div'); - el.className = 'hlx-preview-overlay'; - el.setAttribute('hidden', true); - shadow.append(el); - } -} -customElements.define('aem-experimentation-bar', AemExperimentationBar); - -function createPreviewOverlay() { - const overlay = document.createElement('aem-experimentation-bar'); - return overlay; -} - -function getOverlay() { - let overlay = document.querySelector('aem-experimentation-bar')?.shadowRoot.children[1]; - if (!overlay) { - const el = createPreviewOverlay(); - document.body.append(el); - [, overlay] = el.shadowRoot.children; - } - return overlay; -} - -function createButton(label) { - const button = document.createElement('button'); - button.className = 'hlx-badge'; - const text = document.createElement('span'); - text.innerHTML = label; - button.append(text); - return button; -} - -function createPopupItem(item) { - const actions = typeof item === 'object' - ? item.actions.map((action) => (action.href - ? `` - : ``)) - : []; - const div = document.createElement('div'); - div.className = `hlx-popup-item${item.isSelected ? ' is-selected' : ''}`; - div.innerHTML = ` -
${typeof item === 'object' ? item.label : item}
- ${item.description ? `
${item.description}
` : ''} - ${actions.length ? `
${actions}
` : ''}`; - const buttons = [...div.querySelectorAll('.hlx-button a')]; - item.actions?.forEach((action, index) => { - if (action.onclick) { - buttons[index].addEventListener('click', action.onclick); - } - }); - return div; -} - -function createPopupDialog(header, items = []) { - const actions = typeof header === 'object' - ? (header.actions || []).map((action) => (action.href - ? `` - : ``)) - : []; - const popup = document.createElement('div'); - popup.className = 'hlx-popup hlx-hidden'; - popup.innerHTML = ` -
-
${typeof header === 'object' ? header.label : header}
- ${header.description ? `
${header.description}
` : ''} - ${actions.length ? `
${actions}
` : ''} -
-
`; - const list = popup.querySelector('.hlx-popup-items'); - items.forEach((item) => { - list.append(createPopupItem(item)); - }); - const buttons = [...popup.querySelectorAll('.hlx-popup-header-actions .hlx-button a')]; - header.actions?.forEach((action, index) => { - if (action.onclick) { - buttons[index].addEventListener('click', action.onclick); - } - }); - return popup; -} - -function createPopupButton(label, header, items) { - const button = createButton(label); - const popup = createPopupDialog(header, items); - button.innerHTML += ''; - button.append(popup); - button.addEventListener('click', () => { - popup.classList.toggle('hlx-hidden'); - }); - return button; -} - -// eslint-disable-next-line no-unused-vars -function createToggleButton(label) { - const button = document.createElement('div'); - button.className = 'hlx-badge'; - button.role = 'button'; - button.setAttribute('aria-pressed', false); - button.setAttribute('tabindex', 0); - const text = document.createElement('span'); - text.innerHTML = label; - button.append(text); - button.addEventListener('click', () => { - button.setAttribute('aria-pressed', button.getAttribute('aria-pressed') === 'false'); - }); - return button; -} - -const percentformat = new Intl.NumberFormat('en-US', { style: 'percent', maximumSignificantDigits: 2 }); -const countformat = new Intl.NumberFormat('en-US', { maximumSignificantDigits: 2 }); -const significanceformat = { - format: (value) => { - if (value < 0.005) { - return 'highly significant'; - } - if (value < 0.05) { - return 'significant'; - } - if (value < 0.1) { - return 'marginally significant'; - } - return 'not significant'; - }, -}; -const bigcountformat = { - format: (value) => { - if (value > 1000000) { - return `${countformat.format(value / 1000000)}M`; - } - if (value > 1000) { - return `${countformat.format(value / 1000)}K`; - } - return countformat.format(value); - }, -}; - -function createVariant(experiment, variantName, config, options) { - const selectedVariant = config?.selectedVariant || config?.variantNames[0]; - const variant = config.variants[variantName]; - const split = variant.percentageSplit; - const percentage = percentformat.format(split); - - const experimentURL = new URL(window.location.href); - // this will retain other query params such as ?rum=on - experimentURL.searchParams.set(options.experimentsQueryParameter, `${experiment}/${variantName}`); - - return { - label: `${variantName}`, - description: ` -

${variant.label}

-

(${percentage} split)

-

`, - actions: [{ label: 'Simulate', href: experimentURL.href }], - isSelected: selectedVariant === variantName, - }; -} - -async function fetchRumData(experiment, options) { - if (!options.domainKey) { - // eslint-disable-next-line no-console - console.warn('Cannot show RUM data. No `domainKey` configured.'); - return null; - } - if (!options.prodHost && (typeof options.isProd !== 'function' || !options.isProd())) { - // eslint-disable-next-line no-console - console.warn('Cannot show RUM data. No `prodHost` configured or custom `isProd` method provided.'); - return null; - } - - // the query is a bit slow, so I'm only fetching the results when the popup is opened - const resultsURL = new URL('https://helix-pages.anywhere.run/helix-services/run-query@v3/rum-experiments'); - // restrict results to the production host, this also reduces query cost - if (typeof options.isProd === 'function' && options.isProd()) { - resultsURL.searchParams.set('url', window.location.host); - } else if (options.prodHost) { - resultsURL.searchParams.set('url', options.prodHost); - } - resultsURL.searchParams.set('domainkey', options.domainKey); - resultsURL.searchParams.set('experiment', experiment); - - const response = await fetch(resultsURL.href); - if (!response.ok) { - return null; - } - - const { results } = await response.json(); - const { data } = results; - if (!data.length) { - return null; - } - - const numberify = (obj) => Object.entries(obj).reduce((o, [k, v]) => { - o[k] = Number.parseFloat(v); - o[k] = Number.isNaN(o[k]) ? v : o[k]; - return o; - }, {}); - - const variantsAsNums = data.map(numberify); - const totals = Object.entries( - variantsAsNums.reduce((o, v) => { - Object.entries(v).forEach(([k, val]) => { - if (typeof val === 'number' && Number.isInteger(val) && k.startsWith('variant_')) { - o[k] = (o[k] || 0) + val; - } else if (typeof val === 'number' && Number.isInteger(val) && k.startsWith('control_')) { - o[k] = val; - } - }); - return o; - }, {}), - ).reduce((o, [k, v]) => { - o[k] = v; - const vkey = k.replace(/^(variant|control)_/, 'variant_'); - const ckey = k.replace(/^(variant|control)_/, 'control_'); - const tkey = k.replace(/^(variant|control)_/, 'total_'); - if (!Number.isNaN(o[ckey]) && !Number.isNaN(o[vkey])) { - o[tkey] = o[ckey] + o[vkey]; - } - return o; - }, {}); - const richVariants = variantsAsNums - .map((v) => ({ - ...v, - allocation_rate: v.variant_experimentations / totals.total_experimentations, - })) - .reduce((o, v) => { - const variantName = v.variant; - o[variantName] = v; - return o; - }, { - control: { - variant: 'control', - ...Object.entries(variantsAsNums[0]).reduce((k, v) => { - const [key, val] = v; - if (key.startsWith('control_')) { - k[key.replace(/^control_/, 'variant_')] = val; - } - return k; - }, {}), - }, - }); - const winner = variantsAsNums.reduce((w, v) => { - if (v.variant_conversion_rate > w.conversion_rate && v.p_value < 0.05) { - w.conversion_rate = v.variant_conversion_rate; - w.p_value = v.p_value; - w.variant = v.variant; - } - return w; - }, { variant: 'control', p_value: 1, conversion_rate: 0 }); - - return { - richVariants, - totals, - variantsAsNums, - winner, - }; -} - -function populatePerformanceMetrics(div, config, { - richVariants, totals, variantsAsNums, winner, -}) { - // add summary - const summary = div.querySelector('.hlx-info'); - summary.innerHTML = `Showing results for ${bigcountformat.format(totals.total_experimentations)} visits and ${bigcountformat.format(totals.total_conversions)} conversions: `; - if (totals.total_conversion_events < 500 && winner.p_value > 0.05) { - summary.innerHTML += ` not yet enough data to determine a winner. Keep going until you get ${bigcountformat.format((500 * totals.total_experimentations) / totals.total_conversion_events)} visits.`; - } else if (winner.p_value > 0.05) { - summary.innerHTML += ' no significant difference between variants. In doubt, stick with control.'; - } else if (winner.variant === 'control') { - summary.innerHTML += ' Stick with control. No variant is better than the control.'; - } else { - summary.innerHTML += ` ${winner.variant} is the winner.`; - } - - // add traffic allocation to control and each variant - config.variantNames.forEach((variantName, index) => { - const variantDiv = document.querySelectorAll('.hlx-popup-item')[index]; - const percentage = variantDiv.querySelector('.percentage'); - percentage.innerHTML = ` - ${bigcountformat.format(richVariants[variantName].variant_conversions)} clicks / - ${bigcountformat.format(richVariants[variantName].variant_experimentations)} visits - (${percentformat.format(richVariants[variantName].variant_experimentations / totals.total_experimentations)} split) - `; - }); - - // add click rate and significance to each variant - variantsAsNums.forEach((result) => { - const variant = document.querySelectorAll('.hlx-popup-item')[config.variantNames.indexOf(result.variant)]; - if (variant) { - const performance = variant.querySelector('.performance'); - performance.innerHTML = ` - click rate: ${percentformat.format(result.variant_conversion_rate)} - vs. ${percentformat.format(result.control_conversion_rate)} - ${significanceformat.format(result.p_value)} - `; - } - }); -} - -/** - * Create Badge if a Page is enlisted in a AEM Experiment - * @return {Object} returns a badge or empty string - */ -async function decorateExperimentPill(overlay, options, context) { - const config = window?.hlx?.experiment; - const experiment = context.toClassName(context.getMetadata(options.experimentsMetaTag)); - if (!experiment || !config) { - return; - } - // eslint-disable-next-line no-console - console.log('preview experiment', experiment); - - const domainKey = window.localStorage.getItem(DOMAIN_KEY_NAME); - const pill = createPopupButton( - `Experiment: ${config.id}`, - { - label: config.label, - description: ` -
- ${config.status} - ${config.resolvedAudiences ? ', ' : ''} - ${config.resolvedAudiences && config.resolvedAudiences.length ? config.resolvedAudiences[0] : ''} - ${config.resolvedAudiences && !config.resolvedAudiences.length ? 'No audience resolved' : ''} - ${config.variants[config.variantNames[0]].blocks.length ? ', Blocks: ' : ''} - ${config.variants[config.variantNames[0]].blocks.join(',')} -
-
How is it going?
`, - actions: [ - ...config.manifest ? [{ label: 'Manifest', href: config.manifest }] : [], - { - label: '', - onclick: async () => { - // eslint-disable-next-line no-alert - const key = window.prompt( - 'Please enter your domain key:', - window.localStorage.getItem(DOMAIN_KEY_NAME) || '', - ); - if (key && key.match(/[a-f0-9-]+/)) { - window.localStorage.setItem(DOMAIN_KEY_NAME, key); - const performanceMetrics = await fetchRumData(experiment, { - ...options, - domainKey: key, - }); - if (performanceMetrics === null) { - return; - } - populatePerformanceMetrics(pill, config, performanceMetrics); - } else if (key === '') { - window.localStorage.removeItem(DOMAIN_KEY_NAME); - } - }, - }, - ], - }, - config.variantNames.map((vname) => createVariant(experiment, vname, config, options)), - ); - if (config.run) { - pill.classList.add(`is-${context.toClassName(config.status)}`); - } - overlay.append(pill); - - const performanceMetrics = await fetchRumData(experiment, { ...options, domainKey }); - if (performanceMetrics === null) { - return; - } - populatePerformanceMetrics(pill, config, performanceMetrics); -} - -function createCampaign(campaign, isSelected, options) { - const url = new URL(window.location.href); - if (campaign !== 'default') { - url.searchParams.set(options.campaignsQueryParameter, campaign); - } else { - url.searchParams.delete(options.campaignsQueryParameter); - } - - return { - label: `${campaign}`, - actions: [{ label: 'Simulate', href: url.href }], - isSelected, - }; -} - -/** - * Create Badge if a Page is enlisted in a AEM Campaign - * @return {Object} returns a badge or empty string - */ -async function decorateCampaignPill(overlay, options, context) { - const campaigns = context.getAllMetadata(options.campaignsMetaTagPrefix); - if (!Object.keys(campaigns).length) { - return; - } - - const usp = new URLSearchParams(window.location.search); - const forcedAudience = usp.has(options.audiencesQueryParameter) - ? context.toClassName(usp.get(options.audiencesQueryParameter)) - : null; - const audiences = campaigns.audience?.split(',').map(context.toClassName) || []; - const resolvedAudiences = await context.getResolvedAudiences(audiences, options); - const isActive = forcedAudience - ? audiences.includes(forcedAudience) - : (!resolvedAudiences || !!resolvedAudiences.length); - const campaign = (usp.has(options.campaignsQueryParameter) - ? context.toClassName(usp.get(options.campaignsQueryParameter)) - : null) - || (usp.has('utm_campaign') ? context.toClassName(usp.get('utm_campaign')) : null); - const pill = createPopupButton( - `Campaign: ${campaign || 'default'}`, - { - label: 'Campaigns on this page:', - description: ` -
- ${audiences.length && resolvedAudiences?.length ? `Audience: ${resolvedAudiences[0]}` : ''} - ${audiences.length && !resolvedAudiences?.length ? 'No audience resolved' : ''} - ${!audiences.length || !resolvedAudiences ? 'No audience configured' : ''} -
`, - }, - [ - createCampaign('default', !campaign || !isActive, options), - ...Object.keys(campaigns) - .filter((c) => c !== 'audience') - .map((c) => createCampaign(c, isActive && context.toClassName(campaign) === c, options)), - ], - ); - - if (campaign && isActive) { - pill.classList.add('is-active'); - } - overlay.append(pill); -} - -function createAudience(audience, isSelected, options) { - const url = new URL(window.location.href); - url.searchParams.set(options.audiencesQueryParameter, audience); - - return { - label: `${audience}`, - actions: [{ label: 'Simulate', href: url.href }], - isSelected, - }; -} - -/** - * Create Badge if a Page is enlisted in a AEM Audiences - * @return {Object} returns a badge or empty string - */ -async function decorateAudiencesPill(overlay, options, context) { - const audiences = context.getAllMetadata(options.audiencesMetaTagPrefix); - if (!Object.keys(audiences).length || !Object.keys(options.audiences).length) { - return; - } - - const resolvedAudiences = await context.getResolvedAudiences( - Object.keys(audiences), - options, - context, - ); - const pill = createPopupButton( - 'Audiences', - { - label: 'Audiences for this page:', - }, - [ - createAudience('default', !resolvedAudiences.length || resolvedAudiences[0] === 'default', options), - ...Object.keys(audiences) - .filter((a) => a !== 'audience') - .map((a) => createAudience(a, resolvedAudiences && resolvedAudiences[0] === a, options)), - ], - ); - - if (resolvedAudiences.length) { - pill.classList.add('is-active'); - } - overlay.append(pill); -} - -/** - * Decorates Preview mode badges and overlays - * @return {Object} returns a badge or empty string - */ -export default async function decoratePreviewMode(document, options, context) { - try { - const overlay = getOverlay(options); - await decorateAudiencesPill(overlay, options, context); - await decorateCampaignPill(overlay, options, context); - await decorateExperimentPill(overlay, options, context); - } catch (e) { - // eslint-disable-next-line no-console - console.log(e); - } -} diff --git a/plugins/experimentation/src/ued.js b/plugins/experimentation/src/ued.js index d28e91c3..4d4e3a39 100644 --- a/plugins/experimentation/src/ued.js +++ b/plugins/experimentation/src/ued.js @@ -9,6 +9,7 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +/* eslint-disable */ var storage = window.sessionStorage; diff --git a/plugins/experimentation/tests/audiences.test.js b/plugins/experimentation/tests/audiences.test.js new file mode 100644 index 00000000..8ce57cc0 --- /dev/null +++ b/plugins/experimentation/tests/audiences.test.js @@ -0,0 +1,212 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { test, expect } from '@playwright/test'; +import { track } from './coverage.js'; +import { goToAndRunAudience, waitForDomEvent } from './utils.js'; + +track(test); + +test.describe('Page-level audiences', () => { + test('Replaces the page content with the variant.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level'); + expect(await page.locator('main').textContent()).toContain('Hello v1!'); + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level--async'); + expect(await page.locator('main').textContent()).toContain('Hello v2!'); + }); + + test('Sets a class on the body for the main resolved audience.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level'); + expect(await page.locator('body').getAttribute('class')).not.toContain('audience-default'); + expect(await page.locator('body').getAttribute('class')).toContain('audience-foo'); + expect(await page.locator('body').getAttribute('class')).not.toContain('audience-bar'); + }); + + test('Ignores empty audiences.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level--empty'); + expect(await page.locator('main').textContent()).toContain('Hello World!'); + expect(await page.locator('body').getAttribute('class')).toContain('audience-default'); + expect(await page.locator('body').getAttribute('class')).not.toContain('audience-foo'); + expect(await page.locator('body').getAttribute('class')).not.toContain('audience-bar'); + }); + + test('Controls the audience shown via query parameters.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level?audience=foo'); + expect(await page.locator('main').textContent()).toContain('Hello v1!'); + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level?audience=bar'); + expect(await page.locator('main').textContent()).toContain('Hello v2!'); + }); + + test('Ignores invalid audiences.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level--invalid'); + expect(await page.locator('main').textContent()).toContain('Hello v2!'); + }); + + test('Ignores invalid audience references in the query parameters.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level?audience=baz'); + expect(await page.locator('main').textContent()).toContain('Hello World!'); + }); + + test('Ignores invalid variant urls.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level--invalid-url'); + expect(await page.locator('main').textContent()).toContain('Hello World!'); + }); + + test('Tracks the audience in RUM.', async ({ page }) => { + await page.addInitScript(() => { + window.rumCalls = []; + window.hlx = { rum: { sampleRUM: (...args) => window.rumCalls.push(args) } }; + }); + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level'); + expect(await page.evaluate(() => window.rumCalls)).toContainEqual([ + 'audience', + expect.objectContaining({ + source: 'foo', + target: 'foo:bar', + }), + ]); + }); + + test('Track RUM is fired before redirect.', async ({ page }) => { + const rumCalls = []; + await page.exposeFunction('logRumCall', (...args) => rumCalls.push(args)); + await page.addInitScript(() => { + window.hlx = { rum: { sampleRUM: (...args) => window.logRumCall(args) } }; + }); + await page.goto('/tests/fixtures/audiences/page-level--redirect'); + await page.waitForURL('/tests/fixtures/audiences/variant-1'); + expect(await page.evaluate(() => window.document.body.innerText)).toEqual('Hello v1!'); + expect(rumCalls[0]).toContainEqual([ + 'audience', + { + source: 'foo', + target: 'foo:bar', + }, + ]); + }); + + test('Exposes the audiences in a JS API.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/page-level'); + expect(await page.evaluate(() => window.hlx.audiences)).toContainEqual( + expect.objectContaining({ + type: 'page', + config: expect.objectContaining({ + configuredAudiences: { + foo: '/tests/fixtures/audiences/variant-1', + bar: '/tests/fixtures/audiences/variant-2', + }, + resolvedAudiences: ['foo', 'bar'], + selectedAudience: 'foo', + }), + servedExperience: '/tests/fixtures/audiences/variant-1', + }), + ); + }); + + test('triggers a DOM event with the audience detail', async ({ page }) => { + const fn = await waitForDomEvent(page, 'aem:experimentation'); + await page.goto('/tests/fixtures/audiences/page-level'); + expect(await page.evaluate(fn)).toEqual({ + type: 'audience', + element: await page.evaluate(() => document.body), + audience: 'foo', + }); + }); +}); + +test.describe('Section-level audiences', () => { + test('Replaces the section content with the variant.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/section-level'); + expect(await page.locator('main>div').textContent()).toContain('Hello v2!'); + }); + + test('Sets classes on the section for the audience.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/section-level'); + expect(await page.locator('main>div').getAttribute('class')).toContain('audience-bar'); + await goToAndRunAudience(page, '/tests/fixtures/audiences/section-level?audience=foo'); + expect(await page.locator('main>div').getAttribute('class')).toContain('audience-foo'); + }); + + test('Exposes the audiences in a JS API.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/section-level'); + expect(await page.evaluate(() => window.hlx.audiences)).toContainEqual( + expect.objectContaining({ + type: 'section', + config: expect.objectContaining({ + configuredAudiences: { + foo: `${await page.evaluate(() => window.location.origin)}/tests/fixtures/audiences/variant-1`, + bar: '/tests/fixtures/audiences/variant-2', + }, + resolvedAudiences: ['bar'], + selectedAudience: 'bar', + }), + servedExperience: '/tests/fixtures/audiences/variant-2', + }), + ); + }); + + test('triggers a DOM event with the audience detail', async ({ page }) => { + const fn = await waitForDomEvent(page, 'aem:experimentation'); + await page.goto('/tests/fixtures/audiences/section-level'); + expect(await page.evaluate(fn)).toEqual({ + type: 'audience', + element: await page.evaluate(() => document.querySelector('.section')), + audience: 'bar', + }); + }); +}); + +test.describe('Fragment-level audiences', () => { + test('Replaces the fragment content with the variant.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/fragment-level'); + expect(await page.locator('.fragment').textContent()).toContain('Hello v1!'); + }); + + test('Supports plural format for manifest keys.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/fragment-level--alt'); + expect(await page.locator('.fragment').textContent()).toContain('Hello v1!'); + }); + + test('Ignores invalid manifest url.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/fragment-level--invalid-url'); + expect(await page.locator('.fragment').textContent()).toContain('Hello World!'); + }); + + test('Replaces the async fragment content with the variant.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/fragment-level--async'); + expect(await page.locator('.fragment').textContent()).toContain('Hello v2!'); + }); + + test('Sets classes on the section for the audience.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/fragment-level'); + expect(await page.locator('.fragment').getAttribute('class')).toContain('audience-foo'); + await goToAndRunAudience(page, '/tests/fixtures/audiences/fragment-level?audience=bar'); + expect(await page.locator('.fragment').getAttribute('class')).toContain('audience-bar'); + }); + + test('Exposes the audiences in a JS API.', async ({ page }) => { + await goToAndRunAudience(page, '/tests/fixtures/audiences/fragment-level'); + expect(await page.evaluate(() => window.hlx.audiences)).toContainEqual( + expect.objectContaining({ + type: 'fragment', + config: expect.objectContaining({ + configuredAudiences: { + foo: '/tests/fixtures/audiences/variant-1', + bar: '/tests/fixtures/audiences/variant-2', + }, + resolvedAudiences: ['foo'], + selectedAudience: 'foo', + }), + servedExperience: '/tests/fixtures/audiences/variant-1', + }), + ); + }); + + test('triggers a DOM event with the audience detail', async ({ page }) => { + const fn = await waitForDomEvent(page, 'aem:experimentation'); + await page.goto('/tests/fixtures/audiences/fragment-level'); + expect(await page.evaluate(fn)).toEqual({ + type: 'audience', + element: await page.evaluate(() => document.querySelector('.fragment')), + audience: 'foo', + }); + }); +}); diff --git a/plugins/experimentation/tests/campaigns.test.js b/plugins/experimentation/tests/campaigns.test.js new file mode 100644 index 00000000..1c4fef3d --- /dev/null +++ b/plugins/experimentation/tests/campaigns.test.js @@ -0,0 +1,224 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { test, expect } from '@playwright/test'; +import { track } from './coverage.js'; +import { goToAndRunCampaign, waitForDomEvent } from './utils.js'; + +track(test); + +test.describe('Page-level campaigns', () => { + test('Replaces the page content with the variant.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level'); + expect(await page.locator('main').textContent()).toContain('Hello World!'); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level?campaign=foo'); + expect(await page.locator('main').textContent()).toContain('Hello v1!'); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level?campaign=bar'); + expect(await page.locator('main').textContent()).toContain('Hello v2!'); + }); + + test('Serves the campaign if the configured audience is resolved.', async ({ page }) => { + await page.addInitScript(() => { + window.AUDIENCES = { baz: () => false }; + }); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level--audiences?campaign=foo'); + expect(await page.locator('main').textContent()).toContain('Hello World!'); + await page.addInitScript(() => { + window.AUDIENCES = { baz: () => true }; + }); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level--audiences?campaign=foo'); + expect(await page.locator('main').textContent()).toContain('Hello v1!'); + }); + + test('Sets a class on the body for the active campaign.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level'); + expect(await page.locator('body').getAttribute('class')).toContain('campaign-default'); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level?campaign=foo'); + expect(await page.locator('body').getAttribute('class')).toContain('campaign-foo'); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level?campaign=bar'); + expect(await page.locator('body').getAttribute('class')).toContain('campaign-bar'); + }); + + test('Ignores empty campaigns.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level--empty?campaign=foo'); + expect(await page.locator('main').textContent()).toContain('Hello World!'); + expect(await page.locator('body').getAttribute('class')).toContain('campaign-default'); + }); + + test('Controls the campaign shown via UTM query parameters.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level?utm_campaign=foo'); + expect(await page.locator('main').textContent()).toContain('Hello v1!'); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level?utm_campaign=bar'); + expect(await page.locator('main').textContent()).toContain('Hello v2!'); + }); + + test('Ignores invalid campaigns references in the query parameters.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level?campaign=baz'); + expect(await page.locator('main').textContent()).toContain('Hello World!'); + }); + + test('Ignores invalid variant urls.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level--invalid-url?campaign=foo'); + expect(await page.locator('main').textContent()).toContain('Hello World!'); + }); + + test('Tracks the campaign in RUM.', async ({ page }) => { + await page.addInitScript(() => { + window.rumCalls = []; + window.hlx = { rum: { sampleRUM: (...args) => window.rumCalls.push(args) } }; + }); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level?campaign=foo'); + expect(await page.evaluate(() => window.rumCalls)).toContainEqual([ + 'audience', + expect.objectContaining({ + source: 'foo', + target: 'foo:bar', + }), + ]); + }); + + test('Track RUM is fired before redirect.', async ({ page }) => { + const rumCalls = []; + await page.exposeFunction('logRumCall', (...args) => rumCalls.push(args)); + await page.addInitScript(() => { + window.hlx = { rum: { sampleRUM: (...args) => window.logRumCall(args) } }; + }); + await page.goto('/tests/fixtures/campaigns/page-level--redirect?campaign=bar'); + await page.waitForURL('/tests/fixtures/campaigns/variant-2'); + expect(await page.evaluate(() => window.document.body.innerText)).toEqual('Hello v2!'); + expect(rumCalls[0]).toContainEqual([ + 'audience', + { + source: 'bar', + target: 'foo:bar', + }, + ]); + }); + + test('Exposes the campaign in a JS API.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level?campaign=bar'); + expect(await page.evaluate(() => window.hlx.campaigns)).toContainEqual( + expect.objectContaining({ + type: 'page', + config: expect.objectContaining({ + configuredCampaigns: { + foo: '/tests/fixtures/campaigns/variant-1', + bar: '/tests/fixtures/campaigns/variant-2', + }, + selectedCampaign: 'bar', + }), + servedExperience: '/tests/fixtures/campaigns/variant-2', + }), + ); + }); + + test('triggers a DOM event with the campaign detail', async ({ page }) => { + const fn = await waitForDomEvent(page, 'aem:experimentation'); + await page.goto('/tests/fixtures/campaigns/page-level?campaign=bar'); + expect(await page.evaluate(fn)).toEqual({ + type: 'campaign', + element: await page.evaluate(() => document.body), + campaign: 'bar', + }); + }); +}); + +test.describe('Section-level campaigns', () => { + test('Replaces the section content with the variant.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/section-level?campaign=bar'); + expect(await page.locator('main>div').textContent()).toContain('Hello v2!'); + }); + + test('Sets classes on the section for the campaign.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/section-level?campaign=foo'); + expect(await page.locator('main>div').getAttribute('class')).toContain('campaign-foo'); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/section-level?campaign=bar'); + expect(await page.locator('main>div').getAttribute('class')).toContain('campaign-bar'); + }); + + test('Exposes the campaigns in a JS API.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/section-level?campaign=bar'); + expect(await page.evaluate(() => window.hlx.campaigns)).toContainEqual( + expect.objectContaining({ + type: 'section', + config: expect.objectContaining({ + configuredCampaigns: { + foo: '/tests/fixtures/campaigns/variant-1', + bar: '/tests/fixtures/campaigns/variant-2', + }, + selectedCampaign: 'bar', + }), + servedExperience: '/tests/fixtures/campaigns/variant-2', + }), + ); + }); + + test('triggers a DOM event with the campaign detail', async ({ page }) => { + const fn = await waitForDomEvent(page, 'aem:experimentation'); + await page.goto('/tests/fixtures/campaigns/section-level?campaign=bar'); + expect(await page.evaluate(fn)).toEqual({ + type: 'campaign', + element: await page.evaluate(() => document.querySelector('.section')), + campaign: 'bar', + }); + }); +}); + +test.describe('Fragment-level campaigns', () => { + test('Replaces the fragment content with the variant.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/fragment-level?campaign=foo'); + expect(await page.locator('.fragment').textContent()).toContain('Hello v1!'); + }); + + test('Supports plural format for manifest keys.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/fragment-level--alt?campaign=foo'); + expect(await page.locator('.fragment').textContent()).toContain('Hello v1!'); + }); + + test('Ignores invalid manifest url.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/fragment-level--invalid-url?campaign=foo'); + expect(await page.locator('.fragment').textContent()).toContain('Hello World!'); + }); + + test('Sets classes on the section for the campaign.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/fragment-level?campaign=foo'); + expect(await page.locator('.fragment').getAttribute('class')).toContain('campaign-foo'); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/fragment-level?campaign=bar'); + expect(await page.locator('.fragment').getAttribute('class')).toContain('campaign-bar'); + }); + + test('Exposes the campaigns in a JS API.', async ({ page }) => { + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/fragment-level?campaign=foo'); + expect(await page.evaluate(() => window.hlx.campaigns)).toContainEqual( + expect.objectContaining({ + type: 'fragment', + config: expect.objectContaining({ + configuredCampaigns: { + foo: '/tests/fixtures/campaigns/variant-1', + bar: '/tests/fixtures/campaigns/variant-2', + }, + selectedCampaign: 'foo', + }), + servedExperience: '/tests/fixtures/campaigns/variant-1', + }), + ); + }); + + test('triggers a DOM event with the campaign detail', async ({ page }) => { + const fn = await waitForDomEvent(page, 'aem:experimentation'); + await page.goto('/tests/fixtures/campaigns/fragment-level?campaign=foo'); + expect(await page.evaluate(fn)).toEqual({ + type: 'campaign', + element: await page.evaluate(() => document.querySelector('.fragment')), + campaign: 'foo', + }); + }); +}); + +test.describe('Backward Compatibility with v1', () => { + test('Support the old "audience" metadata.', async ({ page }) => { + await page.addInitScript(() => { + window.AUDIENCES = { baz: () => true }; + }); + await goToAndRunCampaign(page, '/tests/fixtures/campaigns/page-level--backward-compatibility--audience?campaign=foo'); + expect(await page.locator('main').textContent()).toContain('Hello v1!'); + }); +}); diff --git a/plugins/experimentation/tests/coverage.js b/plugins/experimentation/tests/coverage.js new file mode 100644 index 00000000..939df135 --- /dev/null +++ b/plugins/experimentation/tests/coverage.js @@ -0,0 +1,37 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import MCR from 'monocart-coverage-reports'; + +const coverageReport = MCR({ + name: 'AEM Experimentation Plugin Coverage Report', + outputDir: './coverage', + reports: ['v8', 'console-details', 'codecov'], + entryFilter: { + '**/src/ued.js': false, + '**/src/**': true, + }, +}); + +export async function start() { + // Nothing to do here +} + +export async function end() { + await coverageReport.generate(); +} + +export function track(test) { + test.beforeEach(async ({ page }) => { + await Promise.all([ + page.coverage.startJSCoverage({ resetOnNavigation: false }), + page.coverage.startCSSCoverage({ resetOnNavigation: false }), + ]); + }); + + test.afterEach(async ({ page }) => { + const [jsCoverage, cssCoverage] = await Promise.all([ + page.coverage.stopJSCoverage(), + page.coverage.stopCSSCoverage(), + ]); + await coverageReport.add([...jsCoverage, ...cssCoverage]); + }); +} diff --git a/plugins/experimentation/tests/experiments.test.js b/plugins/experimentation/tests/experiments.test.js new file mode 100644 index 00000000..e6380ada --- /dev/null +++ b/plugins/experimentation/tests/experiments.test.js @@ -0,0 +1,356 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { test, expect } from '@playwright/test'; +import { track } from './coverage.js'; +import { goToAndRunExperiment, waitForDomEvent } from './utils.js'; + +track(test); + +test.describe('Page-level experiments', () => { + test('Replaces the page content with the variant.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + expect(await page.locator('main').textContent()).toMatch(/Hello (World|v1|v2)!/); + }); + + test('Visiting the page multiple times yields the same variant', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + const text = await page.locator('main').textContent(); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + expect(await page.locator('main').textContent()).toEqual(text); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + expect(await page.locator('main').textContent()).toEqual(text); + }); + + test('does not run inactive experiments.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--inactive'); + expect(await page.locator('main').textContent()).toEqual('Hello World!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--inactive?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello v1!'); + }); + + test('Serves the variant if the configured audience is resolved.', async ({ page }) => { + await page.addInitScript(() => { + window.AUDIENCES = { bar: () => false }; + }); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--audiences?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello World!'); + await page.addInitScript(() => { + window.AUDIENCES = { bar: () => true }; + }); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--audiences?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello v1!'); + await page.addInitScript(() => { + window.AUDIENCES = { bar: async () => Promise.resolve(true) }; + }); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--audiences?experiment=foo/challenger-2'); + expect(await page.locator('main').textContent()).toEqual('Hello v2!'); + }); + + test('Supports the "stard-date" and "end-date" metadata.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--time-bound'); + expect(await page.locator('main').textContent()).toMatch(/Hello (World|v1|v2)!/); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--time-bound-start'); + expect(await page.locator('main').textContent()).toEqual('Hello World!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--time-bound-end'); + expect(await page.locator('main').textContent()).toEqual('Hello World!'); + }); + + test('Supports the "split" metadata.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--split'); + expect(await page.locator('main').textContent()).toEqual('Hello v2!'); + }); + + test('Sets classes on the body for the experiment.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level?experiment=foo/control'); + expect(await page.locator('body').getAttribute('class')).toContain('experiment-foo'); + expect(await page.locator('body').getAttribute('class')).toContain('variant-control'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level?experiment=foo/challenger-1'); + expect(await page.locator('body').getAttribute('class')).toContain('experiment-foo'); + expect(await page.locator('body').getAttribute('class')).toContain('variant-challenger-1'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level?experiment=foo/challenger-2'); + expect(await page.locator('body').getAttribute('class')).toContain('experiment-foo'); + expect(await page.locator('body').getAttribute('class')).toContain('variant-challenger-2'); + }); + + test('Ignores empty experiments.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--empty?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello World!'); + expect(await page.locator('body').getAttribute('class')).not.toContain('experiment-foo'); + expect(await page.locator('body').getAttribute('class')).not.toContain('variant-control'); + + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--empty2?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello World!'); + expect(await page.locator('body').getAttribute('class')).not.toContain('experiment-foo'); + expect(await page.locator('body').getAttribute('class')).not.toContain('variant-control'); + }); + + test('Ignores invalid experiment references in the query parameters.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level?experiment=foo/invalid'); + expect(await page.locator('main').textContent()).toMatch(/Hello (World|v1|v2)!/); + }); + + test('Ignores invalid variant urls.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--invalid-url?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello World!'); + expect(await page.locator('body').getAttribute('class')).toContain('experiment-foo'); + expect(await page.locator('body').getAttribute('class')).toContain('variant-control'); + }); + + test('Supports code experiments.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--code?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello World!'); + expect(await page.locator('body').getAttribute('class')).toContain('experiment-foo'); + expect(await page.locator('body').getAttribute('class')).toContain('variant-challenger-1'); + }); + + test('Tracks the experiment in RUM.', async ({ page }) => { + await page.addInitScript(() => { + window.rumCalls = []; + window.hlx = { rum: { sampleRUM: (...args) => window.rumCalls.push(args) } }; + }); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + expect(await page.evaluate(() => window.rumCalls)).toContainEqual([ + 'experiment', + expect.objectContaining({ + source: 'foo', + target: expect.stringMatching(/control|challenger-1|challenger-2/), + }), + ]); + }); + + test('Track RUM is fired before redirect', async ({ page }) => { + const rumCalls = []; + await page.exposeFunction('logRumCall', (...args) => rumCalls.push(args)); + await page.addInitScript(() => { + window.hlx = { rum: { sampleRUM: (...args) => window.logRumCall(args) } }; + }); + await page.goto('/tests/fixtures/experiments/page-level--redirect'); + await page.waitForFunction(() => window.hlx.rum.sampleRUM); + expect(rumCalls[0]).toContainEqual([ + 'experiment', + { + source: 'foo', + target: expect.stringMatching(/control|challenger-1|challenger-2/), + }, + ]); + + const expectedContent = { + v1: 'Hello v1!', + v2: 'Hello v2!', + redirect: 'Hello World!', + }; + const expectedUrlPath = { + v1: '/tests/fixtures/experiments/page-level-v1', + v2: '/tests/fixtures/experiments/page-level-v2', + redirect: '/tests/fixtures/experiments/page-level--redirect', + }; + const url = new URL(page.url()); + const variant = Object.keys(expectedUrlPath).find((k) => url.pathname.endsWith(k)); + expect(await page.evaluate(() => window.document.body.innerText)).toMatch( + new RegExp(expectedContent[variant]), + ); + expect(expectedUrlPath[variant]).toBe(url.pathname); + }); + + test('Exposes the experiment in a JS API.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level'); + expect(await page.evaluate(() => window.hlx.experiments)).toContainEqual( + expect.objectContaining({ + type: 'page', + config: expect.objectContaining({ + id: 'foo', + run: true, + selectedVariant: expect.stringMatching(/control|challenger-1|challenger-2/), + status: 'active', + variants: { + control: expect.objectContaining({ percentageSplit: '0.3334' }), + 'challenger-1': expect.objectContaining({ percentageSplit: '0.3333' }), + 'challenger-2': expect.objectContaining({ percentageSplit: '0.3333' }), + }, + }), + servedExperience: expect.stringContaining('/tests/fixtures/experiments/page-level'), + }), + ); + }); + + test('Controls the variant shown via query parameters.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level?experiment=foo/control'); + expect(await page.locator('main').textContent()).toEqual('Hello World!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello v1!'); + }); + + test('supports overriding the shown experiment and variant via query parameters.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level?experiment=foo/challenger-2&experiment=bar/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello v2!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level?experiment=foo&experiment-variant=challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello v1!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--audiences?experiment=foo&experiment-variant=challenger-2&audience=bar'); + expect(await page.locator('main').textContent()).toEqual('Hello v2!'); + }); + + test('triggers a DOM event with the experiment detail', async ({ page }) => { + const fn = await waitForDomEvent(page, 'aem:experimentation'); + await page.goto('/tests/fixtures/experiments/page-level?experiment=foo&experiment-variant=challenger-1'); + expect(await page.evaluate(fn)).toEqual({ + type: 'experiment', + element: await page.evaluate(() => document.body), + experiment: 'foo', + variant: 'challenger-1', + }); + }); +}); + +test.describe('Section-level experiments', () => { + test('Replaces the section content with the variant.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/section-level'); + expect(await page.locator('main>div').textContent()).toMatch(/Hello (World|v1|v2)!/); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/section-level?experiment=bar/control'); + expect(await page.locator('main>div').textContent()).toContain('Hello World!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/section-level?experiment=bar/challenger-1'); + expect(await page.locator('main>div').textContent()).toContain('Hello v1!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/section-level?experiment=bar/challenger-2'); + expect(await page.locator('main>div').textContent()).toContain('Hello v2!'); + }); + + test('Sets classes on the section for the experiment.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/section-level?experiment=bar/control'); + expect(await page.locator('main>div').getAttribute('class')).toContain('experiment-bar'); + expect(await page.locator('main>div').getAttribute('class')).toContain('variant-control'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/section-level?experiment=bar/challenger-1'); + expect(await page.locator('main>div').getAttribute('class')).toContain('experiment-bar'); + expect(await page.locator('main>div').getAttribute('class')).toContain('variant-challenger-1'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/section-level?experiment=bar/challenger-2'); + expect(await page.locator('main>div').getAttribute('class')).toContain('experiment-bar'); + expect(await page.locator('main>div').getAttribute('class')).toContain('variant-challenger-2'); + }); + + test('Exposes the experiment in a JS API.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/section-level'); + expect(await page.evaluate(() => window.hlx.experiments)).toContainEqual( + expect.objectContaining({ + type: 'section', + config: expect.objectContaining({ + id: 'bar', + run: true, + selectedVariant: expect.stringMatching(/control|challenger-1|challenger-2/), + status: 'active', + variants: { + control: expect.objectContaining({ percentageSplit: '0.3334' }), + 'challenger-1': expect.objectContaining({ percentageSplit: '0.3333' }), + 'challenger-2': expect.objectContaining({ percentageSplit: '0.3333' }), + }, + }), + servedExperience: expect.stringMatching(/\/tests\/fixtures\/experiments\/(page|section)-level/), + }), + ); + }); + + test('triggers a DOM event with the experiment detail', async ({ page }) => { + const fn = await waitForDomEvent(page, 'aem:experimentation'); + await page.goto('/tests/fixtures/experiments/section-level?experiment=bar/challenger-2'); + expect(await page.evaluate(fn)).toEqual({ + type: 'experiment', + element: await page.evaluate(() => document.querySelector('.section')), + experiment: 'bar', + variant: 'challenger-2', + }); + }); +}); + +test.describe('Fragment-level experiments', () => { + test('Replaces the fragment content with the variant.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level'); + expect(await page.locator('.fragment').first().textContent()).toMatch(/Hello (World|v1|v2)!/); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level?experiment=baz/control'); + expect(await page.locator('.fragment').first().textContent()).toContain('Hello World!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level?experiment=baz/challenger-1'); + expect(await page.locator('.fragment').first().textContent()).toContain('Hello v1!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level?experiment=baz/challenger-2'); + expect(await page.locator('.fragment').first().textContent()).toContain('Hello v2!'); + }); + + test('Supports plural format for manifest keys.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level--alt?experiment=baz/control'); + expect(await page.locator('.fragment').first().textContent()).toContain('Hello World!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level--alt?experiment=baz/challenger-1'); + expect(await page.locator('.fragment').first().textContent()).toContain('Hello v1!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level--alt?experiment=baz/challenger-2'); + expect(await page.locator('.fragment').first().textContent()).toContain('Hello v2!'); + }); + + test('Ignores invalid manifest url.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level--invalid-url'); + expect(await page.locator('.fragment').first().textContent()).toContain('Hello World!'); + }); + + test('Replaces the async fragment content with the variant.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level--async'); + expect(await page.locator('.fragment').first().textContent()).toMatch(/Hello (World|v1|v2)!/); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level--async?experiment=baz/control'); + expect(await page.locator('.fragment').first().textContent()).toContain('Hello World!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level--async?experiment=baz/challenger-1'); + expect(await page.locator('.fragment').first().textContent()).toContain('Hello v1!'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level--async?experiment=baz/challenger-2'); + expect(await page.locator('.fragment').first().textContent()).toContain('Hello v2!'); + }); + + test('Sets classes on the section for the experiment.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level?experiment=baz/control'); + expect(await page.locator('.fragment').first().getAttribute('class')).toContain('experiment-baz'); + expect(await page.locator('.fragment').first().getAttribute('class')).toContain('variant-control'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level?experiment=baz/challenger-1'); + expect(await page.locator('.fragment').first().getAttribute('class')).toContain('experiment-baz'); + expect(await page.locator('.fragment').first().getAttribute('class')).toContain('variant-challenger-1'); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level?experiment=baz/challenger-2'); + expect(await page.locator('.fragment').first().getAttribute('class')).toContain('experiment-baz'); + expect(await page.locator('.fragment').first().getAttribute('class')).toContain('variant-challenger-2'); + }); + + test('Exposes the experiment in a JS API.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/fragment-level'); + expect(await page.evaluate(() => window.hlx.experiments)).toContainEqual( + expect.objectContaining({ + type: 'fragment', + config: expect.objectContaining({ + id: 'baz', + run: true, + selectedVariant: expect.stringMatching(/control|challenger-1|challenger-2/), + status: 'active', + variants: { + control: expect.objectContaining({ percentageSplit: '0.3334' }), + 'challenger-1': expect.objectContaining({ percentageSplit: '0.3333' }), + 'challenger-2': expect.objectContaining({ percentageSplit: '0.3333' }), + }, + label: expect.stringMatching(/Experiment Baz/), + }), + servedExperience: expect.stringMatching(/\/tests\/fixtures\/experiments\/(fragment|section)-level/), + }), + ); + }); + + test('triggers a DOM event with the experiment detail', async ({ page }) => { + const fn = await waitForDomEvent(page, 'aem:experimentation'); + await page.goto('/tests/fixtures/experiments/fragment-level?experiment=baz/challenger-1'); + expect(await page.evaluate(fn)).toEqual({ + type: 'experiment', + element: await page.evaluate(() => document.querySelector('.fragment')), + experiment: 'baz', + variant: 'challenger-1', + }); + }); +}); + +test.describe('Backward Compatibility with v1', () => { + test('Support the old "instant-experiment" metadata.', async ({ page }) => { + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--backward-compatibility--instant-experiment?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello v1!'); + }); + + test('Support the old "audience" metadata.', async ({ page }) => { + await page.addInitScript(() => { + window.AUDIENCES = { bar: () => true }; + }); + await goToAndRunExperiment(page, '/tests/fixtures/experiments/page-level--backward-compatibility--audience?experiment=foo/challenger-1'); + expect(await page.locator('main').textContent()).toEqual('Hello v1!'); + }); +}); diff --git a/plugins/experimentation/tests/fixtures/aem.js b/plugins/experimentation/tests/fixtures/aem.js new file mode 100644 index 00000000..61527b42 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/aem.js @@ -0,0 +1,759 @@ +/* + * 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. + */ + +/* eslint-env browser */ + +/** + * log RUM if part of the sample. + * @param {string} checkpoint identifies the checkpoint in funnel + * @param {Object} data additional data for RUM sample + * @param {string} data.source DOM node that is the source of a checkpoint event, + * identified by #id or .classname + * @param {string} data.target subject of the checkpoint event, + * for instance the href of a link, or a search term + */ +function sampleRUM(checkpoint, data = {}) { + const SESSION_STORAGE_KEY = 'aem-rum'; + sampleRUM.baseURL = sampleRUM.baseURL + || new URL(window.RUM_BASE == null ? 'https://rum.hlx.page' : window.RUM_BASE, window.location); + sampleRUM.defer = sampleRUM.defer || []; + const defer = (fnname) => { + sampleRUM[fnname] = sampleRUM[fnname] || ((...args) => sampleRUM.defer.push({ fnname, args })); + }; + sampleRUM.drain = sampleRUM.drain + || ((dfnname, fn) => { + sampleRUM[dfnname] = fn; + sampleRUM.defer + .filter(({ fnname }) => dfnname === fnname) + .forEach(({ fnname, args }) => sampleRUM[fnname](...args)); + }); + sampleRUM.always = sampleRUM.always || []; + sampleRUM.always.on = (chkpnt, fn) => { + sampleRUM.always[chkpnt] = fn; + }; + sampleRUM.on = (chkpnt, fn) => { + sampleRUM.cases[chkpnt] = fn; + }; + defer('observe'); + defer('cwv'); + try { + window.hlx = window.hlx || {}; + if (!window.hlx.rum) { + const usp = new URLSearchParams(window.location.search); + const weight = usp.get('rum') === 'on' ? 1 : 100; // with parameter, weight is 1. Defaults to 100. + const id = Array.from({ length: 75 }, (_, i) => String.fromCharCode(48 + i)) + .filter((a) => /\d|[A-Z]/i.test(a)) + .filter(() => Math.random() * 75 > 70) + .join(''); + const random = Math.random(); + const isSelected = random * weight < 1; + const firstReadTime = window.performance ? window.performance.timeOrigin : Date.now(); + const urlSanitizers = { + full: () => window.location.href, + origin: () => window.location.origin, + path: () => window.location.href.replace(/\?.*$/, ''), + }; + // eslint-disable-next-line max-len + const rumSessionStorage = sessionStorage.getItem(SESSION_STORAGE_KEY) + ? JSON.parse(sessionStorage.getItem(SESSION_STORAGE_KEY)) + : {}; + // eslint-disable-next-line max-len + rumSessionStorage.pages = (rumSessionStorage.pages ? rumSessionStorage.pages : 0) + + 1 + /* noise */ + (Math.floor(Math.random() * 20) - 10); + sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(rumSessionStorage)); + // eslint-disable-next-line object-curly-newline, max-len + window.hlx.rum = { + weight, + id, + random, + isSelected, + firstReadTime, + sampleRUM, + sanitizeURL: urlSanitizers[window.hlx.RUM_MASK_URL || 'path'], + rumSessionStorage, + }; + } + + const { weight, id, firstReadTime } = window.hlx.rum; + if (window.hlx && window.hlx.rum && window.hlx.rum.isSelected) { + const knownProperties = [ + 'weight', + 'id', + 'referer', + 'checkpoint', + 't', + 'source', + 'target', + 'cwv', + 'CLS', + 'FID', + 'LCP', + 'INP', + 'TTFB', + ]; + const sendPing = (pdata = data) => { + // eslint-disable-next-line max-len + const t = Math.round( + window.performance ? window.performance.now() : Date.now() - firstReadTime, + ); + // eslint-disable-next-line object-curly-newline, max-len, no-use-before-define + const body = JSON.stringify( + { + weight, id, referer: window.hlx.rum.sanitizeURL(), checkpoint, t, ...data, + }, + knownProperties, + ); + const url = new URL(`.rum/${weight}`, sampleRUM.baseURL).href; + navigator.sendBeacon(url, body); + // eslint-disable-next-line no-console + console.debug(`ping:${checkpoint}`, pdata); + }; + sampleRUM.cases = sampleRUM.cases || { + load: () => sampleRUM('pagesviewed', { source: window.hlx.rum.rumSessionStorage.pages }) || true, + cwv: () => sampleRUM.cwv(data) || true, + lazy: () => { + // use classic script to avoid CORS issues + const script = document.createElement('script'); + script.src = new URL( + '.rum/@adobe/helix-rum-enhancer@^1/src/index.js', + sampleRUM.baseURL, + ).href; + document.head.appendChild(script); + return true; + }, + }; + sendPing(data); + if (sampleRUM.cases[checkpoint]) { + sampleRUM.cases[checkpoint](); + } + } + if (sampleRUM.always[checkpoint]) { + sampleRUM.always[checkpoint](data); + } + } catch (error) { + // something went wrong + } +} + +/** + * Setup block utils. + */ +function setup() { + window.hlx = window.hlx || {}; + window.hlx.RUM_MASK_URL = 'full'; + window.hlx.codeBasePath = ''; + window.hlx.lighthouse = new URLSearchParams(window.location.search).get('lighthouse') === 'on'; + + const scriptEl = document.querySelector('script[src$="/scripts/scripts.js"]'); + if (scriptEl) { + try { + [window.hlx.codeBasePath] = new URL(scriptEl.src).pathname.split('/scripts/scripts.js'); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + } + } +} + +/** + * Auto initializiation. + */ + +function init() { + setup(); +} + +/** + * Sanitizes a string for use as class name. + * @param {string} name The unsanitized string + * @returns {string} The class name + */ +function toClassName(name) { + return typeof name === 'string' + ? name + .toLowerCase() + .replace(/[^0-9a-z]/gi, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + : ''; +} + +/** + * Sanitizes a string for use as a js property name. + * @param {string} name The unsanitized string + * @returns {string} The camelCased name + */ +function toCamelCase(name) { + return toClassName(name).replace(/-([a-z])/g, (g) => g[1].toUpperCase()); +} + +/** + * Extracts the config from a block. + * @param {Element} block The block element + * @returns {object} The block config + */ +// eslint-disable-next-line import/prefer-default-export +function readBlockConfig(block) { + const config = {}; + block.querySelectorAll(':scope > div').forEach((row) => { + if (row.children) { + const cols = [...row.children]; + if (cols[1]) { + const col = cols[1]; + const name = toClassName(cols[0].textContent); + let value = ''; + if (col.querySelector('a')) { + const as = [...col.querySelectorAll('a')]; + if (as.length === 1) { + value = as[0].href; + } else { + value = as.map((a) => a.href); + } + } else if (col.querySelector('img')) { + const imgs = [...col.querySelectorAll('img')]; + if (imgs.length === 1) { + value = imgs[0].src; + } else { + value = imgs.map((img) => img.src); + } + } else if (col.querySelector('p')) { + const ps = [...col.querySelectorAll('p')]; + if (ps.length === 1) { + value = ps[0].textContent; + } else { + value = ps.map((p) => p.textContent); + } + } else value = row.children[1].textContent; + config[name] = value; + } + } + }); + return config; +} + +/** + * Loads a CSS file. + * @param {string} href URL to the CSS file + */ +async function loadCSS(href) { + return new Promise((resolve, reject) => { + if (!document.querySelector(`head > link[href="${href}"]`)) { + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + link.onload = resolve; + link.onerror = reject; + document.head.append(link); + } else { + resolve(); + } + }); +} + +/** + * Loads a non module JS file. + * @param {string} src URL to the JS file + * @param {Object} attrs additional optional attributes + */ +async function loadScript(src, attrs) { + return new Promise((resolve, reject) => { + if (!document.querySelector(`head > script[src="${src}"]`)) { + const script = document.createElement('script'); + script.src = src; + if (attrs) { + // eslint-disable-next-line no-restricted-syntax, guard-for-in + for (const attr in attrs) { + script.setAttribute(attr, attrs[attr]); + } + } + script.onload = resolve; + script.onerror = reject; + document.head.append(script); + } else { + resolve(); + } + }); +} + +/** + * Retrieves the content of metadata tags. + * @param {string} name The metadata name (or property) + * @param {Document} doc Document object to query for metadata. Defaults to the window's document + * @returns {string} The metadata value(s) + */ +function getMetadata(name, doc = document) { + const attr = name && name.includes(':') ? 'property' : 'name'; + const meta = [...doc.head.querySelectorAll(`meta[${attr}="${name}"]`)] + .map((m) => m.content) + .join(', '); + return meta || ''; +} + +/** + * Returns a picture element with webp and fallbacks + * @param {string} src The image URL + * @param {string} [alt] The image alternative text + * @param {boolean} [eager] Set loading attribute to eager + * @param {Array} [breakpoints] Breakpoints and corresponding params (eg. width) + * @returns {Element} The picture element + */ +function createOptimizedPicture( + src, + alt = '', + eager = false, + breakpoints = [{ media: '(min-width: 600px)', width: '2000' }, { width: '750' }], +) { + const url = new URL(src, window.location.href); + const picture = document.createElement('picture'); + const { pathname } = url; + const ext = pathname.substring(pathname.lastIndexOf('.') + 1); + + // webp + breakpoints.forEach((br) => { + const source = document.createElement('source'); + if (br.media) source.setAttribute('media', br.media); + source.setAttribute('type', 'image/webp'); + source.setAttribute('srcset', `${pathname}?width=${br.width}&format=webply&optimize=medium`); + picture.appendChild(source); + }); + + // fallback + breakpoints.forEach((br, i) => { + if (i < breakpoints.length - 1) { + const source = document.createElement('source'); + if (br.media) source.setAttribute('media', br.media); + source.setAttribute('srcset', `${pathname}?width=${br.width}&format=${ext}&optimize=medium`); + picture.appendChild(source); + } else { + const img = document.createElement('img'); + img.setAttribute('loading', eager ? 'eager' : 'lazy'); + img.setAttribute('alt', alt); + picture.appendChild(img); + img.setAttribute('src', `${pathname}?width=${br.width}&format=${ext}&optimize=medium`); + } + }); + + return picture; +} + +/** + * Set template (page structure) and theme (page styles). + */ +function decorateTemplateAndTheme() { + const addClasses = (element, classes) => { + classes.split(',').forEach((c) => { + element.classList.add(toClassName(c.trim())); + }); + }; + const template = getMetadata('template'); + if (template) addClasses(document.body, template); + const theme = getMetadata('theme'); + if (theme) addClasses(document.body, theme); +} + +/** + * Wrap inline text content of block cells within a

tag. + * @param {Element} block the block element + */ +function wrapTextNodes(block) { + const validWrappers = [ + 'P', + 'PRE', + 'UL', + 'OL', + 'PICTURE', + 'TABLE', + 'H1', + 'H2', + 'H3', + 'H4', + 'H5', + 'H6', + ]; + + const wrap = (el) => { + const wrapper = document.createElement('p'); + wrapper.append(...el.childNodes); + el.append(wrapper); + }; + + block.querySelectorAll(':scope > div > div').forEach((blockColumn) => { + if (blockColumn.hasChildNodes()) { + const hasWrapper = !!blockColumn.firstElementChild + && validWrappers.some((tagName) => blockColumn.firstElementChild.tagName === tagName); + if (!hasWrapper) { + wrap(blockColumn); + } else if ( + blockColumn.firstElementChild.tagName === 'PICTURE' + && (blockColumn.children.length > 1 || !!blockColumn.textContent.trim()) + ) { + wrap(blockColumn); + } + } + }); +} + +/** + * Decorates paragraphs containing a single link as buttons. + * @param {Element} element container element + */ +function decorateButtons(element) { + element.querySelectorAll('a').forEach((a) => { + a.title = a.title || a.textContent; + if (a.href !== a.textContent) { + const up = a.parentElement; + const twoup = a.parentElement.parentElement; + if (!a.querySelector('img')) { + if (up.childNodes.length === 1 && (up.tagName === 'P' || up.tagName === 'DIV')) { + a.className = 'button'; // default + up.classList.add('button-container'); + } + if ( + up.childNodes.length === 1 + && up.tagName === 'STRONG' + && twoup.childNodes.length === 1 + && twoup.tagName === 'P' + ) { + a.className = 'button primary'; + twoup.classList.add('button-container'); + } + if ( + up.childNodes.length === 1 + && up.tagName === 'EM' + && twoup.childNodes.length === 1 + && twoup.tagName === 'P' + ) { + a.className = 'button secondary'; + twoup.classList.add('button-container'); + } + } + } + }); +} + +/** + * Add for icon, prefixed with codeBasePath and optional prefix. + * @param {Element} [span] span element with icon classes + * @param {string} [prefix] prefix to be added to icon src + * @param {string} [alt] alt text to be added to icon + */ +function decorateIcon(span, prefix = '', alt = '') { + const iconName = Array.from(span.classList) + .find((c) => c.startsWith('icon-')) + .substring(5); + const img = document.createElement('img'); + img.dataset.iconName = iconName; + img.src = `${window.hlx.codeBasePath}${prefix}/icons/${iconName}.svg`; + img.alt = alt; + img.loading = 'lazy'; + span.append(img); +} + +/** + * Add for icons, prefixed with codeBasePath and optional prefix. + * @param {Element} [element] Element containing icons + * @param {string} [prefix] prefix to be added to icon the src + */ +function decorateIcons(element, prefix = '') { + const icons = [...element.querySelectorAll('span.icon')]; + icons.forEach((span) => { + decorateIcon(span, prefix); + }); +} + +/** + * Decorates all sections in a container element. + * @param {Element} main The container element + */ +function decorateSections(main) { + main.querySelectorAll(':scope > div').forEach((section) => { + const wrappers = []; + let defaultContent = false; + [...section.children].forEach((e) => { + if (e.tagName === 'DIV' || !defaultContent) { + const wrapper = document.createElement('div'); + wrappers.push(wrapper); + defaultContent = e.tagName !== 'DIV'; + if (defaultContent) wrapper.classList.add('default-content-wrapper'); + } + wrappers[wrappers.length - 1].append(e); + }); + wrappers.forEach((wrapper) => section.append(wrapper)); + section.classList.add('section'); + section.dataset.sectionStatus = 'initialized'; + section.style.display = 'none'; + + // Process section metadata + const sectionMeta = section.querySelector('div.section-metadata'); + if (sectionMeta) { + const meta = readBlockConfig(sectionMeta); + Object.keys(meta).forEach((key) => { + if (key === 'style') { + const styles = meta.style + .split(',') + .filter((style) => style) + .map((style) => toClassName(style.trim())); + styles.forEach((style) => section.classList.add(style)); + } else { + section.dataset[toCamelCase(key)] = meta[key]; + } + }); + sectionMeta.parentNode.remove(); + } + }); +} + +/** + * Gets placeholders object. + * @param {string} [prefix] Location of placeholders + * @returns {object} Window placeholders object + */ +// eslint-disable-next-line import/prefer-default-export +async function fetchPlaceholders(prefix = 'default') { + window.placeholders = window.placeholders || {}; + if (!window.placeholders[prefix]) { + window.placeholders[prefix] = new Promise((resolve) => { + fetch(`${prefix === 'default' ? '' : prefix}/placeholders.json`) + .then((resp) => { + if (resp.ok) { + return resp.json(); + } + return {}; + }) + .then((json) => { + const placeholders = {}; + json.data + .filter((placeholder) => placeholder.Key) + .forEach((placeholder) => { + placeholders[toCamelCase(placeholder.Key)] = placeholder.Text; + }); + window.placeholders[prefix] = placeholders; + resolve(window.placeholders[prefix]); + }) + .catch(() => { + // error loading placeholders + window.placeholders[prefix] = {}; + resolve(window.placeholders[prefix]); + }); + }); + } + return window.placeholders[`${prefix}`]; +} + +/** + * Updates all section status in a container element. + * @param {Element} main The container element + */ +function updateSectionsStatus(main) { + const sections = [...main.querySelectorAll(':scope > div.section')]; + for (let i = 0; i < sections.length; i += 1) { + const section = sections[i]; + const status = section.dataset.sectionStatus; + if (status !== 'loaded') { + const loadingBlock = section.querySelector( + '.block[data-block-status="initialized"], .block[data-block-status="loading"]', + ); + if (loadingBlock) { + section.dataset.sectionStatus = 'loading'; + break; + } else { + section.dataset.sectionStatus = 'loaded'; + section.style.display = null; + } + } + } +} + +/** + * Builds a block DOM Element from a two dimensional array, string, or object + * @param {string} blockName name of the block + * @param {*} content two dimensional array or string or object of content + */ +function buildBlock(blockName, content) { + const table = Array.isArray(content) ? content : [[content]]; + const blockEl = document.createElement('div'); + // build image block nested div structure + blockEl.classList.add(blockName); + table.forEach((row) => { + const rowEl = document.createElement('div'); + row.forEach((col) => { + const colEl = document.createElement('div'); + const vals = col.elems ? col.elems : [col]; + vals.forEach((val) => { + if (val) { + if (typeof val === 'string') { + colEl.innerHTML += val; + } else { + colEl.appendChild(val); + } + } + }); + rowEl.appendChild(colEl); + }); + blockEl.appendChild(rowEl); + }); + return blockEl; +} + +/** + * Loads JS and CSS for a block. + * @param {Element} block The block element + */ +async function loadBlock(block) { + const status = block.dataset.blockStatus; + if (status !== 'loading' && status !== 'loaded') { + block.dataset.blockStatus = 'loading'; + const { blockName } = block.dataset; + try { + const cssLoaded = loadCSS(`${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.css`); + const decorationComplete = new Promise((resolve) => { + (async () => { + try { + const mod = await import( + `${window.hlx.codeBasePath}/blocks/${blockName}/${blockName}.js` + ); + if (mod.default) { + await mod.default(block); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log(`failed to load module for ${blockName}`, error); + } + resolve(); + })(); + }); + await Promise.all([cssLoaded, decorationComplete]); + } catch (error) { + // eslint-disable-next-line no-console + console.log(`failed to load block ${blockName}`, error); + } + block.dataset.blockStatus = 'loaded'; + } + return block; +} + +/** + * Loads JS and CSS for all blocks in a container element. + * @param {Element} main The container element + */ +async function loadBlocks(main) { + updateSectionsStatus(main); + const blocks = [...main.querySelectorAll('div.block')]; + for (let i = 0; i < blocks.length; i += 1) { + // eslint-disable-next-line no-await-in-loop + await loadBlock(blocks[i]); + updateSectionsStatus(main); + } +} + +/** + * Decorates a block. + * @param {Element} block The block element + */ +function decorateBlock(block) { + const shortBlockName = block.classList[0]; + if (shortBlockName) { + block.classList.add('block'); + block.dataset.blockName = shortBlockName; + block.dataset.blockStatus = 'initialized'; + wrapTextNodes(block); + const blockWrapper = block.parentElement; + blockWrapper.classList.add(`${shortBlockName}-wrapper`); + const section = block.closest('.section'); + if (section) section.classList.add(`${shortBlockName}-container`); + } +} + +/** + * Decorates all blocks in a container element. + * @param {Element} main The container element + */ +function decorateBlocks(main) { + main.querySelectorAll('div.section > div > div').forEach(decorateBlock); +} + +/** + * Loads a block named 'header' into header + * @param {Element} header header element + * @returns {Promise} + */ +async function loadHeader(header) { + const headerBlock = buildBlock('header', ''); + header.append(headerBlock); + decorateBlock(headerBlock); + return loadBlock(headerBlock); +} + +/** + * Loads a block named 'footer' into footer + * @param footer footer element + * @returns {Promise} + */ +async function loadFooter(footer) { + const footerBlock = buildBlock('footer', ''); + footer.append(footerBlock); + decorateBlock(footerBlock); + return loadBlock(footerBlock); +} + +/** + * Load LCP block and/or wait for LCP in default content. + * @param {Array} lcpBlocks Array of blocks + */ +async function waitForLCP(lcpBlocks) { + const block = document.querySelector('.block'); + const hasLCPBlock = block && lcpBlocks.includes(block.dataset.blockName); + if (hasLCPBlock) await loadBlock(block); + + document.body.style.display = null; + const lcpCandidate = document.querySelector('main img'); + + await new Promise((resolve) => { + if (lcpCandidate && !lcpCandidate.complete) { + lcpCandidate.setAttribute('loading', 'eager'); + lcpCandidate.addEventListener('load', resolve); + lcpCandidate.addEventListener('error', resolve); + } else { + resolve(); + } + }); +} + +init(); + +export { + buildBlock, + createOptimizedPicture, + decorateBlock, + decorateBlocks, + decorateButtons, + decorateIcons, + decorateSections, + decorateTemplateAndTheme, + fetchPlaceholders, + getMetadata, + loadBlock, + loadBlocks, + loadCSS, + loadFooter, + loadHeader, + loadScript, + readBlockConfig, + sampleRUM, + setup, + toCamelCase, + toClassName, + updateSectionsStatus, + waitForLCP, + wrapTextNodes, +}; diff --git a/plugins/experimentation/tests/fixtures/audiences/fragment-level--alt.html b/plugins/experimentation/tests/fixtures/audiences/fragment-level--alt.html new file mode 100644 index 00000000..0dffb768 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/fragment-level--alt.html @@ -0,0 +1,23 @@ + + + + + + + +

+
+
+
+
Hello World!
+
+
+
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/fragment-level--async.html b/plugins/experimentation/tests/fixtures/audiences/fragment-level--async.html new file mode 100644 index 00000000..64b9e1dd --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/fragment-level--async.html @@ -0,0 +1,25 @@ + + + + + + + +
+
+
+ + + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/fragment-level--invalid-url.html b/plugins/experimentation/tests/fixtures/audiences/fragment-level--invalid-url.html new file mode 100644 index 00000000..19cd8305 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/fragment-level--invalid-url.html @@ -0,0 +1,23 @@ + + + + + + + +
+
+
+
+
Hello World!
+
+
+
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/fragment-level.html b/plugins/experimentation/tests/fixtures/audiences/fragment-level.html new file mode 100644 index 00000000..af5b73b2 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/fragment-level.html @@ -0,0 +1,23 @@ + + + + + + + +
+
+
+
+
Hello World!
+
+
+
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/fragments--alt.json b/plugins/experimentation/tests/fixtures/audiences/fragments--alt.json new file mode 100644 index 00000000..322072e5 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/fragments--alt.json @@ -0,0 +1,17 @@ +{ + "data": [ + { + "Pages": "/tests/fixtures/audiences/fragment-level--alt", + "Audiences": "Foo", + "Selectors": ".fragment", + "Urls": "/tests/fixtures/audiences/variant-1" + }, + { + "Pages": "/tests/fixtures/audiences/fragment-level--alt", + "Audiences": "Bar", + "Selectors": ".fragment", + "Urls": "/tests/fixtures/audiences/variant-2" + } + ], + ":type": "sheet" +} \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/fragments.json b/plugins/experimentation/tests/fixtures/audiences/fragments.json new file mode 100644 index 00000000..104f1575 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/fragments.json @@ -0,0 +1,29 @@ +{ + "data": [ + { + "Page": "/tests/fixtures/audiences/fragment-level", + "Audience": "Foo", + "Selector": ".fragment", + "Url": "/tests/fixtures/audiences/variant-1" + }, + { + "Page": "/tests/fixtures/audiences/fragment-level", + "Audience": "Bar", + "Selector": ".fragment", + "Url": "/tests/fixtures/audiences/variant-2" + }, + { + "Page": "/tests/fixtures/audiences/fragment-level--async", + "Audience": "Foo", + "Selector": ".fragment", + "Url": "/tests/fixtures/audiences/variant-1" + }, + { + "Page": "/tests/fixtures/audiences/fragment-level--async", + "Audience": "Bar", + "Selector": ".fragment", + "Url": "/tests/fixtures/audiences/variant-2" + } + ], + ":type": "sheet" +} \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/page-level--async.html b/plugins/experimentation/tests/fixtures/audiences/page-level--async.html new file mode 100644 index 00000000..267d52b6 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/page-level--async.html @@ -0,0 +1,18 @@ + + + + + + + + +
+
Hello World!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/page-level--empty.html b/plugins/experimentation/tests/fixtures/audiences/page-level--empty.html new file mode 100644 index 00000000..1f050dd5 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/page-level--empty.html @@ -0,0 +1,18 @@ + + + + + + + + +
+
Hello World!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/page-level--invalid-url.html b/plugins/experimentation/tests/fixtures/audiences/page-level--invalid-url.html new file mode 100644 index 00000000..6fec3c0c --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/page-level--invalid-url.html @@ -0,0 +1,18 @@ + + + + + + + + +
+
Hello World!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/page-level--invalid.html b/plugins/experimentation/tests/fixtures/audiences/page-level--invalid.html new file mode 100644 index 00000000..2ebf689a --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/page-level--invalid.html @@ -0,0 +1,18 @@ + + + + + + + + +
+
Hello World!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/page-level--redirect.html b/plugins/experimentation/tests/fixtures/audiences/page-level--redirect.html new file mode 100644 index 00000000..fc061f7a --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/page-level--redirect.html @@ -0,0 +1,20 @@ + + + + + + + + + + +
+
Hello World!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/page-level.html b/plugins/experimentation/tests/fixtures/audiences/page-level.html new file mode 100644 index 00000000..6bd63962 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/page-level.html @@ -0,0 +1,19 @@ + + + + + + + + + +
+
Hello World!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/section-level.html b/plugins/experimentation/tests/fixtures/audiences/section-level.html new file mode 100644 index 00000000..e064b2c7 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/section-level.html @@ -0,0 +1,22 @@ + + + + + + +
+
+
Hello World!
+ +
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/variant-1.html b/plugins/experimentation/tests/fixtures/audiences/variant-1.html new file mode 100644 index 00000000..17dc08dc --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/variant-1.html @@ -0,0 +1,9 @@ + + + + +
+
Hello v1!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/audiences/variant-2.html b/plugins/experimentation/tests/fixtures/audiences/variant-2.html new file mode 100644 index 00000000..d057125e --- /dev/null +++ b/plugins/experimentation/tests/fixtures/audiences/variant-2.html @@ -0,0 +1,9 @@ + + + + +
+
Hello v2!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/fragment-level--alt.html b/plugins/experimentation/tests/fixtures/campaigns/fragment-level--alt.html new file mode 100644 index 00000000..98e88fa7 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/fragment-level--alt.html @@ -0,0 +1,17 @@ + + + + + + +
+
+
+
+
Hello World!
+
+
+
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/fragment-level--async.html b/plugins/experimentation/tests/fixtures/campaigns/fragment-level--async.html new file mode 100644 index 00000000..cbb28471 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/fragment-level--async.html @@ -0,0 +1,19 @@ + + + + + + +
+
+
+ + + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/fragment-level--invalid-url.html b/plugins/experimentation/tests/fixtures/campaigns/fragment-level--invalid-url.html new file mode 100644 index 00000000..32a782ee --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/fragment-level--invalid-url.html @@ -0,0 +1,17 @@ + + + + + + +
+
+
+
+
Hello World!
+
+
+
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/fragment-level.html b/plugins/experimentation/tests/fixtures/campaigns/fragment-level.html new file mode 100644 index 00000000..5cbd580c --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/fragment-level.html @@ -0,0 +1,17 @@ + + + + + + +
+
+
+
+
Hello World!
+
+
+
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/fragments--alt.json b/plugins/experimentation/tests/fixtures/campaigns/fragments--alt.json new file mode 100644 index 00000000..b684ee64 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/fragments--alt.json @@ -0,0 +1,17 @@ +{ + "data": [ + { + "Pages": "/tests/fixtures/campaigns/fragment-level--alt", + "Campaigns": "Foo", + "Selectors": ".fragment", + "Urls": "/tests/fixtures/campaigns/variant-1" + }, + { + "Pages": "/tests/fixtures/campaigns/fragment-level--alt", + "Campaigns": "Bar", + "Selectors": ".fragment", + "Urls": "/tests/fixtures/campaigns/variant-2" + } + ], + ":type": "sheet" +} \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/fragments.json b/plugins/experimentation/tests/fixtures/campaigns/fragments.json new file mode 100644 index 00000000..c3de752d --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/fragments.json @@ -0,0 +1,17 @@ +{ + "data": [ + { + "Page": "/tests/fixtures/campaigns/fragment-level", + "Campaign": "Foo", + "Selector": ".fragment", + "Url": "/tests/fixtures/campaigns/variant-1" + }, + { + "Page": "/tests/fixtures/campaigns/fragment-level", + "Campaign": "Bar", + "Selector": ".fragment", + "Url": "/tests/fixtures/campaigns/variant-2" + } + ], + ":type": "sheet" +} \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/page-level--audiences.html b/plugins/experimentation/tests/fixtures/campaigns/page-level--audiences.html new file mode 100644 index 00000000..5475ed8b --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/page-level--audiences.html @@ -0,0 +1,11 @@ + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/page-level--backward-compatibility--audience.html b/plugins/experimentation/tests/fixtures/campaigns/page-level--backward-compatibility--audience.html new file mode 100644 index 00000000..e3ed2386 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/page-level--backward-compatibility--audience.html @@ -0,0 +1,11 @@ + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/page-level--empty.html b/plugins/experimentation/tests/fixtures/campaigns/page-level--empty.html new file mode 100644 index 00000000..e6aac97e --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/page-level--empty.html @@ -0,0 +1,12 @@ + + + + + + + +
+
Hello World!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/page-level--invalid-url.html b/plugins/experimentation/tests/fixtures/campaigns/page-level--invalid-url.html new file mode 100644 index 00000000..101a8b9c --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/page-level--invalid-url.html @@ -0,0 +1,12 @@ + + + + + + + +
+
Hello World!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/page-level--redirect.html b/plugins/experimentation/tests/fixtures/campaigns/page-level--redirect.html new file mode 100644 index 00000000..79714b24 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/page-level--redirect.html @@ -0,0 +1,19 @@ + + + + + + + + + +
+
Hello World!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/page-level.html b/plugins/experimentation/tests/fixtures/campaigns/page-level.html new file mode 100644 index 00000000..3aa4ebbd --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/page-level.html @@ -0,0 +1,18 @@ + + + + + + + + +
+
Hello World!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/section-level.html b/plugins/experimentation/tests/fixtures/campaigns/section-level.html new file mode 100644 index 00000000..e0b99176 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/section-level.html @@ -0,0 +1,16 @@ + + + + + +
+
+
Hello World!
+ +
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/variant-1.html b/plugins/experimentation/tests/fixtures/campaigns/variant-1.html new file mode 100644 index 00000000..17dc08dc --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/variant-1.html @@ -0,0 +1,9 @@ + + + + +
+
Hello v1!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/campaigns/variant-2.html b/plugins/experimentation/tests/fixtures/campaigns/variant-2.html new file mode 100644 index 00000000..d057125e --- /dev/null +++ b/plugins/experimentation/tests/fixtures/campaigns/variant-2.html @@ -0,0 +1,9 @@ + + + + +
+
Hello v2!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/fragment-level--alt.html b/plugins/experimentation/tests/fixtures/experiments/fragment-level--alt.html new file mode 100644 index 00000000..8d3307ad --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/fragment-level--alt.html @@ -0,0 +1,17 @@ + + + + + + +
+
+
+
+
Hello World!
+
+
+
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/fragment-level--async.html b/plugins/experimentation/tests/fixtures/experiments/fragment-level--async.html new file mode 100644 index 00000000..bab83092 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/fragment-level--async.html @@ -0,0 +1,19 @@ + + + + + + +
+
+
+ + + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/fragment-level--invalid-url.html b/plugins/experimentation/tests/fixtures/experiments/fragment-level--invalid-url.html new file mode 100644 index 00000000..fc9a675b --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/fragment-level--invalid-url.html @@ -0,0 +1,17 @@ + + + + + + +
+
+
+
+
Hello World!
+
+
+
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/fragment-level.html b/plugins/experimentation/tests/fixtures/experiments/fragment-level.html new file mode 100644 index 00000000..75f2a820 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/fragment-level.html @@ -0,0 +1,17 @@ + + + + + + +
+
+
+
+
Hello World!
+
+
+
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/fragments--alt.json b/plugins/experimentation/tests/fixtures/experiments/fragments--alt.json new file mode 100644 index 00000000..11466b2a --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/fragments--alt.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "Pages": "/tests/fixtures/experiments/fragment-level--alt", + "Experiments": "Baz", + "Variants": "challenger-1", + "Names": "C1", + "Selectors": ".fragment", + "Urls": "/tests/fixtures/experiments/section-level-v1" + }, + { + "Pages": "/tests/fixtures/experiments/fragment-level--alt", + "Experiments": "Baz", + "Variants": "challenger-2", + "Names": "C2", + "Selectors": ".fragment", + "Urls": "/tests/fixtures/experiments/section-level-v2" + } + ], + ":type": "sheet" +} \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/fragments.json b/plugins/experimentation/tests/fixtures/experiments/fragments.json new file mode 100644 index 00000000..814145db --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/fragments.json @@ -0,0 +1,37 @@ +{ + "data": [ + { + "Page": "/tests/fixtures/experiments/fragment-level", + "Experiment": "Baz", + "Variant": "challenger-1", + "Names": "C1", + "Selector": ".fragment", + "Url": "/tests/fixtures/experiments/section-level-v1" + }, + { + "Page": "/tests/fixtures/experiments/fragment-level", + "Experiment": "Baz", + "Variant": "challenger-2", + "Names": "C2", + "Selector": ".fragment", + "Url": "/tests/fixtures/experiments/section-level-v2" + }, + { + "Page": "/tests/fixtures/experiments/fragment-level--async", + "Experiment": "Baz", + "Variant": "challenger-1", + "Names": "C1", + "Selector": ".fragment", + "Url": "/tests/fixtures/experiments/section-level-v1" + }, + { + "Page": "/tests/fixtures/experiments/fragment-level--async", + "Experiment": "Baz", + "Variant": "challenger-2", + "Names": "C2", + "Selector": ".fragment", + "Url": "/tests/fixtures/experiments/section-level-v2" + } + ], + ":type": "sheet" +} \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--audiences.html b/plugins/experimentation/tests/fixtures/experiments/page-level--audiences.html new file mode 100644 index 00000000..1fab348e --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--audiences.html @@ -0,0 +1,11 @@ + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--backward-compatibility--audience.html b/plugins/experimentation/tests/fixtures/experiments/page-level--backward-compatibility--audience.html new file mode 100644 index 00000000..99f19ad0 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--backward-compatibility--audience.html @@ -0,0 +1,11 @@ + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--backward-compatibility--instant-experiment.html b/plugins/experimentation/tests/fixtures/experiments/page-level--backward-compatibility--instant-experiment.html new file mode 100644 index 00000000..50301eee --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--backward-compatibility--instant-experiment.html @@ -0,0 +1,10 @@ + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--code.html b/plugins/experimentation/tests/fixtures/experiments/page-level--code.html new file mode 100644 index 00000000..de01d6e9 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--code.html @@ -0,0 +1,10 @@ + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--empty.html b/plugins/experimentation/tests/fixtures/experiments/page-level--empty.html new file mode 100644 index 00000000..f37b132c --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--empty.html @@ -0,0 +1,10 @@ + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--empty2.html b/plugins/experimentation/tests/fixtures/experiments/page-level--empty2.html new file mode 100644 index 00000000..c8f6e1fa --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--empty2.html @@ -0,0 +1,10 @@ + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--inactive.html b/plugins/experimentation/tests/fixtures/experiments/page-level--inactive.html new file mode 100644 index 00000000..782081be --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--inactive.html @@ -0,0 +1,11 @@ + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--invalid-url.html b/plugins/experimentation/tests/fixtures/experiments/page-level--invalid-url.html new file mode 100644 index 00000000..87cb1d45 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--invalid-url.html @@ -0,0 +1,10 @@ + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--redirect.html b/plugins/experimentation/tests/fixtures/experiments/page-level--redirect.html new file mode 100644 index 00000000..392d5986 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--redirect.html @@ -0,0 +1,12 @@ + + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--split.html b/plugins/experimentation/tests/fixtures/experiments/page-level--split.html new file mode 100644 index 00000000..17fb7462 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--split.html @@ -0,0 +1,11 @@ + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--time-bound-end.html b/plugins/experimentation/tests/fixtures/experiments/page-level--time-bound-end.html new file mode 100644 index 00000000..dc3407b8 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--time-bound-end.html @@ -0,0 +1,11 @@ + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--time-bound-start.html b/plugins/experimentation/tests/fixtures/experiments/page-level--time-bound-start.html new file mode 100644 index 00000000..72e12c4e --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--time-bound-start.html @@ -0,0 +1,11 @@ + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level--time-bound.html b/plugins/experimentation/tests/fixtures/experiments/page-level--time-bound.html new file mode 100644 index 00000000..fde3028b --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level--time-bound.html @@ -0,0 +1,12 @@ + + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level-v1.html b/plugins/experimentation/tests/fixtures/experiments/page-level-v1.html new file mode 100644 index 00000000..4771e208 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level-v1.html @@ -0,0 +1,7 @@ + + + + +
Hello v1!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level-v2.html b/plugins/experimentation/tests/fixtures/experiments/page-level-v2.html new file mode 100644 index 00000000..9ff94a12 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level-v2.html @@ -0,0 +1,7 @@ + + + + +
Hello v2!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/page-level.html b/plugins/experimentation/tests/fixtures/experiments/page-level.html new file mode 100644 index 00000000..543d6f12 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/page-level.html @@ -0,0 +1,11 @@ + + + + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/section-level-v1.html b/plugins/experimentation/tests/fixtures/experiments/section-level-v1.html new file mode 100644 index 00000000..17dc08dc --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/section-level-v1.html @@ -0,0 +1,9 @@ + + + + +
+
Hello v1!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/section-level-v2.html b/plugins/experimentation/tests/fixtures/experiments/section-level-v2.html new file mode 100644 index 00000000..d057125e --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/section-level-v2.html @@ -0,0 +1,9 @@ + + + + +
+
Hello v2!
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/experiments/section-level.html b/plugins/experimentation/tests/fixtures/experiments/section-level.html new file mode 100644 index 00000000..0ffd0633 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/experiments/section-level.html @@ -0,0 +1,23 @@ + + + + + +
+
+
Hello World!
+ +
+
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/global.html b/plugins/experimentation/tests/fixtures/global.html new file mode 100644 index 00000000..70f2c9ff --- /dev/null +++ b/plugins/experimentation/tests/fixtures/global.html @@ -0,0 +1,8 @@ + + + + + +
Hello World!
+ + \ No newline at end of file diff --git a/plugins/experimentation/tests/fixtures/scripts.js b/plugins/experimentation/tests/fixtures/scripts.js new file mode 100644 index 00000000..e763cb30 --- /dev/null +++ b/plugins/experimentation/tests/fixtures/scripts.js @@ -0,0 +1,103 @@ +import { + buildBlock, + decorateButtons, + decorateIcons, + decorateSections, + decorateBlocks, + decorateTemplateAndTheme, + waitForLCP, + loadBlocks, +} from './aem.js'; + +const LCP_BLOCKS = []; // add your LCP blocks to the list + +/** + * Builds hero block and prepends to main in a new section. + * @param {Element} main The container element + */ +function buildHeroBlock(main) { + const h1 = main.querySelector('h1'); + const picture = main.querySelector('picture'); + // eslint-disable-next-line no-bitwise + if (h1 && picture && (h1.compareDocumentPosition(picture) & Node.DOCUMENT_POSITION_PRECEDING)) { + const section = document.createElement('div'); + section.append(buildBlock('hero', { elems: [picture, h1] })); + main.prepend(section); + } +} + +/** + * Builds all synthetic blocks in a container element. + * @param {Element} main The container element + */ +function buildAutoBlocks(main) { + try { + buildHeroBlock(main); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Auto Blocking failed', error); + } +} + +/** + * Decorates the main element. + * @param {Element} main The main element + */ +// eslint-disable-next-line import/prefer-default-export +export function decorateMain(main) { + // hopefully forward compatible button decoration + decorateButtons(main); + decorateIcons(main); + buildAutoBlocks(main); + decorateSections(main); + decorateBlocks(main); +} + +/** + * Loads everything needed to get to LCP. + * @param {Element} doc The container element + */ +async function loadEager(doc) { + document.documentElement.lang = 'en'; + decorateTemplateAndTheme(); + + // Add below snippet early in the eager phase + if (document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"]') + || [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i))) { + // eslint-disable-next-line import/no-absolute-path, import/no-unresolved + const { loadEager: runEager } = await import('/src/index.js'); + await runEager(document, { audiences: window.AUDIENCES || {} }); + } + + const main = doc.querySelector('main'); + if (main) { + decorateMain(main); + document.body.classList.add('appear'); + await waitForLCP(LCP_BLOCKS); + } +} + +/** + * Loads everything that doesn't need to be delayed. + * @param {Element} doc The container element + */ +async function loadLazy(doc) { + const main = doc.querySelector('main'); + await loadBlocks(main); +} + +/** + * Loads everything that happens a lot later, + * without impacting the user experience. + */ +function loadDelayed() { + // load anything that can be postponed to the latest here +} + +async function loadPage() { + await loadEager(document); + await loadLazy(document); + loadDelayed(); +} + +loadPage(); diff --git a/plugins/experimentation/tests/global.test.js b/plugins/experimentation/tests/global.test.js new file mode 100644 index 00000000..5f38d617 --- /dev/null +++ b/plugins/experimentation/tests/global.test.js @@ -0,0 +1,20 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { test, expect } from '@playwright/test'; +import { track } from './coverage.js'; + +track(test); + +test.describe('Plugin config', () => { + test('debug statements are shown in dev/stage', async ({ page }) => { + await page.goto('/tests/fixtures/global'); + await page.addScriptTag({ content: 'import { setDebugMode } from "/src/index.js"; window.setDebugMode = setDebugMode;', type: 'module' }); + expect(await page.evaluate(async () => window.setDebugMode(new URL('http://localhost:3000'), {}))).toEqual(true); + expect(await page.evaluate(async () => window.setDebugMode(new URL('https://ref--repo--org.hlx.page/'), {}))).toEqual(true); + expect(await page.evaluate(async () => window.setDebugMode(new URL('https://ref--repo--org.hlx.live/'), {}))).toEqual(false); + expect(await page.evaluate(async () => window.setDebugMode(new URL('https://ref--repo--org.hlx.live/'), { isProd: () => false }))).toEqual(true); + expect(await page.evaluate(async () => window.setDebugMode(new URL('https://stage.foo.com'), { prodHost: 'www.foo.com' }))).toEqual(true); + }); + + // test.skip('debug statements are not shown on prod'); + // test.skip('sends event with details when experiment/audience/campaign is run'); +}); diff --git a/plugins/experimentation/tests/setup.js b/plugins/experimentation/tests/setup.js new file mode 100644 index 00000000..72408938 --- /dev/null +++ b/plugins/experimentation/tests/setup.js @@ -0,0 +1,5 @@ +import { start } from './coverage.js'; + +export default async function setup() { + await start(); +} diff --git a/plugins/experimentation/tests/teardown.js b/plugins/experimentation/tests/teardown.js new file mode 100644 index 00000000..553211fc --- /dev/null +++ b/plugins/experimentation/tests/teardown.js @@ -0,0 +1,5 @@ +import { end } from './coverage.js'; + +export default async function teardown() { + await end(); +} diff --git a/plugins/experimentation/tests/utils.js b/plugins/experimentation/tests/utils.js new file mode 100644 index 00000000..38656fb1 --- /dev/null +++ b/plugins/experimentation/tests/utils.js @@ -0,0 +1,34 @@ +import { expect } from '@playwright/test'; + +async function waitForNamespace(page, namespace) { + await expect(async () => { + expect(await page.evaluate((ns) => window.hlx[ns], namespace)).toBeDefined(); + }).toPass(); + // Wait for the fragments to decorate + await new Promise((res) => { setTimeout(res); }); +} + +export async function goToAndRunAudience(page, url) { + await page.goto(url); + await waitForNamespace(page, 'audiences'); +} + +export async function goToAndRunCampaign(page, url) { + await page.goto(url); + await waitForNamespace(page, 'campaigns'); +} + +export async function goToAndRunExperiment(page, url) { + await page.goto(url); + await waitForNamespace(page, 'experiments'); +} + +export async function waitForDomEvent(page, eventName) { + await page.addInitScript((name) => { + // Override the prototype + window.eventPromise = new Promise((resolve) => { + document.addEventListener(name, (ev) => resolve(ev.detail)); + }); + }, eventName); + return async () => window.eventPromise; +} diff --git a/scripts/scripts.js b/scripts/scripts.js index 3ed722bb..3fe4c58c 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -1,16 +1,16 @@ import { sampleRUM, buildBlock, - getAllMetadata, - getMetadata, loadHeader, loadFooter, decorateButtons, decorateIcons, decorateSections, + decorateBlock, decorateBlocks, decorateTemplateAndTheme, waitForLCP, + loadBlock, loadBlocks, loadCSS, } from './lib-franklin.js'; @@ -39,15 +39,6 @@ window.hlx.plugins.add('rum-conversion', { load: 'lazy', }); -window.hlx.plugins.add('experimentation', { - condition: () => getMetadata('experiment') - || Object.keys(getAllMetadata('campaign')).length - || Object.keys(getAllMetadata('audience')).length, - options: { audiences: AUDIENCES }, - load: 'eager', - url: '/plugins/experimentation/src/index.js', -}); - /** * Determine if we are serving content for the block-library, if so don't load the header or footer * @returns {boolean} True if we are loading block library content @@ -176,13 +167,46 @@ export function decorateMain(main) { decorateBlocks(main); } +function redecorateIfHeroBlock(heroElement) { + const parent = heroElement.parentElement.parentElement; + [...heroElement.children].reverse().forEach((el) => parent.prepend(el)); + heroElement.parentElement.remove(); + heroElement.remove(); + // Rebuild and redecorate the hero block + buildHeroBlock(parent); + decorateBlocks(parent); + loadBlocks(parent); +} + +export function decorateFunction(element) { + if (element.classList.contains('hero')) { + redecorateIfHeroBlock(element); + } else if (element.classList.contains('block')) { + decorateBlock(element); + loadBlock(element); + } else if (element.classList.contains('section')) { + decorateBlocks(element); + } else if (element.tagName === 'MAIN') { + decorateMain(element); + } +} + +window.hlx.plugins.add('experimentation', { + condition: () => document.head.querySelector('[name^="experiment"],[name^="campaign-"],[name^="audience-"]') + || document.head.querySelector('[property^="campaign:"],[property^="audience:"]') + || document.querySelector('.section[class*="experiment-"],.section[class*="audience-"],.section[class*="campaign-"]') + || [...document.querySelectorAll('.section-metadata div')].some((d) => d.textContent.match(/Experiment|Campaign|Audience/i)), + options: { audiences: AUDIENCES, decorateFunction }, + load: 'eager', + url: '/plugins/experimentation/src/index.js', +}); + /** * loads everything needed to get to LCP. */ async function loadEager(doc) { document.documentElement.lang = 'en'; decorateTemplateAndTheme(); - await window.hlx.plugins.run('loadEager'); // load demo config