Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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),
// sent 0-based, so the second load sends 1.
expect(body.gandalfPageViewCount).toBe(1);
});
});

test.describe('Banner browserId targeting', function () {
Expand Down
27 changes: 27 additions & 0 deletions dotcom-rendering/src/components/SignInGate/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export interface AuxiaProxyGetTreatmentsPayload {
showDefaultGate: ShowGateValues; // [3]
gateDisplayCount: number;
hideSupportMessagingTimestamp: number | undefined; // [4]
gandalfPageViewCount?: number; // [5] gandalfPageViewCount
}

// [1]
Expand Down Expand Up @@ -183,6 +184,22 @@ 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] gandalfPageViewCount
//
// date: 2nd September 2026
// comment group: gandalf
//
// "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.
//
// `gandalfPageViewCount` is the 0-based number of views the reader has
// already completed today (gu.history.dailyArticleCount, see
// src/lib/dailyArticleCount.ts). It is optional so older payloads and traffic
// outside the Gandalf countries are unaffected; SDC treats a missing value
// as 0.

export interface AuxiaProxyGetTreatmentsResponse {
status: boolean;
data?: AuxiaProxyGetTreatmentsProxyResponseData;
Expand All @@ -191,6 +208,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 (the pageview still counts towards the free
// allowance) 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 Expand Up @@ -235,6 +258,10 @@ export interface AuxiaGateReaderPersonalData {
export interface AuxiaGateDisplayData {
browserId: string | undefined;
auxiaData: AuxiaProxyGetTreatmentsProxyResponseData;
// The country code the gate request was made for. Set by the client so the
// selector can build the per-country Gandalf Ophan variant
// (gandalf-<country>) without re-resolving geolocation.
gandalfCountryCode?: string;
}

export type SignInGatePropsAuxia = {
Expand Down
190 changes: 190 additions & 0 deletions dotcom-rendering/src/components/SignInGateSelector.island.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
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,
) => ({
isPaidContent: false,
isPreview: false,
pageId: 'crosswords/quick/16914',
contributionsServiceUrl: 'https://contributions.example.com',
auxiaGateDisplayData: {
browserId: undefined,
gandalfCountryCode: 'NZ',
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' }),
'Web',
);

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

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