/** * Internal dependencies */ import { applyAutoInlineExperiments } from './apply-auto-inline-experiments'; import { getCookie } from '../utils/cookies'; import { flow, getCollapsedAlternative, getNumberOfAlternatives, headReady, isAlternativeDistributionAllowed, log, preloadQueryArgs, } from '../utils/helpers'; import { areSegmentsValid } from '../utils/segmentation'; import { REFERRER_PARAM, addNabQueryArg, addQueryArgs, getQueryArg, getQueryArgs, removeArgsAndHash, removeQueryArgs, removeTestingArgs, } from '../utils/url'; import type { ActiveExperiment, Experiment, Session } from '../types'; export async function loadAlternative( session: Session ): Promise< boolean > { if ( ! isCookieTestingEnabled( session ) && ! areSegmentsValid( session ) ) { log( 'Invalid segmentation' ); await loadUntestedControl(); return false; } if ( isCookieTestingEnabled( session ) && isCookieTestingOutdated( session ) ) { await refresh( session ); return false; } const targetUrl = getExpectedAlternativeUrl( session ); await loadPage( session, targetUrl ); await applyAutoInlineExperiments( session.experiments ); removeOverlay(); return true; } export const loadUntestedControl = (): Promise< void > => new Promise( ( resolve ) => { const url = document.location.href; const args = getQueryArgs( url ); if ( args.nab === undefined ) { removeOverlay(); return resolve(); } document.location.href = removeTestingArgs( url ); } ); // ======= // HELPERS // ======= function getExpectedAlternativeUrl( session: Session ): string { if ( ! session.experiments.some( ( e ) => e.active ) ) { return session.untestedUrl; } const { alternativeUrls } = session; const nab = getCollapsedAlternative( session ); if ( ! alternativeUrls?.value.length ) { return addNabQueryArg( session.untestedUrl, nab, session.nabPosition ); } // @ts-expect-error The experiment that defined alternative URLs is available. const experiment: Experiment = session.experiments.find( ( e ) => e.id === alternativeUrls.experimentId ); // @ts-expect-error “urls” is not empty and the index is always within range. const expectedAlternativeUrl: string = alternativeUrls.value[ experiment.alternative ]; return flow( ( url: string ) => addMissingArguments( url ), ( url: string ) => maybeAddHash( url ), ( url: string ) => addNabQueryArg( url, nab, session.nabPosition ) )( expectedAlternativeUrl ); } const loadPage = ( session: Session, targetUrl: string ): Promise< void > => new Promise( ( resolve ) => { const validateCurrentUrl = () => { maybeReplaceUrlWithControlUrl( session ); cleanTestingQueryArgs( session ); preloadQueryArgs( session ); resolve(); }; if ( session.isTestedPostRequest ) { updateUrl( targetUrl ); return validateCurrentUrl(); } if ( areUrlsEqual( session, targetUrl, session.currentUrl ) ) { updateUrl( targetUrl ); return validateCurrentUrl(); } if ( isTargetUrlCompletelyDifferent( session, targetUrl ) ) { // Don’t resolve -- just wait for the redirection to complete. redirect( session, targetUrl ); return; } if ( isCurrentUrlJustMissingNab( session, targetUrl ) ) { updateUrl( targetUrl ); return validateCurrentUrl(); } if ( doesCurrentUrlHaveCompatibleNab( session, targetUrl ) ) { updateUrl( targetUrl ); return validateCurrentUrl(); } if ( isAlternativeRequestNoLongerValid( session ) ) { stayWithNoTestingArgs(); return validateCurrentUrl(); } // Don’t resolve -- just wait for the redirection to complete. redirect( session, targetUrl ); } ); const refresh = ( session: Session ) => new Promise< void >( () => redirect( session, session.untestedUrl ) ); function redirect( session: Session, targetUrl: string ) { if ( isCookieTestingEnabled( session ) ) { targetUrl = removeQueryArgs( targetUrl, 'nab' ); } headReady( () => document.querySelector( 'html' )?.classList.add( 'nab-redirecting' ) ); if ( ! document.location.replace ) { document.location.href = targetUrl; return; } document.location.replace( targetUrl ); } const removeOverlay = () => { const overlay = document.getElementById( 'nelio-ab-testing-overlay' ); if ( overlay ) { overlay.parentNode?.removeChild( overlay ); } else { headReady( () => document.body.classList.add( 'nab-done' ) ); } }; function updateUrl( url: string ) { try { window.history.replaceState( {}, '', url ); } catch ( _ ) {} } function stayWithNoTestingArgs() { cleanTestingQueryArgs( { hideQueryArgs: true } ); } function isTargetUrlCompletelyDifferent( session: Session, targetUrl: string ): boolean { const cleanCurrentUrl = maybeRemoveTrailingSlash( session.untestedUrl, session ); const cleanTargetUrl = maybeRemoveTrailingSlash( removeTestingArgs( targetUrl ), session ); return cleanCurrentUrl !== cleanTargetUrl; } function isCurrentUrlJustMissingNab( session: Session, targetUrl: string ) { if ( isTargetUrlCompletelyDifferent( session, targetUrl ) ) { return false; } const { currentUrl } = session; const isNabAlreadyThere = undefined !== getQueryArg( currentUrl, 'nab' ); const isNabExpected = undefined !== getQueryArg( targetUrl, 'nab' ); if ( isNabAlreadyThere || ! isNabExpected ) { return false; } if ( isCookieTestingEnabled( session ) ) { return true; } if ( areAllExperimentsInline( session ) ) { return true; } return doAllExperimentsLoadControlVariant( session ); } function doesCurrentUrlHaveCompatibleNab( session: Session, targetUrl: string ) { if ( session.maxCombinations <= 1 ) { return false; } const actualNab = Number.parseInt( getQueryArg( session.currentUrl, 'nab' ) ?? '' ); if ( isNaN( actualNab ) ) { return false; } const expectedNab = Number.parseInt( getQueryArg( targetUrl, 'nab' ) ?? '' ); if ( isNaN( expectedNab ) ) { return false; } if ( isAlternativeDistributionAllowed( session ) ) { return actualNab === expectedNab; } const numOfAlternatives = getNumberOfAlternatives( session ); const actual = actualNab % numOfAlternatives; const expected = expectedNab % numOfAlternatives; return actual === expected; } function isAlternativeRequestNoLongerValid( session: Session ) { const actualNab = getQueryArg( document.location.search, 'nab' ); const isAlternativeRequest = undefined !== actualNab; return ( ! session.experiments.some( ( e ) => e.active ) && isAlternativeRequest ); } function getReferrer() { const referrerValue = getQueryArg( document.location.search, REFERRER_PARAM ); if ( referrerValue ) { return decodeURIComponent( referrerValue ); } const referrer = document.referrer; const currentUrl = document.location.href; if ( isSameDomain( currentUrl, referrer ) ) { return false; } return referrer; } function isSameDomain( oneUrl = '', anotherUrl = '' ) { const clean = ( x: string ) => x.replace( /^https?:\/\//, '' ).replace( /\/.*$/, '' ); return clean( oneUrl ) === clean( anotherUrl ); } function maybeReplaceUrlWithControlUrl( session: Session ) { if ( ! session.useControlUrl ) { return; } const alternativeUrl = session.alternativeUrls?.value[ 0 ]; if ( ! alternativeUrl ) { return; } const args = getQueryArgs( document.location.href ); const url = addQueryArgs( alternativeUrl, args ); try { window.history.replaceState( {}, '', url ); } catch ( _ ) {} } function cleanTestingQueryArgs( session: Pick< Session, 'hideQueryArgs' > ) { const url = removeTestingArgs( document.location.href, ! session.hideQueryArgs ); try { window.history.replaceState( {}, '', url ); } catch ( _ ) {} } function areUrlsEqual( session: Session, oneUrl = '', anotherUrl = '' ) { let oneCleanUrl = removeArgsAndHash( oneUrl ); let anotherCleanUrl = removeArgsAndHash( anotherUrl ); if ( session.ignoreTrailingSlash ) { oneCleanUrl = `${ oneCleanUrl }/`.replace( /\/\/$/, '/' ); anotherCleanUrl = `${ anotherCleanUrl }/`.replace( /\/\/$/, '/' ); } if ( oneCleanUrl !== anotherCleanUrl ) { return false; } const oneArgs = getQueryArgs( oneUrl ); const anotherArgs = getQueryArgs( anotherUrl ); const oneArgKeys = Object.keys( oneArgs ); const anotherArgKeys = Object.keys( anotherArgs ); if ( oneArgKeys.length !== anotherArgKeys.length ) { return false; } for ( const key of oneArgKeys ) { if ( ! anotherArgKeys.includes( key ) ) { return false; } } for ( const key of oneArgKeys ) { if ( oneArgs[ key ] !== anotherArgs[ key ] ) { return false; } } return true; } function addMissingArguments( url: string ) { const expectedArguments = getQueryArgs( url ); const currentArguments = getQueryArgs( document.location.search ); delete currentArguments.nab; delete currentArguments.nabforce; delete currentArguments[ REFERRER_PARAM ]; url = addQueryArgs( url, { ...currentArguments, ...expectedArguments, } ); const referrer = getReferrer(); if ( ! referrer ) { return url; } return addQueryArgs( url, { [ REFERRER_PARAM ]: referrer } ); } function maybeAddHash( url: string ) { if ( -1 !== url.indexOf( '#' ) ) { return url; } return url + document.location.hash; } function maybeRemoveTrailingSlash( url: string, session: Session ) { if ( ! session.ignoreTrailingSlash ) { return url; } return url.includes( '?' ) ? url.replace( '/?', '?' ) : url.replace( /\/$/, '' ); } function areAllExperimentsInline( session: Session ) { return session.experiments .filter( ( e ): e is ActiveExperiment => e.active ) .every( ( e ) => !! e.inline ); } function doAllExperimentsLoadControlVariant( session: Session ) { const activeExperiments = session.experiments.filter( ( e ): e is ActiveExperiment => e.active ); const first = activeExperiments[ 0 ]; if ( ! first ) { return true; } if ( first.alternative !== 0 ) { return false; } return activeExperiments.every( ( e ) => e.alternative === first.alternative ); } function isCookieTestingEnabled( session: Session ) { return false !== session.cookieTesting; } function isCookieTestingOutdated( session: Session ) { return session.cookieTesting !== getAlternativeValueFromCookie(); } function getAlternativeValueFromCookie() { const nabAlt = getCookie( 'nabAlternative' ) ?? ''; if ( 'none' === nabAlt ) { return 'none'; } const value = Number.parseInt( nabAlt ); if ( isNaN( value ) ) { return false; } if ( value < 0 ) { return false; } return value; }