diff --git a/plugins/experimentation/package.json b/plugins/experimentation/package.json index e69de29b..0073aace 100644 --- a/plugins/experimentation/package.json +++ b/plugins/experimentation/package.json @@ -0,0 +1,47 @@ +{ + "name": "@adobe/aem-experimentation", + "version": "1.0.1", + "main": "src/index.js", + "scripts": { + "lint:js": "eslint src", + "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", + "url": "git+ssh://git@github.com/adobe/aem-experimentation.git" + }, + "author": "Adobe Inc.", + "license": "Apache-2.0", + "keywords": [ + "aem", + "experimentation", + "experience", + "decisioning", + "plugin", + "campaigns", + "audiences" + ], + "bugs": { + "url": "https://github.com/adobe/aem-experimentation/issues" + }, + "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/src/preview.js b/plugins/experimentation/src/preview.js deleted file mode 100644 index 68b3b0b6..00000000 --- a/plugins/experimentation/src/preview.js +++ /dev/null @@ -1,617 +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. - */ -// eslint-disable-next-line import/no-cycle -import { - debug, - getMetadata, - toClassName, -} from './index.js'; - -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); - shadow.append(link); - - const el = document.createElement('div'); - el.className = 'hlx-preview-overlay'; - shadow.append(el); - } -} -customElements.define('aem-experimentation-bar', AemExperimentationBar); - -function watchForAddedExperiences(ns, cb) { - let { length } = ns; - window.setInterval(() => { - if (length !== ns.length) { - const diff = length - ns.length; - length = ns.length; - ns.slice(diff).map((c) => cb(c)); - } - }, 1000); -} - -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(); - const style = document.createElement('style'); - style.textContent = ` - .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; - }`; - el.prepend(style); - document.body.prepend(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 = ` -${variant.label}
`,
- description: `
- ${variantName}
-(${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); - resultsURL.searchParams.set('conversioncheckpoint', options.conversionName); - - 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, -}, conversionName) { - // 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 withcontrol
.';
- } 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 = getOverlay().querySelectorAll('.hlx-popup-item')[index];
- const percentage = variantDiv.querySelector('.percentage');
- percentage.innerHTML = `
- ${bigcountformat.format(richVariants[variantName].variant_conversions)} ${conversionName} events /
- ${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 = getOverlay().querySelectorAll('.hlx-popup-item')[config.variantNames.indexOf(result.variant)];
- if (variant) {
- const performance = variant.querySelector('.performance');
- performance.innerHTML = `
- ${conversionName} conversion 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({ el, config }, container, options) {
- if (!config) {
- return;
- }
- // eslint-disable-next-line no-console
- debug('preview experiment', config.id);
-
- const domainKey = window.localStorage.getItem(DOMAIN_KEY_NAME);
- const conversionName = (el.tagName === 'MAIN'
- ? toClassName(getMetadata('conversion-name'))
- : el.dataset.conversionName
- ) || 'click';
- const pill = createPopupButton(
- `Experiment: ${config.id}`,
- {
- label: config.label,
- description: `
- ${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({ el, config }, container, options) {
- if (!config) {
- return;
- }
- const pill = createPopupButton(
- `Campaign: ${config.selectedCampaign || 'default'}`,
- {
- label: 'Campaigns on this page:',
- description: `
- ${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({ el, config }, container, options) {
- if (!config) {
- return;
- }
- const configuredAudienceNames = Object.keys(config.configuredAudiences);
- if (!Object.keys(config.configuredAudiences).length || !Object.keys(options.audiences).length) {
- return;
- }
-
- const pill = createPopupButton(
- `Audience: ${config.selectedAudience || 'default'}`,
- {
- label: 'Audiences for this page:',
- },
- [
- createAudience('default', !config.selectedAudience || config.selectedAudience === 'default', options),
- ...configuredAudienceNames
- .filter((a) => a !== 'audience')
- .map((a) => createAudience(a, config.selectedAudience === a, options)),
- ],
- {
- click: (ev) => {
- if (!ev.target.querySelector('.hlx-hidden')) {
- el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- }
- },
- mouseenter: () => { el.classList.add('hlx-highlight'); },
- mouseleave: () => {
- document.querySelectorAll('.hlx-highlight')
- .forEach((e) => e.classList.remove('hlx-highlight'));
- },
- },
- );
-
- if (config.selectedAudience) {
- pill.classList.add('is-active');
- }
- container.append(pill);
-}
-
-async function decorateAudiencesPills(container, options) {
- const ns = window.aem || window.hlx;
- if (!ns?.audiences) {
- return null;
- }
-
- watchForAddedExperiences(ns.audiences, (c) => decorateAudiencesPill(c, container, options));
- return Promise.all(ns.audiences.map((c) => decorateAudiencesPill(c, container, options)));
-}
-
-/**
- * Decorates Preview mode badges and overlays
- * @return {Object} returns a badge or empty string
- */
-export default async function decoratePreviewMode(document, options) {
- try {
- const overlay = getOverlay();
-
- await decorateAudiencesPills(overlay, options);
- await decorateCampaignPills(overlay, options);
- await decorateExperimentPills(overlay, options);
- } catch (e) {
- // eslint-disable-next-line no-console
- console.log(e);
- }
-}
-