Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0e57ea3
Add puzzles page to define payload contract
gustavo-olmedo Sep 1, 2026
ab6ed05
Add an experiment helper to centralize name and enabled group
gustavo-olmedo Sep 1, 2026
daae2cb
Update types remove useless id
gustavo-olmedo Sep 1, 2026
f0d7465
Add recursive validation for frontend payload
gustavo-olmedo Sep 1, 2026
1a6b553
Add base layout for puzzles
gustavo-olmedo Sep 1, 2026
edb1700
Add initial puzzle page
gustavo-olmedo Sep 1, 2026
1701a40
Add the html renderer, this converts a validated puzzles page into a …
gustavo-olmedo Sep 1, 2026
c253edb
Add guarded request handler
gustavo-olmedo Sep 1, 2026
10b6d73
Update to register routes, these expose dev GET /PuzzlesPage/*url nad…
gustavo-olmedo Sep 1, 2026
8425839
Add shared test fixtures
gustavo-olmedo Sep 1, 2026
c90eaed
Add tests for puzzles hub experiment lib
gustavo-olmedo Sep 1, 2026
af3a537
Add tests for the validation puzzles part
gustavo-olmedo Sep 1, 2026
bdcb3f1
Add tests for 200, 400, invalid payloads, and using the experiment
gustavo-olmedo Sep 1, 2026
77a9c86
Merge branch 'main' into puzzles/add-dcr-base-page-pipeline
gustavo-olmedo Sep 2, 2026
5548a86
Merge branch 'main' into puzzles/add-dcr-base-page-pipeline
gustavo-olmedo Sep 2, 2026
56fe707
chore(deps): bump the storybook group across 1 directory with 4 updat…
dependabot[bot] Sep 2, 2026
718002c
prevent adding 100% tests without intermediate smaller test (#16623)
Jakeii Sep 2, 2026
4b157d6
Remove superfluous custom resource logging (#16665)
Jakeii Sep 2, 2026
f2db1f1
Fix build
gustavo-olmedo Sep 3, 2026
428128c
Fix run chromatic
gustavo-olmedo Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion ab-testing/config/scripts/build/test-group-mvt-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,8 @@ test("TestGroupMVTManager - stress test with maximum MVTs", () => {
const manager = new TestGroupMVTManager(emptyAudienceSpace);

// Add a group that uses all available MVTs
manager.addTestGroup("test1:control", 1000);
manager.addTestGroup("test1:control", 200);
manager.resizeTestGroup("test1:control", 1000);

const testGroup = manager.getTestGroup("test1:control");
equal(testGroup?.length, 1000);
Expand Down Expand Up @@ -379,3 +380,33 @@ test("TestGroupMVTManager - constructor with invalid MVT key format", () => {
false,
);
});

test("TestGroupMVTManager - adding a test with size 1000 should throw an error", () => {
const emptyAudienceSpace = new Map<
string,
{ name: string; type: string; exp: number }
>();
const manager = new TestGroupMVTManager(emptyAudienceSpace);

throws(
() => manager.addTestGroup("test1:control", 1000),
Error,
"Size for for new test, test1:control, cannot be 100%, please add it as a 20-50% first and scale up to 100% after a few hours, see https://github.com/guardian/dotcom-rendering/blob/9370061e00535d4e280c573ea27cc3095d7e2b8a/dotcom-rendering/docs/development/ab-testing-in-dcr.md#L43 for details on why",
);
});

test("TestGroupMVTManager - resizing a test from 0 to 1000 should throw an error", () => {
const emptyAudienceSpace = new Map<
string,
{ name: string; type: string; exp: number }
>();
const manager = new TestGroupMVTManager(emptyAudienceSpace);

manager.addTestGroup("test1:control", 10);

throws(
() => manager.resizeTestGroup("test1:control", 1000),
Error,
"Cannot resize test test1:control from 10% to 100%, please scale it to 20-50% first then up to 100% after a few hours, see https://github.com/guardian/dotcom-rendering/blob/9370061e00535d4e280c573ea27cc3095d7e2b8a/dotcom-rendering/docs/development/ab-testing-in-dcr.md#L43 for details on why",
);
});
11 changes: 11 additions & 0 deletions ab-testing/config/scripts/build/test-group-mvt-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ class TestGroupMVTManager {
if (!Number.isInteger(size)) {
throw new Error(`Size for test ${name} must be an integer`);
}
if (size === 1000) {
throw new Error(
`Size for for new test, ${name}, cannot be 100%, please add it as a 20-50% first and scale up to 100% after a few hours, see https://github.com/guardian/dotcom-rendering/blob/9370061e00535d4e280c573ea27cc3095d7e2b8a/dotcom-rendering/docs/development/ab-testing-in-dcr.md#L43 for details on why`,
);
}
const mvts = this.availableMVTs.splice(0, size);
this.testGroups.set(name, mvts);
mvts.forEach((mvt) => {
Expand All @@ -103,6 +108,12 @@ class TestGroupMVTManager {
const currentMVTs = this.testGroups.get(name) ?? [];
const currentSize = currentMVTs.length;

if (currentSize < 200 && newSize === 1000) {
throw new Error(
`Cannot resize test ${name} from ${Math.round((currentSize / 1000) * 100)}% to 100%, please scale it to 20-50% first then up to 100% after a few hours, see https://github.com/guardian/dotcom-rendering/blob/9370061e00535d4e280c573ea27cc3095d7e2b8a/dotcom-rendering/docs/development/ab-testing-in-dcr.md#L43 for details on why`,
);
}

if (newSize > currentSize) {
const additionalMVTsNeeded = newSize - currentSize;
if (this.availableMVTs.length < additionalMVTsNeeded) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,6 @@ const send = async (
Data: responseData,
});

console.log("Response body:\n", responseBody);
console.log("Sending response to:", event.ResponseURL);

try {
await fetch(event.ResponseURL, {
method: "PUT",
Expand Down
102 changes: 102 additions & 0 deletions dotcom-rendering/fixtures/manual/puzzlesPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import type {
FEPuzzlesPageType,
PuzzlesLayoutType,
} from '../../src/types/puzzlesPage';
import { Standard } from '../generated/fe-articles/Standard';

export const minimalPuzzlesLayout: PuzzlesLayoutType = {
filters: [],
containers: [],
};

export const fullPuzzlesLayout: PuzzlesLayoutType = {
filters: [
{
id: 'word-games',
title: 'Word games',
target: '#word-games',
backgroundColour: '#f9d4e8',
},
],
containers: [
{
title: 'Word games',
variant: 'standard',
filterId: 'word-games',
content: {
items: [
[
{
id: 'wordiply-daily',
title: 'Wordiply',
type: 'wordiply',
set: 'all',
cardVariant: 'primary',
cadence: 'Daily',
slug: 'wordiply',
url: 'https://www.wordiply.com/',
variant: 'iframe-page',
backgroundColour: '#f9d4e8',
filterId: 'word-games',
},
],
],
nestedContainers: [
{
title: 'Word wheel',
desktopSpan: 6,
content: {
items: [
[
{
id: 'word-wheel-daily',
title: 'Word wheel',
type: 'word-wheel',
set: 'all',
cardVariant: 'compact',
cadence: 'Daily',
index: 1,
},
],
],
nestedContainers: [],
archive: {
id: 'word-wheel-archive',
title: 'Word wheel archive',
type: 'word-wheel',
set: 'all',
cardVariant: 'archive',
slug: 'word-wheel',
url: '/puzzles/word-wheel/archive',
variant: 'archive-page',
},
},
},
],
},
},
],
};

export const createPuzzlesPage = (
overrides: Partial<FEPuzzlesPageType> = {},
): FEPuzzlesPageType => ({
id: 'puzzles',
editionId: Standard.editionId,
editionLongForm: Standard.editionLongForm,
contributionsServiceUrl: Standard.contributionsServiceUrl,
webTitle: 'Puzzles & Games',
description: 'Play the Guardian’s daily puzzles and games.',
config: {
...Standard.config,
contentType: 'Puzzles',
serverSideABTests: {},
},
nav: Standard.nav,
pageFooter: Standard.pageFooter,
commercialProperties: Standard.commercialProperties,
isAdFreeUser: false,
canonicalUrl: 'https://www.theguardian.com/puzzles',
layout: minimalPuzzlesLayout,
...overrides,
});
8 changes: 4 additions & 4 deletions dotcom-rendering/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@
"@opentelemetry/sdk-node": "0.219.0",
"@playwright/test": "1.60.0",
"@sentry/browser": "10.65.0",
"@storybook/addon-a11y": "10.5.7",
"@storybook/addon-docs": "10.5.7",
"@storybook/addon-a11y": "10.5.10",
"@storybook/addon-docs": "10.5.10",
"@storybook/addon-webpack5-compiler-swc": "4.0.3",
"@storybook/react-webpack5": "10.5.7",
"@storybook/react-webpack5": "10.5.10",
"@svgr/webpack": "8.1.0",
"@swc/cli": "0.8.1",
"@swc/core": "1.16.1",
Expand Down Expand Up @@ -148,7 +148,7 @@
"screenfull": "6.0.2",
"semver": "7.5.4",
"source-map": "0.7.4",
"storybook": "10.5.7",
"storybook": "10.5.10",
"stylelint": "16.26.1",
"stylelint-config-recommended": "14.0.0",
"swc-loader": "0.2.7",
Expand Down
59 changes: 59 additions & 0 deletions dotcom-rendering/src/components/PuzzlesPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Global } from '@emotion/react';
import { StrictMode } from 'react';
import { PuzzlesLayout } from '../layouts/PuzzlesLayout';
import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat';
import { rootStyles } from '../lib/rootStyles';
import type { NavType } from '../model/extract-nav';
import type { FEPuzzlesPageType } from '../types/puzzlesPage';
import { AdmiralScript } from './AdmiralScript.island';
import { AlreadyVisited } from './AlreadyVisited.island';
import { useConfig } from './ConfigContext';
import { FocusStyles } from './FocusStyles.island';
import { Island } from './Island';
import { Metrics } from './Metrics.island';
import { SetABTests } from './SetABTests.island';
import { SkipTo } from './SkipTo';

type Props = {
puzzlesPage: FEPuzzlesPageType;
NAV: NavType;
};

export const PuzzlesPage = ({ puzzlesPage, NAV }: Props) => {
const format = {
display: ArticleDisplay.Standard,
design: ArticleDesign.Standard,
theme: Pillar.Lifestyle,
};
const { darkModeAvailable } = useConfig();

return (
<StrictMode>
<Global styles={rootStyles(format, darkModeAvailable)} />
<SkipTo id="maincontent" label="Skip to main content" />
<SkipTo id="navigation" label="Skip to navigation" />
<Island priority="feature" defer={{ until: 'idle' }}>
<AlreadyVisited />
</Island>
<Island priority="feature" defer={{ until: 'idle' }}>
<AdmiralScript />
</Island>
<Island priority="feature" defer={{ until: 'idle' }}>
<FocusStyles />
</Island>
<Island priority="critical">
<Metrics
commercialMetricsEnabled={
!!puzzlesPage.config.switches.commercialMetrics
}
/>
</Island>
<Island priority="critical">
<SetABTests
serverSideABTests={puzzlesPage.config.serverSideABTests}
/>
</Island>
<PuzzlesLayout puzzlesPage={puzzlesPage} NAV={NAV} />
</StrictMode>
);
};
79 changes: 79 additions & 0 deletions dotcom-rendering/src/layouts/PuzzlesLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { css } from '@emotion/react';
import { headlineBold34, palette, space } from '@guardian/source/foundations';
import { Footer } from '../components/Footer';
import { HeaderAdSlot } from '../components/HeaderAdSlot';
import { Masthead } from '../components/Masthead/Masthead';
import { Section } from '../components/Section';
import type { NavType } from '../model/extract-nav';
import type { FEPuzzlesPageType } from '../types/puzzlesPage';
import { Stuck } from './lib/stickiness';

type Props = {
puzzlesPage: FEPuzzlesPageType;
NAV: NavType;
};

const heading = css`
margin: 0;
padding: ${space[6]}px 0 ${space[12]}px;
${headlineBold34};
`;

/**
* The base puzzles page shell. Blueprint-driven composition is intentionally
* deferred to the next task.
*/
export const PuzzlesLayout = ({ puzzlesPage, NAV }: Props) => (
<>
<div data-print-layout="hide" id="bannerandheader">
{!puzzlesPage.isAdFreeUser && (
<Stuck>
<Section
fullWidth={true}
showTopBorder={false}
showSideBorders={false}
padSides={false}
shouldCenter={false}
>
<HeaderAdSlot />
</Section>
</Stuck>
)}
<Masthead
nav={NAV}
editionId={puzzlesPage.editionId}
idUrl={puzzlesPage.config.idUrl}
mmaUrl={puzzlesPage.config.mmaUrl}
discussionApiUrl={puzzlesPage.config.discussionApiUrl}
idApiUrl={puzzlesPage.config.idApiUrl}
contributionsServiceUrl={puzzlesPage.contributionsServiceUrl}
showSubNav={true}
showSlimNav={false}
hasPageSkin={false}
hasPageSkinContentSelfConstrain={false}
/>
</div>

<main data-layout="PuzzlesPageLayout" id="maincontent">
<Section fullWidth={true} showTopBorder={false}>
<h1 css={heading}>{puzzlesPage.webTitle}</h1>
</Section>
</main>

<Section
fullWidth={true}
padSides={false}
backgroundColour={palette.brand[400]}
borderColour={palette.brand[600]}
showSideBorders={false}
element="footer"
>
<Footer
pageFooter={puzzlesPage.pageFooter}
pillars={NAV.pillars}
urls={NAV.readerRevenueLinks.footer}
editionId={puzzlesPage.editionId}
/>
</Section>
</>
);
29 changes: 29 additions & 0 deletions dotcom-rendering/src/lib/puzzlesHubExperiment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
isPuzzlesHubEnabled,
puzzlesHubExperiment,
puzzlesHubParticipation,
} from './puzzlesHubExperiment';

