diff --git a/src/app/components/AmpExperiment/index.test.tsx b/src/app/components/AmpExperiment/index.test.tsx
new file mode 100644
index 00000000000..bd54ba91ec0
--- /dev/null
+++ b/src/app/components/AmpExperiment/index.test.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { render, waitFor } from '../react-testing-library-with-providers';
+import AmpExperiment from './index';
+
+const experimentConfig = {
+ someExperiment: {
+ variants: {
+ control: 33,
+ variant_1: 33,
+ variant_2: 33,
+ },
+ },
+};
+
+const multipleExperimentConfig = {
+ aExperiment: {
+ variants: {
+ control: 33,
+ variant_1: 33,
+ variant_2: 33,
+ },
+ },
+ bExperiment: {
+ variants: {
+ control: 33,
+ variant_1: 33,
+ variant_2: 33,
+ },
+ },
+};
+
+describe('Amp experiment container on Amp pages', () => {
+ it('should render an amp-experiment with the expected config', async () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('amp-experiment')).toBeInTheDocument();
+ expect(container).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it('should render an amp-experiment with the expected config when multiple experiments are running at the same time', async () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.querySelector('amp-experiment')).toBeInTheDocument();
+ expect(container).toMatchInlineSnapshot(`
+
+ `);
+ });
+
+ it(`should add amp-experiment extension script to page head`, async () => {
+ render();
+
+ await waitFor(() => {
+ const scripts = Array.from(document.querySelectorAll('head script'));
+
+ expect(scripts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ src: `https://cdn.ampproject.org/v0/amp-experiment-0.1.js`,
+ }),
+ ]),
+ );
+
+ expect(scripts).toHaveLength(1);
+ });
+ });
+});
diff --git a/src/app/components/AmpExperiment/index.tsx b/src/app/components/AmpExperiment/index.tsx
new file mode 100644
index 00000000000..54e7d615c95
--- /dev/null
+++ b/src/app/components/AmpExperiment/index.tsx
@@ -0,0 +1,50 @@
+/** @jsx jsx */
+/* @jsxFrag React.Fragment */
+import { jsx } from '@emotion/react';
+import React from 'react';
+import { Helmet } from 'react-helmet';
+
+type Variant = string;
+type Experiment = string;
+type TrafficAllocationPercentage = number;
+
+type AmpExperimentConfig = {
+ [key: Experiment]: {
+ sticky?: boolean;
+ consentNotificationId?: string;
+ variants: {
+ [key: Variant]: TrafficAllocationPercentage;
+ };
+ };
+};
+
+type AmpExperimentProps = {
+ [key: Experiment]: AmpExperimentConfig;
+};
+
+const AmpHead = () => (
+
+
+
+);
+
+const AmpExperiment = ({ experimentConfig }: AmpExperimentProps) => {
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+export default AmpExperiment;
diff --git a/src/app/components/AmpExperiment/types.d.ts b/src/app/components/AmpExperiment/types.d.ts
new file mode 100644
index 00000000000..53aa9b97cb2
--- /dev/null
+++ b/src/app/components/AmpExperiment/types.d.ts
@@ -0,0 +1,7 @@
+declare namespace JSX {
+ interface IntrinsicElements {
+ 'amp-experiment': React.PropsWithChildren<
+ ScriptHTMLAttributes
+ >;
+ }
+}
diff --git a/src/app/pages/ArticlePage/ArticlePage.styles.ts b/src/app/pages/ArticlePage/ArticlePage.styles.ts
index d1ef396298b..e45f39540e9 100644
--- a/src/app/pages/ArticlePage/ArticlePage.styles.ts
+++ b/src/app/pages/ArticlePage/ArticlePage.styles.ts
@@ -81,8 +81,7 @@ export default {
paddingBottom: `${spacings.QUADRUPLE}rem`,
},
}),
-
- topStoriesAndFeaturesSection: ({ spacings, mq }: Theme) =>
+ featuresSection: ({ spacings, mq }: Theme) =>
css({
marginBottom: `${spacings.TRIPLE}rem`,
@@ -91,4 +90,21 @@ export default {
padding: `${spacings.DOUBLE}rem`,
},
}),
+ topStoriesSection: ({ spacings, mq }: Theme) =>
+ css({
+ marginBottom: `${spacings.TRIPLE}rem`,
+ [mq.GROUP_4_MIN_WIDTH]: {
+ display: 'block',
+ marginBottom: `${spacings.FULL}rem`,
+ padding: `${spacings.DOUBLE}rem`,
+ },
+ '[amp-x-topStoriesExperiment="show_at_halfway"] &': {
+ display: 'none',
+ [mq.GROUP_4_MIN_WIDTH]: {
+ display: 'block',
+ marginBottom: `${spacings.FULL}rem`,
+ padding: `${spacings.DOUBLE}rem`,
+ },
+ },
+ }),
};
diff --git a/src/app/pages/ArticlePage/ArticlePage.tsx b/src/app/pages/ArticlePage/ArticlePage.tsx
index 7e1b6f51308..334e7ce8d8f 100644
--- a/src/app/pages/ArticlePage/ArticlePage.tsx
+++ b/src/app/pages/ArticlePage/ArticlePage.tsx
@@ -59,20 +59,26 @@ import {
import { ServiceContext } from '../../contexts/ServiceContext';
import RelatedContentSection from '../../components/RelatedContentSection';
import Disclaimer from '../../components/Disclaimer';
-
import SecondaryColumn from './SecondaryColumn';
-
import styles from './ArticlePage.styles';
import { ComponentToRenderProps, TimeStampProps } from './types';
+import AmpExperiment from '../../components/AmpExperiment';
+import {
+ experimentTopStoriesConfig,
+ getExperimentTopStories,
+ ExperimentTopStories,
+} from './experimentTopStories/helpers';
const ArticlePage = ({ pageData }: { pageData: Article }) => {
- const { isApp, pageType, service } = useContext(RequestContext);
+ const { isApp, pageType, service, isAmp, id } = useContext(RequestContext);
+
const {
articleAuthor,
isTrustProjectParticipant,
showRelatedTopics,
brandName,
} = useContext(ServiceContext);
+
const { enabled: preloadLeadImageToggle } = useToggle('preloadLeadImage');
const {
@@ -131,6 +137,16 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
...(isCPS && { pageTitle: `${atiAnalytics.pageTitle} - ${brandName}` }),
};
+ const topStoriesContent = pageData?.secondaryColumn?.topStories;
+ const { shouldEnableExperimentTopStories, transformedBlocks } =
+ getExperimentTopStories({
+ blocks,
+ topStoriesContent,
+ isAmp,
+ service,
+ id,
+ });
+
const componentsToRender = {
visuallyHiddenHeadline,
headline: headings,
@@ -174,6 +190,10 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
),
podcastPromo: () => (podcastPromoEnabled ? : null),
+ experimentTopStories: () =>
+ topStoriesContent ? (
+
+ ) : null,
};
const visuallyHiddenBlock = {
@@ -183,8 +203,8 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
};
const articleBlocks = startsWithHeading
- ? blocks
- : [visuallyHiddenBlock, ...blocks];
+ ? transformedBlocks
+ : [visuallyHiddenBlock, ...transformedBlocks];
const promoImageBlocks =
pageData?.promo?.images?.defaultPromoImage?.blocks ?? [];
@@ -206,6 +226,9 @@ const ArticlePage = ({ pageData }: { pageData: Article }) => {
return (
+ {shouldEnableExperimentTopStories && (
+
+ )}
{
return (
{topStoriesContent && (
-
+
)}
{featuresContent && (
-
+
{
+ const mockTextBlock = {
+ type: 'text',
+ model: {
+ blocks: [],
+ },
+ };
+ const expectedExperimentTopStoriesBlock = (index: number) => {
+ return {
+ type: 'experimentTopStories',
+ model: topStoriesList,
+ id: `experimentTopStories-${index}`,
+ };
+ };
+
+ const blocksShortLength = [mockTextBlock];
+
+ const blocksEvenLength = [
+ mockTextBlock,
+ mockTextBlock,
+ mockTextBlock,
+ mockTextBlock,
+ ];
+ const blocksOddLength = [mockTextBlock, mockTextBlock, mockTextBlock];
+
+ describe('getExperimentTopStories()', () => {
+ it('returns shouldEnableExperimentTopStories as true if props match conditions.', () => {
+ const { shouldEnableExperimentTopStories } = getExperimentTopStories({
+ blocks: blocksEvenLength,
+ topStoriesContent: topStoriesList,
+ isAmp: true,
+ id: 'c6v11qzyv8po',
+ service: 'news',
+ });
+ expect(shouldEnableExperimentTopStories).toBe(true);
+ });
+
+ it.each`
+ testDescription | isAmp | id | service
+ ${'all props are undefined'} | ${false} | ${undefined} | ${undefined}
+ ${'only isAmp is true'} | ${true} | ${undefined} | ${undefined}
+ ${'only pathname is undefined'} | ${true} | ${undefined} | ${'news'}
+ ${'only pathname is defined and valid'} | ${false} | ${'c6v11qzyv8po'} | ${undefined}
+ ${'all props defined but pathname is invalid'} | ${false} | ${'c1231qzyv8po'} | ${undefined}
+ ${'only service is undefined'} | ${true} | ${'c6v11qzyv8po'} | ${undefined}
+ ${'only service is defined and valid'} | ${false} | ${undefined} | ${'news'}
+ ${'all props defined but service is invalid'} | ${true} | ${'c6v11qzyv8po'} | ${'igbo'}
+ `(
+ 'returns shouldEnableExperimentTopStories as false because $testDescription.',
+ ({ isAmp, id, service }) => {
+ const { shouldEnableExperimentTopStories } = getExperimentTopStories({
+ blocks: blocksEvenLength,
+ topStoriesContent: topStoriesList,
+ isAmp,
+ id,
+ service,
+ });
+
+ expect(shouldEnableExperimentTopStories).toBe(false);
+ },
+ );
+
+ const expectedBlocksEvenLength = [
+ mockTextBlock,
+ mockTextBlock,
+ expectedExperimentTopStoriesBlock(2),
+ mockTextBlock,
+ mockTextBlock,
+ ];
+ const expectedBlocksOddLength = [
+ mockTextBlock,
+ expectedExperimentTopStoriesBlock(1),
+ mockTextBlock,
+ mockTextBlock,
+ ];
+
+ it.each`
+ testType | inputBlocks | expectedOutput
+ ${'even'} | ${blocksEvenLength} | ${expectedBlocksEvenLength}
+ ${'odd'} | ${blocksOddLength} | ${expectedBlocksOddLength}
+ `(
+ 'should insert experimentTopStories block into blocks array in the correct position when blocks.length is $testType',
+ ({ inputBlocks, expectedOutput }) => {
+ const { transformedBlocks } = getExperimentTopStories({
+ blocks: inputBlocks,
+ topStoriesContent: topStoriesList,
+ isAmp: true,
+ id: 'c6v11qzyv8po',
+ service: 'news',
+ });
+ expect(transformedBlocks).toEqual(expectedOutput);
+ },
+ );
+
+ it('does not insert experiment top stories blocks if the blocks array length is < 2.', () => {
+ const { transformedBlocks } = getExperimentTopStories({
+ blocks: blocksShortLength,
+ topStoriesContent: topStoriesList,
+ isAmp: true,
+ id: 'c6v11qzyv8po',
+ service: 'news',
+ });
+ expect(transformedBlocks).toBe(blocksShortLength);
+ });
+ });
+});
diff --git a/src/app/pages/ArticlePage/experimentTopStories/helpers.tsx b/src/app/pages/ArticlePage/experimentTopStories/helpers.tsx
new file mode 100644
index 00000000000..e169d89a158
--- /dev/null
+++ b/src/app/pages/ArticlePage/experimentTopStories/helpers.tsx
@@ -0,0 +1,116 @@
+/** @jsx jsx */
+import { jsx } from '@emotion/react';
+import { OptimoBlock } from '#app/models/types/optimo';
+import { TopStoryItem } from '#app/pages/ArticlePage/PagePromoSections/TopStoriesSection/types';
+import TopStoriesSection from '../PagePromoSections/TopStoriesSection';
+import styles from './index.styles';
+
+export const experimentTopStoriesConfig = {
+ topStoriesExperiment: {
+ variants: {
+ control: 50,
+ show_at_halfway: 50,
+ },
+ },
+};
+
+const enableExperimentTopStories = ({
+ topStoriesContent,
+ isAmp,
+ service,
+ id,
+}: {
+ topStoriesContent: TopStoryItem[] | undefined;
+ isAmp: boolean;
+ service: string;
+ id: string | null;
+}) => {
+ if (!topStoriesContent || !isAmp || !service || !id) return false;
+ const newsTestAsset = 'c6v11qzyv8po';
+ const newsAsset = 'cz7xywn940ro';
+ const sportAsset = 'cpgw0xjmpd3o';
+ const experimentAssets = [newsAsset, newsTestAsset, sportAsset];
+ const experimentServices = ['news', 'sport'];
+
+ return (
+ isAmp &&
+ id &&
+ experimentServices.includes(service) &&
+ experimentAssets.includes(id)
+ );
+};
+
+const insertExperimentTopStories = ({
+ blocks,
+ topStoriesContent,
+}: {
+ blocks: OptimoBlock[];
+ topStoriesContent: TopStoryItem[];
+}) => {
+ const insertIndex = Math.floor(blocks.length * 0.5); // halfway index of blocks array
+ const experimentTopStoriesBlock = {
+ type: 'experimentTopStories',
+ model: topStoriesContent,
+ id: `experimentTopStories-${insertIndex}`,
+ };
+
+ const blocksClone = [...blocks];
+ blocksClone.splice(insertIndex, 0, experimentTopStoriesBlock);
+ return blocksClone;
+};
+
+export const getExperimentTopStories = ({
+ blocks,
+ topStoriesContent,
+ isAmp,
+ service,
+ id,
+}: {
+ blocks: OptimoBlock[];
+ topStoriesContent: TopStoryItem[] | undefined;
+ isAmp: boolean;
+ service: string;
+ id: string | null;
+}) => {
+ if (!topStoriesContent)
+ return {
+ transformedBlocks: blocks,
+ shouldEnableExperimentTopStories: false,
+ };
+
+ const shouldEnableExperimentTopStories = enableExperimentTopStories({
+ topStoriesContent,
+ isAmp,
+ service,
+ id,
+ });
+
+ const transformedBlocks =
+ shouldEnableExperimentTopStories && blocks.length > 2
+ ? insertExperimentTopStories({
+ blocks,
+ topStoriesContent,
+ })
+ : blocks;
+
+ return {
+ transformedBlocks,
+ shouldEnableExperimentTopStories,
+ };
+};
+
+export const ExperimentTopStories = ({
+ topStoriesContent,
+}: {
+ topStoriesContent: TopStoryItem[];
+}) => {
+ return (
+
+
+
+ );
+};
diff --git a/src/app/pages/ArticlePage/experimentTopStories/index.styles.ts b/src/app/pages/ArticlePage/experimentTopStories/index.styles.ts
new file mode 100644
index 00000000000..f59bfb8ffbb
--- /dev/null
+++ b/src/app/pages/ArticlePage/experimentTopStories/index.styles.ts
@@ -0,0 +1,15 @@
+import { css, Theme } from '@emotion/react';
+
+export default {
+ experimentTopStoriesSection: ({ spacings, mq }: Theme) =>
+ css({
+ display: 'none',
+ marginBottom: `${spacings.TRIPLE}rem`,
+ '[amp-x-topStoriesExperiment="show_at_halfway"] &': {
+ display: 'block',
+ [mq.GROUP_4_MIN_WIDTH]: {
+ display: 'none',
+ },
+ },
+ }),
+};
diff --git a/src/app/pages/ArticlePage/index.test.tsx b/src/app/pages/ArticlePage/index.test.tsx
index f7acc2bf37c..96499af100d 100644
--- a/src/app/pages/ArticlePage/index.test.tsx
+++ b/src/app/pages/ArticlePage/index.test.tsx
@@ -39,6 +39,7 @@ import { ServiceContextProvider } from '../../contexts/ServiceContext';
import ArticlePage from './ArticlePage';
import ThemeProvider from '../../components/ThemeProvider';
import ATIAnalytics from '../../components/ATIAnalytics';
+import { topStoriesList } from './PagePromoSections/TopStoriesSection/fixture/index';
jest.mock('../../components/ThemeProvider');
@@ -64,6 +65,8 @@ type Props = {
showAdsBasedOnLocation?: boolean;
isApp?: boolean;
promo?: boolean | null;
+ isAmp?: boolean;
+ id?: string | null;
};
const Context = ({
@@ -74,12 +77,16 @@ const Context = ({
showAdsBasedOnLocation = false,
isApp = false,
promo = null,
+ isAmp = false,
+ id,
}: PropsWithChildren = {}) => {
const appInput = {
...input,
service,
showAdsBasedOnLocation,
isApp,
+ isAmp,
+ id,
};
return (
@@ -869,4 +876,69 @@ describe('Article Page', () => {
);
});
});
+
+ describe('when rendering an AMP page', () => {
+ const pageDataWithSecondaryColumn = {
+ ...articleDataNews,
+ secondaryColumn: {
+ topStories: topStoriesList,
+ features: [],
+ },
+ };
+
+ const renderAmpPage = ({
+ service,
+ id,
+ }: {
+ service: Services;
+ id: string | null;
+ }) => {
+ return render(
+
+
+ ,
+ {
+ isAmp: true,
+ service,
+ id,
+ },
+ );
+ };
+
+ const validNewsAsset = 'c6v11qzyv8po';
+ const validSportAsset = 'cpgw0xjmpd3o';
+
+ it.each`
+ service | id
+ ${'news'} | ${validNewsAsset}
+ ${'sport'} | ${validSportAsset}
+ `(
+ 'should render page with experiment-top-stories blocks only on specific $service assets',
+ ({ service, id }) => {
+ const { queryByTestId } = renderAmpPage({
+ service,
+ id,
+ });
+
+ expect(queryByTestId('experiment-top-stories')).toBeInTheDocument();
+ },
+ );
+
+ it.each`
+ service | id | testDescription
+ ${'news'} | ${'c1231qzyv8po'} | ${'news assets not specified'}
+ ${'sport'} | ${'c1231qzyv8po'} | ${'sport assets not specified'}
+ ${'pidgin'} | ${'c6v11qzyv8po'} | ${`services which are not 'news' or 'sport'`}
+ `(
+ 'should render page without experiment-top-stories blocks on $testDescription',
+ ({ service, id }) => {
+ const { queryByTestId } = renderAmpPage({
+ service,
+ id,
+ });
+
+ expect(queryByTestId('experiment-top-stories')).not.toBeInTheDocument();
+ },
+ );
+ });
});