Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a39bfe8
Add Gandalf sign-in gate counter, popup hosting and Ophan identity
Sep 2, 2026
7d6acb6
Merge branch 'main' into nz-non-auxia-sign-in-gate
juabara Sep 9, 2026
4c2c3aa
Add Gandalf sign-in gate counter, popup hosting and Ophan identity
Sep 2, 2026
e50a03b
Merge branch 'nz-non-auxia-sign-in-gate' of github.com:guardian/dotco…
Sep 9, 2026
2a9362a
Show the Gandalf mandatory popup without waiting for scroll
Sep 10, 2026
31259dc
Add view deduplication to prevent duplicate Gandalf sign-in gate trac…
Sep 11, 2026
fd536f7
Remove per-country Gandalf pageview counter in favor of global count
Sep 14, 2026
c26568b
Merge branch 'main' into nz-non-auxia-sign-in-gate
juabara Sep 14, 2026
d7f7665
Replace Gandalf pageview counter with daily article count for sign-in…
Sep 14, 2026
f3ad760
Merge branch 'nz-non-auxia-sign-in-gate' of github.com:guardian/dotco…
Sep 14, 2026
458e931
Drop redundant gandalfPageViewCount in favour of dailyArticleCount
Sep 15, 2026
fa53652
Merge branch 'main' into nz-non-auxia-sign-in-gate
juabara Sep 16, 2026
9bf21cb
Skip SDC request for surfaces that can never show sign-in gate
Sep 16, 2026
ea09113
Merge branch 'main' into nz-non-auxia-sign-in-gate
juabara Sep 16, 2026
63e30ba
Merge branch 'nz-non-auxia-sign-in-gate' of github.com:guardian/dotco…
Sep 17, 2026
97bfc78
Exclude additional page paths from sign-in gate and defer article pop…
Sep 17, 2026
ea039f8
Merge branch 'main' into nz-non-auxia-sign-in-gate
juabara Sep 17, 2026
7fe99dc
Refactor sign-in gate placeholder and scroll detection logic
Sep 17, 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
64 changes: 62 additions & 2 deletions dotcom-rendering/playwright/tests/banner.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,74 @@ test.describe('Sign-in gate portal', function () {
await cmpAcceptAll(page);

await page.evaluate(() => {
// Set geolocation to IE to force the sign-in gate to appear
window.localStorage.setItem('gu.geo.override', 'IE');
// Set geolocation to IE to force the sign-in gate to appear.
// storage.local expects the { value } wrapper format.
window.localStorage.setItem(
'gu.geo.override',
JSON.stringify({ value: 'IE' }),
);
});

await page.reload({ waitUntil: 'domcontentloaded' });

await auxiaRequestPromise;
});

test('sends the daily view count for New Zealand readers', async ({
page,
context,
}) => {
await optOutOfArticleCountConsent(context);

const auxiaUrl =
'https://contributions.guardianapis.com/auxia/get-treatments';
const auxiaRequestPromise = page.waitForRequest((request) => {
if (!requestBodyHasProperties(request, auxiaUrl, ['isSupporter'])) {
return false;
}
const body = request.postDataJSON() as Record<string, unknown>;
// Match only the post-reload request: the first load runs with the
// default (GB) geolocation.
return body.countryCode === 'NZ';
});

await loadPage({
page,
path: ARTICLE_PATH,
waitUntil: 'domcontentloaded',
region: 'GB',
preventSupportBanner: false,
overrides: {
configOverrides: {
frontendAssetsFullURL: LOCAL_ASSET_ORIGIN,
},
},
});

await cmpAcceptAll(page);

// Set geolocation to NZ for the Gandalf proof of concept. This must be
// an init script (not page.evaluate) because loadPage registers its
// own init script that resets gu.geo.override to GB on every
// navigation, including the reload below; init scripts run in
// registration order, so this one runs last and wins. storage.local
// expects the { value } wrapper format that storage.local.set writes.
await page.addInitScript(() => {
window.localStorage.setItem(
'gu.geo.override',
JSON.stringify({ value: 'NZ' }),
);
});

await page.reload({ waitUntil: 'domcontentloaded' });

const auxiaRequest = await auxiaRequestPromise;
const body = auxiaRequest.postDataJSON() as Record<string, unknown>;
expect(body.countryCode).toBe('NZ');
// Two article loads today, each increments gu.history.dailyArticleCount
// including the current pageview, so the second load sends 2.
expect(body.dailyArticleCount).toBe(2);
});
});