describe('isPuzzlesHubEnabled', () => {
it('enables only the configured variant', () => {
expect(
isPuzzlesHubEnabled({
serverSideABTests: puzzlesHubParticipation(
puzzlesHubExperiment.variant,
),
}),
).toBe(true);
});

it.each([
puzzlesHubParticipation(puzzlesHubExperiment.control),
puzzlesHubParticipation('unknown'),
puzzlesHubParticipation('variant:extra'),
{},
{ 'another-test': 'variant' },
])('returns false for non-variant participation %#', (participations) => {
expect(isPuzzlesHubEnabled({ serverSideABTests: participations })).toBe(
false,
);
});
});
21 changes: 21 additions & 0 deletions dotcom-rendering/src/lib/puzzlesHubExperiment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { ConfigType } from '../types/config';

export const puzzlesHubExperiment = {
name: 'puzzles-new-hub',
variant: 'variant',
control: 'control',
} as const;

type PuzzlesExperimentConfig = Pick<ConfigType, 'serverSideABTests'>;

export const isPuzzlesHubEnabled = ({
serverSideABTests,
}: PuzzlesExperimentConfig): boolean =>
serverSideABTests[puzzlesHubExperiment.name] ===
puzzlesHubExperiment.variant;

export const puzzlesHubParticipation = (
group: string,
): Record<string, string> => ({
[puzzlesHubExperiment.name]: group,
});
Loading
Loading