Skip to content

Commit

Permalink
Squashed 'plugins/experimentation/' changes from 942948d..d4a5c00
Browse files Browse the repository at this point in the history
d4a5c00 test: add tests for page level redirects (#45)
285011f feat: Support page level full redirection (#43)

git-subtree-dir: plugins/experimentation
git-subtree-split: d4a5c00373904a92edd9763ab12b55dd6eed409e
  • Loading branch information
FentPams committed Aug 20, 2024
1 parent 6d370f4 commit 97e67b7
Show file tree
Hide file tree
Showing 12 changed files with 206 additions and 14 deletions.
10 changes: 10 additions & 0 deletions documentation/audiences.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ The audiences are set up directly in the page metadata block as follows:

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.
Expand Down
10 changes: 10 additions & 0 deletions documentation/campaigns.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ If you wanted to additionally restrict the campaign to specific audiences, so th
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.
Expand Down
17 changes: 17 additions & 0 deletions documentation/experiments.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ Start and end dates are in the flexible JS [Date Time String Format](https://tc3

So you can both use generic dates, like `2024-01-31` or `2024/01/31`, and time-specific dates like `2024-01-31T13:37` or `2024/01/31 1:37 pm`. You can even enforce a specific timezone so your experiment activates when, say, it's 2am GMT+1 by using `2024/1/31 2:00 pm GMT+1` or similar notations.

#### 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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "1.0.1",
"main": "src/index.js",
"scripts": {
"lint:js": "eslint src",
"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",
Expand Down
58 changes: 46 additions & 12 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,36 @@ export function toClassName(name) {
: '';
}

/**
* 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
Expand Down Expand Up @@ -321,6 +351,7 @@ function toDecisionPolicy(config) {
* 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
Expand All @@ -329,6 +360,7 @@ function toDecisionPolicy(config) {
* @returns the modification handler
*/
function createModificationsHandler(
type,
overrides,
metadataToConfig,
getExperienceUrl,
Expand All @@ -344,6 +376,13 @@ function createModificationsHandler(
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 {
Expand Down Expand Up @@ -479,6 +518,7 @@ async function applyAllModifications(
cb,
) {
const modificationsHandler = createModificationsHandler(
type,
getAllQueryParameters(paramNS),
metadataToConfig,
getExperienceUrl,
Expand Down Expand Up @@ -699,16 +739,14 @@ async function runExperiment(document, pluginOptions) {
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)}`);
window.hlx?.rum?.sampleRUM('experiment', {
source: id,
target: variant,
});
document.dispatchEvent(new CustomEvent('aem:experimentation', {
detail: {
element: el,
Expand Down Expand Up @@ -802,15 +840,13 @@ async function runCampaign(document, pluginOptions) {
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}`);
window.hlx?.rum?.sampleRUM('audience', {
source: campaign,
target: Object.keys(pluginOptions.audiences).join(':'),
});
document.dispatchEvent(new CustomEvent('aem:experimentation', {
detail: {
element: el,
Expand Down Expand Up @@ -885,14 +921,12 @@ async function serveAudience(document, pluginOptions) {
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}`);
window.hlx?.rum?.sampleRUM('audience', {
source: audience,
target: Object.keys(pluginOptions.audiences).join(':'),
});
document.dispatchEvent(new CustomEvent('aem:experimentation', {
detail: {
element: el,
Expand Down
18 changes: 18 additions & 0 deletions tests/audiences.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ test.describe('Page-level audiences', () => {
]);
});

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(
Expand Down
18 changes: 18 additions & 0 deletions tests/campaigns.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ test.describe('Page-level campaigns', () => {
]);
});

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(
Expand Down
34 changes: 34 additions & 0 deletions tests/experiments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,40 @@ test.describe('Page-level experiments', () => {
]);
});

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(
Expand Down
20 changes: 20 additions & 0 deletions tests/fixtures/audiences/page-level--redirect.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<html>
<head>
<meta name="audience-foo" content="/tests/fixtures/audiences/variant-1"/>
<meta name="audience-bar" content="/tests/fixtures/audiences/variant-2"/>
<meta name="audience-resolution" content="redirect"/>
<meta property="audience:-foo" content="/tests/fixtures/audiences/variant-1"/>
<script>
window.AUDIENCES = {
foo: () => true,
bar: () => true,
}
</script>
<script type="module" src="/tests/fixtures/scripts.js"></script>
</head>
<body>
<main>
<div>Hello World!</div>
</main>
</body>
</html>
19 changes: 19 additions & 0 deletions tests/fixtures/campaigns/page-level--redirect.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<html>
<head>
<meta name="campaign-foo" content="/tests/fixtures/campaigns/variant-1"/>
<meta name="campaign-bar" content="/tests/fixtures/campaigns/variant-2"/>
<meta name="campaign-resolution" content="redirect"/>
<script type="module" src="/tests/fixtures/scripts.js"></script>
<script>
window.AUDIENCES = {
foo: () => true,
bar: () => true,
}
</script>
</head>
<body>
<main>
<div>Hello World!</div>
</main>
</body>
</html>
12 changes: 12 additions & 0 deletions tests/fixtures/experiments/page-level--redirect.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<html>
<head>
<meta name="experiment" content="foo"/>
<meta name="experiment-variants" content="/tests/fixtures/experiments/page-level-v1,/tests/fixtures/experiments/page-level-v2"/>
<meta name="experiment-name" content="V1,V2"/>
<meta name="experiment-resolution" content="redirect"/>
<script type="module" src="/tests/fixtures/scripts.js"></script>
</head>
<body>
<main>Hello World!</main>
</body>
</html>
2 changes: 1 addition & 1 deletion tests/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,5 @@ export async function waitForDomEvent(page, eventName) {
document.addEventListener(name, (ev) => resolve(ev.detail));
});
}, eventName);
return async () => await window.eventPromise;
return async () => window.eventPromise;
}

0 comments on commit 97e67b7

Please sign in to comment.