test.describe('Banner browserId targeting', function () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,9 @@ describe('articleIdentifierIsAllowed', () => {
expect(pageIdIsAllowedForGating('tips')).toBe(false);
expect(pageIdIsAllowedForGating('tips#test')).toBe(false);
expect(pageIdIsAllowedForGating('tips/test')).toBe(false);
expect(pageIdIsAllowedForGating('info/privacy')).toBe(false);
expect(pageIdIsAllowedForGating('info/complaints-and-corrections')).toBe(
false,
);
expect(pageIdIsAllowedForGating('the-whole-picture')).toBe(false);
});
3 changes: 3 additions & 0 deletions dotcom-rendering/src/components/SignInGate/displayRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ export const pageIdIsAllowedForGating = (pageId: string): boolean => {
const denyPaths = [
'tips',
'help/ng-interactive/2017/mar/17/contact-the-guardian-securely',
'info/privacy',
'info/complaints-and-corrections',
'the-whole-picture',
];

return !denyPaths.some((denyPath) => pageId.startsWith(denyPath));
Expand Down
18 changes: 18 additions & 0 deletions dotcom-rendering/src/components/SignInGate/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,18 @@ export interface AuxiaProxyGetTreatmentsPayload {
// It is either undefined or return the timestamp carried by cookie `gu_hide_support_messaging`
// See: https://github.com/guardian/support-frontend/blob/7a5c0f9209054c24934b876771392531c261f51c/support-frontend/assets/helpers/storage/contributionsCookies.ts#L11

// [5] (comment group: gandalf)
//
// date: 2nd September 2026
//
// "Gandalf" is the marketing name for the Guardian-managed sign-in gate
// journey: a 100% rollout run entirely by Guardian rules with no Auxia
// involvement, currently live for New Zealand and extendable to further
// countries via the gandalfSignInGateCountries channel switch. SDC drives
// the journey from the standard dailyArticleCount field (the reader's
// pageview count for the current day, including this pageview) — see
// gandalfSignInGate on AuxiaProxyGetTreatmentsProxyResponseData below.

export interface AuxiaProxyGetTreatmentsResponse {
status: boolean;
data?: AuxiaProxyGetTreatmentsProxyResponseData;
Expand All @@ -191,6 +203,12 @@ export interface AuxiaProxyGetTreatmentsResponse {
export interface AuxiaProxyGetTreatmentsProxyResponseData {
responseId: string;
userTreatment?: AuxiaAPIResponseDataUserTreatment;
// Set to true on responses produced by the active Gandalf rules, both
// when no gate should display and when the Guardian-managed
// non-dismissible popup is returned. When present, the client must not
// make any Auxia interaction call and reports to Ophan under the stable
// Gandalf identity.
gandalfSignInGate?: boolean;
}

// Log Treatment Interaction
Expand Down
212 changes: 212 additions & 0 deletions dotcom-rendering/src/components/SignInGateSelector.island.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { storage } from '@guardian/libs';
import { cleanup, render, screen as testScreen } from '@testing-library/react';
import { StrictMode } from 'react';
import { useIsInView } from '../lib/useIsInView';
import { submitComponentEventTracking } from './SignInGate/componentEventTracking';
import type { AuxiaAPIResponseDataUserTreatment } from './SignInGate/types';
import { SignInGateSelector } from './SignInGateSelector.island';

jest.mock('../lib/useIsInView', () => ({ useIsInView: jest.fn() }));
jest.mock('../lib/usePageViewId', () => ({
usePageViewId: () => 'test-page-view',
}));
jest.mock('./ConfigContext', () => ({
useConfig: () => ({ renderingTarget: 'Web' }),
}));
jest.mock('./SignInGate/componentEventTracking', () => ({
submitComponentEventTracking: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('./SignInGate/gateDesigns/SignInGateAuxiaV1', () => ({
SignInGateAuxiaV1: () => <div data-testid="v1-gate" />,
}));
jest.mock('./SignInGate/gateDesigns/SignInGateAuxiaV2', () => ({
SignInGateAuxiaV2: () => <div data-testid="v2-gate" />,
}));

const makeTreatment = (
overrides: Partial<AuxiaAPIResponseDataUserTreatment> = {},
): AuxiaAPIResponseDataUserTreatment => ({
treatmentId: 'test-treatment',
treatmentTrackingId: 'test-tracking',
treatmentType: 'NONDISMISSIBLE_SIGN_IN_GATE_POPUP',
treatmentContent: '{}',
rank: '1',
contentLanguageCode: 'en',
surface: 'test-surface',
...overrides,
});

const makeProps = (
userTreatment = makeTreatment(),
gandalfSignInGate = true,
contentType = 'Network Front',
) => ({
isPaidContent: false,
isPreview: false,
pageId: 'crosswords/quick/16914',
contentType,
contributionsServiceUrl: 'https://contributions.example.com',
auxiaGateDisplayData: {
browserId: undefined,
auxiaData: {
responseId: 'test-response',
gandalfSignInGate,
userTreatment,
},
},
});

const mockSetNode = jest.fn();
const mockUseIsInView = jest.mocked(useIsInView);
const mockTrack = jest.mocked(submitComponentEventTracking);
const mockModalOpen = jest.fn();
const mockFetch = jest.fn<ReturnType<typeof fetch>, Parameters<typeof fetch>>();
const originalFetch = global.fetch;

const expectViews = (count: number) => {
expect(mockTrack).toHaveBeenCalledTimes(count);
expect(storage.local.getRaw('gate_display_count')).toBe(String(count));
expect(mockModalOpen).toHaveBeenCalledTimes(count);
};

describe('SignInGateSelector view tracking', () => {
beforeEach(() => {
jest.clearAllMocks();
global.fetch = mockFetch;
mockFetch.mockResolvedValue({
json: () => Promise.resolve({}),
} as Response);
storage.local.setRaw('gate_display_count', '0');
mockUseIsInView.mockReturnValue([null, mockSetNode]);
Object.defineProperty(document.documentElement, 'scrollHeight', {
configurable: true,
value: 3000,
});
document.addEventListener('modal:open', mockModalOpen);
});

afterEach(() => {
cleanup();
document.removeEventListener('modal:open', mockModalOpen);
jest.restoreAllMocks();
global.fetch = originalFetch;
});

it('shows a mandatory popup without scrolling and does not recount when the placeholder becomes visible', () => {
const props = makeProps();
const { rerender } = render(<SignInGateSelector {...props} />);

expect(testScreen.getByTestId('v2-gate')).toBeInTheDocument();
expectViews(1);
expect(mockTrack).toHaveBeenCalledWith(
expect.objectContaining({
action: 'VIEW',
abTest: {
name: 'GandalfSignInGate',
variant: 'gandalf-nz',
id: 'GandalfSignInGate',
},
}),
'Web',
);

mockUseIsInView.mockReturnValue([true, mockSetNode]);
rerender(<SignInGateSelector {...props} />);
expect(testScreen.getByTestId('v2-gate')).toBeInTheDocument();
expectViews(1);
expect(mockFetch).not.toHaveBeenCalled();
});

it('keeps a mandatory Article popup deferred until visibility', () => {
const props = makeProps(makeTreatment(), true, 'Article');
const { rerender } = render(<SignInGateSelector {...props} />);

expect(testScreen.queryByTestId('v2-gate')).not.toBeInTheDocument();
expectViews(0);

mockUseIsInView.mockReturnValue([true, mockSetNode]);
rerender(<SignInGateSelector {...props} />);

expect(testScreen.getByTestId('v2-gate')).toBeInTheDocument();
expectViews(1);
});

it('does not recount an equivalent treatment supplied as a new object', () => {
const { rerender } = render(<SignInGateSelector {...makeProps()} />);
rerender(<SignInGateSelector {...makeProps()} />);
expectViews(1);
});

it.each([
{ treatmentId: 'another-treatment' },
{ treatmentTrackingId: 'another-tracking' },
])('records a new treatment identity: %j', (identity) => {
const { rerender } = render(<SignInGateSelector {...makeProps()} />);
rerender(
<SignInGateSelector {...makeProps(makeTreatment(identity))} />,
);
expectViews(2);
});

it('records another view when the gate is unmounted and displayed again', () => {
const { unmount } = render(<SignInGateSelector {...makeProps()} />);
unmount();
render(<SignInGateSelector {...makeProps()} />);
expectViews(2);
});

it('does not duplicate a view when StrictMode replays effects', () => {
render(
<StrictMode>
<SignInGateSelector {...makeProps()} />
</StrictMode>,
);
expectViews(1);
});

it('keeps a dismissible popup deferred until visibility and records it only once', () => {
const treatment = makeTreatment({
treatmentType: 'DISMISSABLE_SIGN_IN_GATE_POPUP',
});
const { rerender } = render(
<SignInGateSelector {...makeProps(treatment)} />,
);
expect(testScreen.queryByTestId('v2-gate')).not.toBeInTheDocument();
expectViews(0);

mockUseIsInView.mockReturnValue([true, mockSetNode]);
rerender(<SignInGateSelector {...makeProps(treatment)} />);
expect(testScreen.getByTestId('v2-gate')).toBeInTheDocument();
expectViews(1);
rerender(<SignInGateSelector {...makeProps({ ...treatment })} />);
expectViews(1);
});

it('preserves inline gate rendering and visibility-based tracking', () => {
const props = makeProps(
makeTreatment({ treatmentType: 'DISMISSABLE_SIGN_IN_GATE' }),
);
const { rerender } = render(<SignInGateSelector {...props} />);
expect(testScreen.getByTestId('v1-gate')).toBeInTheDocument();
expectViews(0);
mockUseIsInView.mockReturnValue([true, mockSetNode]);
rerender(<SignInGateSelector {...props} />);
expectViews(1);
});

it('records the Auxia VIEWED interaction only once for non-Gandalf treatments', () => {
const props = makeProps(makeTreatment(), false);
const { rerender } = render(<SignInGateSelector {...props} />);
mockUseIsInView.mockReturnValue([true, mockSetNode]);
rerender(<SignInGateSelector {...props} />);
expectViews(1);
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(mockFetch).toHaveBeenCalledWith(
'https://contributions.example.com/auxia/log-treatment-interaction',
expect.objectContaining({ method: 'POST' }),
);
expect(mockFetch.mock.calls[0]?.[1]?.body).toContain(
'"interactionType":"VIEWED"',
);
});
});
Loading
Loading