Merge "Revise AB.js to handle other features + server sampling/bucketing"

This commit is contained in:
jenkins-bot 2022-03-22 22:53:30 +00:00 committed by Gerrit Code Review
commit b3f19854b6
5 changed files with 405 additions and 204 deletions

View File

@ -1,124 +1,186 @@
/** @module WebABTest */
const EXCLUDED_BUCKET = 'unsampled';
const TREATMENT_BUCKET_SUBSTRING = 'treatment';
const WEB_AB_TEST_ENROLLMENT_HOOK = 'mediawiki.web_AB_test_enrollment';
/**
* Example A/B test configuration for sticky header:
*
* $wgVectorABTestEnrollment = [
* 'name' => 'vector.sticky_header',
* 'enabled' => true,
* 'buckets' => [
* 'unsampled' => [
* 'samplingRate' => 0.1,
* ],
* 'control' => [
* 'samplingRate' => 0.3,
* ],
* 'stickyHeaderDisabled' => [
* 'samplingRate' => 0.3,
* ],
* 'stickyHeaderEnabled' => [
* 'samplingRate' => 0.3,
* ],
* ],
* ];
* @typedef {Object} SamplingRate
* @property {number} samplingRate The desired sampling rate for the group in the range [0, 1].
*/
/**
* Functions and variables to implement A/B testing.
* @typedef {Object} WebABTestProps
* @property {string} name The name of the experiment.
* @property {Object} buckets Dict with bucket name as key and SamplingRate
* object as value. There must be an `unsampled` bucket that represents a
* population excluded from the experiment. Additionally, the treatment
* bucket(s) must include a case-insensitive `treatment` substring in their name
* (e.g. `treatment`, `stickyHeaderTreatment`, `sticky-header-treatment`).
* @property {string} [token] Token that uniquely identifies the subject for the
* duration of the experiment. If bucketing/server occurs on the server and the
* bucket is a class on the body tag, this can be ignored. Otherwise, it is
* required.
*/
const ABTestConfig = require( /** @type {string} */ ( './config.json' ) ).wgVectorWebABTestEnrollment || {};
/**
* Get the name of the bucket the user is assigned to for A/B testing.
* Initializes an AB test experiment.
*
* @return {string} the name of the bucket the user is assigned.
* Example props:
*
* webABTest({
* name: 'nameOfExperiment',
* buckets: {
* unsampled: {
* samplingRate: 0.5
* },
* control: {
* samplingRate: 0.25
* },
* treatment: {
* samplingRate: 0.25
* }
* },
* token: 'token'
* });
*
* @param {WebABTestProps} props
* @return {WebABTest}
*/
function getBucketName() {
module.exports = function webABTest( props ) {
let /** @type {string} */ cachedBucket;
/**
* Provided config should contain the keys:
* name: the name of the experiment prefixed with the skin name.
* enabled: must be true or all users are assigned to control.
* buckets: dict with bucket name as key and test config as value.
* Get the names of all the buckets from props.buckets.
*
* Bucket test config can contain the keys:
* samplingRate: sampling rates will be summed up and each bucket will receive a proportion
* equal to its value.
* @return {string[]}
*/
return mw.experiments.getBucket( {
name: ABTestConfig.name,
enabled: ABTestConfig.enabled,
buckets: {
function getBucketNames() {
return Object.keys( props.buckets );
}
/**
* Get the name of the bucket from the class added to the body tag.
*
* @return {?string}
*/
function getBucketFromHTML() {
for ( const bucketName of getBucketNames() ) {
if ( document.body.classList.contains( `${props.name}-${bucketName}` ) ) {
return bucketName;
}
}
return null;
}
/**
* Get the name of the bucket the subject is assigned to for A/B testing.
*
* @throws {Error} Will throw an error if token is undefined and body tag has
* not been assigned a bucket.
* @return {string} the name of the bucket the subject is assigned.
*/
function getBucket() {
if ( cachedBucket ) {
// If we've already cached the bucket, return early.
return cachedBucket;
}
const bucketFromHTML = getBucketFromHTML();
// If bucketing/sampling already occurred on the server, return that bucket
// instead of trying to do it on the client.
if ( bucketFromHTML ) {
cachedBucket = bucketFromHTML;
return bucketFromHTML;
}
if ( props.token === undefined ) {
throw new Error( 'Tried to call `getBucket` with an undefined token' );
}
cachedBucket = mw.experiments.getBucket( {
name: props.name,
enabled: true,
// @ts-ignore
unsampled: ABTestConfig.buckets.unsampled.samplingRate,
control: ABTestConfig.buckets.control.samplingRate,
stickyHeaderDisabled: ABTestConfig.buckets.stickyHeaderDisabled.samplingRate,
stickyHeaderEnabled: ABTestConfig.buckets.stickyHeaderEnabled.samplingRate
}
}, mw.user.getId().toString() );
}
buckets:
getBucketNames().reduce( ( buckets, key ) => {
// @ts-ignore
buckets[ key ] = props.buckets[ key ].samplingRate;
/**
* Get the group and experiment name for an A/B test.
*
* @return {Object} data to pass to event logging
*/
function getABTestGroupExperimentName() {
return buckets;
}, {} )
}, props.token );
return cachedBucket;
}
/**
* Whether or not the subject is included in the experiment.
*
* @return {boolean}
*/
function isInSample() {
return getBucket() !== EXCLUDED_BUCKET;
}
/**
* Determine if subject is in a particular bucket.
*
* @param {string} targetBucket The target test bucket.
* @return {boolean} Whether the subject is in the test bucket.
*/
function isInBucket( targetBucket ) {
return getBucket() === targetBucket;
}
/**
* Whether or not the subject has been bucketed in a treatment bucket as
* defined by the bucket name containing the case-insensitive `treatment`
* substring (e.g. 'treatment', 'sticky-header-treatment' and
* 'stickyHeaderTreatment' are all assumed to be treatment buckets).
*
* @return {boolean}
*/
function isInTreatmentBucket() {
const bucket = getBucket();
// eslint-disable-next-line no-restricted-syntax
return bucket.toLowerCase().includes( TREATMENT_BUCKET_SUBSTRING );
}
/**
* Initialize and fire hook to register A/B test enrollment if necessary.
*/
function init() {
// Send data to WikimediaEvents to log A/B test initialization if the subject
// has been sampled into the experiment.
if ( isInSample() ) {
// @ts-ignore
mw.hook( WEB_AB_TEST_ENROLLMENT_HOOK ).fire( {
group: getBucket(),
experimentName: props.name
} );
}
}
init();
/**
* @typedef {Object} WebABTest
* @property {string} name
* @property {getBucket} getBucket
* @property {isInBucket} isInBucket
* @property {isInSample} isInSample
* @property {isInTreatmentBucket} isInTreatmentBucket
*/
return {
group: getBucketName(),
experimentName: ABTestConfig.name
name: props.name,
getBucket,
isInBucket,
isInSample,
isInTreatmentBucket
};
}
/**
* Provides A/B test config for the current user.
*
* @return {Object} A/B test config data
*/
function getEnabledExperiment() {
const mergedConfig = {};
if ( ABTestConfig.enabled ) {
// Merge all the A/B config to return.
Object.assign( mergedConfig, getABTestGroupExperimentName(), ABTestConfig );
}
return mergedConfig;
}
/**
* Determine if user is in test group to experience feature.
*
* @param {string} bucket the bucket name the user is assigned
* @param {string} targetGroup the target test group to experience feature
* @return {boolean} true if the user should experience feature
*/
function isInTestGroup( bucket, targetGroup ) {
return bucket === targetGroup;
}
/**
* Fire hook to register A/B test enrollment.
*
* @param {string} bucket the bucket user is assigned to
*/
function initAB( bucket ) {
// Send data to WikimediaEvents to log A/B test initialization if experiment is enabled
// and if the user is logged in.
if ( ABTestConfig.enabled && !mw.user.isAnon() && bucket !== 'unsampled' ) {
// @ts-ignore
mw.hook( 'mediawiki.web_AB_test_enrollment' ).fire( getABTestGroupExperimentName() );
// Remove class if present on the html element so that scroll padding isn't undesirably
// applied to users who don't experience the new treatment.
if ( bucket !== 'stickyHeaderEnabled' ) {
document.documentElement.classList.remove( 'vector-sticky-header-enabled' );
}
}
}
module.exports = {
isInTestGroup,
getEnabledExperiment,
initAB,
test: {
getBucketName,
getABTestGroupExperimentName
}
};

View File

@ -3,14 +3,15 @@ const
searchToggle = require( './searchToggle.js' ),
stickyHeader = require( './stickyHeader.js' ),
scrollObserver = require( './scrollObserver.js' ),
AB = require( './AB.js' ),
initExperiment = require( './AB.js' ),
initSectionObserver = require( './sectionObserver.js' ),
initTableOfContents = require( './tableOfContents.js' ),
deferUntilFrame = require( './deferUntilFrame.js' ),
TOC_ID = 'mw-panel-toc',
BODY_CONTENT_ID = 'bodyContent',
HEADLINE_SELECTOR = '.mw-headline',
TOC_SECTION_ID_PREFIX = 'toc-';
TOC_SECTION_ID_PREFIX = 'toc-',
ABTestConfig = require( /** @type {string} */ ( './config.json' ) ).wgVectorWebABTestEnrollment || {};
/**
* @return {void}
@ -23,24 +24,25 @@ const main = () => {
searchToggle( searchToggleElement );
}
// Get the A/B test config for sticky header if enabled.
// If necessary, initialize experiment and fire the A/B test enrollment hook.
const stickyHeaderExperiment =
!!ABTestConfig.enabled &&
ABTestConfig.name === stickyHeader.STICKY_HEADER_EXPERIMENT_NAME &&
!mw.user.isAnon() &&
stickyHeader.isStickyHeaderAllowed() &&
initExperiment( Object.assign( {}, ABTestConfig, { token: mw.user.getId() } ) );
// Remove class if present on the html element so that scroll padding isn't undesirably
// applied to users who don't experience the new treatment.
if ( stickyHeaderExperiment && !stickyHeaderExperiment.isInTreatmentBucket() ) {
document.documentElement.classList.remove( 'vector-sticky-header-enabled' );
}
const
FEATURE_TEST_GROUP = 'stickyHeaderEnabled',
testConfig = AB.getEnabledExperiment(),
stickyConfig = testConfig &&
// @ts-ignore
testConfig.experimentName === stickyHeader.STICKY_HEADER_EXPERIMENT_NAME ?
testConfig : null,
// Note that the default test group is set to experience the feature by default.
// @ts-ignore
testGroup = stickyConfig ? stickyConfig.group : FEATURE_TEST_GROUP,
targetElement = stickyHeader.header,
targetIntersection = stickyHeader.stickyIntersection,
isStickyHeaderAllowed = stickyHeader.isStickyHeaderAllowed() &&
testGroup !== 'unsampled' && AB.isInTestGroup( testGroup, FEATURE_TEST_GROUP );
// Fire the A/B test enrollment hook.
AB.initAB( testGroup );
isStickyHeaderAllowed = stickyHeaderExperiment ?
stickyHeaderExperiment.isInTreatmentBucket() : stickyHeader.isStickyHeaderAllowed();
// Set up intersection observer for sticky header functionality and firing scroll event hooks
// for event logging if AB test is enabled.

View File

@ -476,12 +476,12 @@
"stickyHeaderDisabled": {
"samplingRate": 0.3
},
"stickyHeaderEnabled": {
"stickyHeaderEnabledTreatment": {
"samplingRate": 0.3
}
}
},
"description": "An associative array of A/B test configs keyed by parameters noted in mediawiki.experiments.js."
"description": "An associative array of A/B test configs keyed by parameters noted in mediawiki.experiments.js. There must be an `unsampled` bucket that represents a population excluded from the experiment. Additionally, the treatment bucket(s) must include a case-insensitive `treatment` substring in their name (e.g. `treatment`, `stickyHeaderTreatment`, `sticky-header-treatment`)"
},
"VectorDisableSidebarPersistence": {
"value": false,

View File

@ -1,74 +1,231 @@
const mockConfig = require( './__mocks__/config.json' );
const ABTestConfig = mockConfig.wgVectorWebABTestEnrollment;
// Mock out virtual config.json file used in AB.js, before importing AB.js
jest.mock( '../../resources/skins.vector.es6/config.json', () => {
return mockConfig;
}, { virtual: true } );
const AB = require( '../../resources/skins.vector.es6/AB.js' );
const NAME_OF_EXPERIMENT = 'name-of-experiment';
const TOKEN = 'token';
const MW_EXPERIMENT_PARAM = {
name: NAME_OF_EXPERIMENT,
enabled: true,
buckets: {
unsampled: 0.5,
control: 0.25,
treatment: 0.25
}
};
// eslint-disable-next-line jsdoc/require-returns
/**
* @param {Object} props
*/
function createInstance( props = {} ) {
const mergedProps = /** @type {AB.WebABTestProps} */ ( Object.assign( {
name: NAME_OF_EXPERIMENT,
buckets: {
unsampled: {
samplingRate: 0.5
},
control: {
samplingRate: 0.25
},
treatment: {
samplingRate: 0.25
}
},
token: TOKEN
}, props ) );
return AB( mergedProps );
}
describe( 'AB.js', () => {
const bucket = 'sampled';
const userId = '1';
const bucket = 'treatment';
const getBucketMock = jest.fn().mockReturnValue( bucket );
const toStringMock = jest.fn().mockReturnValue( userId );
mw.experiments.getBucket = getBucketMock;
// @ts-ignore
mw.user.getId = () => ( { toString: toStringMock } );
const expectedABTestGroupExperimentName = {
group: bucket,
experimentName: ABTestConfig.name
};
afterEach( () => {
document.body.removeAttribute( 'class' );
} );
describe( 'getBucketName', () => {
it( 'calls mw.experiments.getBucket with config data', () => {
expect( AB.test.getBucketName() ).toBe( bucket );
expect( getBucketMock ).toBeCalledWith( {
name: ABTestConfig.name,
enabled: ABTestConfig.enabled,
buckets: {
unsampled: ABTestConfig.buckets.unsampled.samplingRate,
control: ABTestConfig.buckets.control.samplingRate,
stickyHeaderDisabled: ABTestConfig.buckets.stickyHeaderDisabled.samplingRate,
stickyHeaderEnabled: ABTestConfig.buckets.stickyHeaderEnabled.samplingRate
}
}, userId );
expect( toStringMock ).toHaveBeenCalled();
describe( 'initialization when body tag does not contain bucket', () => {
let /** @type {jest.Mock} */ hookMock;
beforeEach( () => {
hookMock = jest.fn().mockReturnValue( { fire: () => {} } );
mw.hook = hookMock;
} );
} );
describe( 'getABTestGroupExperimentName', () => {
it( 'returns group and experiment name object', () => {
expect( AB.test.getABTestGroupExperimentName() )
.toEqual( expectedABTestGroupExperimentName );
} );
} );
describe( 'getEnabledExperiment', () => {
it( 'returns AB config data when enabled', () => {
expect( AB.getEnabledExperiment() ).toEqual(
Object.assign( {}, expectedABTestGroupExperimentName, ABTestConfig )
);
} );
} );
describe( 'initAB(', () => {
const hookMock = jest.fn().mockReturnValue( { fire: () => {} } );
const isAnonMock = jest.fn();
mw.user.isAnon = isAnonMock;
mw.hook = hookMock;
it( 'sends data to WikimediaEvents when the AB test is enabled ', () => {
isAnonMock.mockReturnValueOnce( false );
AB.initAB( 'sampled' );
it( 'sends data to WikimediaEvents when the bucket is part of sample (e.g. control)', () => {
getBucketMock.mockReturnValueOnce( 'control' );
createInstance();
expect( hookMock ).toHaveBeenCalled();
} );
it( 'doesnt send data to WikimediaEvents when the user is anon ', () => {
isAnonMock.mockReturnValueOnce( true );
AB.initAB( 'sampled' );
it( 'sends data to WikimediaEvents when the bucket is part of sample (e.g. treatment)', () => {
getBucketMock.mockReturnValueOnce( 'treatment' );
createInstance();
expect( hookMock ).toHaveBeenCalled();
} );
it( 'does not send data to WikimediaEvents when the bucket is unsampled ', () => {
getBucketMock.mockReturnValueOnce( 'unsampled' );
createInstance();
expect( hookMock ).not.toHaveBeenCalled();
} );
it( 'doesnt send data to WikimediaEvents when the bucket is unsampled ', () => {
isAnonMock.mockReturnValueOnce( false );
AB.initAB( 'unsampled' );
} );
describe( 'initialization when body tag contains bucket', () => {
let /** @type {jest.Mock} */ hookMock;
beforeEach( () => {
hookMock = jest.fn().mockReturnValue( { fire: () => {} } );
mw.hook = hookMock;
} );
it( 'sends data to WikimediaEvents when the bucket is part of sample (e.g. control)', () => {
document.body.classList.add( 'name-of-experiment-control' );
createInstance();
expect( hookMock ).toHaveBeenCalled();
} );
it( 'sends data to WikimediaEvents when the bucket is part of sample (e.g. treatment)', () => {
document.body.classList.add( 'name-of-experiment-treatment' );
createInstance();
expect( hookMock ).toHaveBeenCalled();
} );
it( 'does not send data to WikimediaEvents when the bucket is unsampled ', () => {
document.body.classList.add( 'name-of-experiment-unsampled' );
createInstance();
expect( hookMock ).not.toHaveBeenCalled();
} );
} );
describe( 'initialization when token is undefined', () => {
it( 'throws an error', () => {
expect( () => {
createInstance( { token: undefined } );
} ).toThrow( 'Tried to call `getBucket`' );
} );
} );
describe( 'getBucket when body tag does not contain AB class', () => {
it( 'calls mw.experiments.getBucket with config data', () => {
const experiment = createInstance();
expect( getBucketMock ).toBeCalledWith( MW_EXPERIMENT_PARAM, TOKEN );
expect( experiment.getBucket() ).toBe( bucket );
} );
} );
describe( 'getBucket when body tag contains AB class that is in the sample', () => {
it( 'returns the bucket on the body tag', () => {
document.body.classList.add( 'name-of-experiment-control' );
const experiment = createInstance();
expect( getBucketMock ).not.toHaveBeenCalled();
expect( experiment.getBucket() ).toBe( 'control' );
} );
} );
describe( 'getBucket when body tag contains AB class that is not in the sample', () => {
it( 'returns the bucket on the body tag', () => {
document.body.classList.add( 'name-of-experiment-unsampled' );
const experiment = createInstance();
expect( getBucketMock ).not.toHaveBeenCalled();
expect( experiment.getBucket() ).toBe( 'unsampled' );
} );
} );
describe( 'isInBucket', () => {
it( 'compares assigned bucket with passed in bucket', () => {
const experiment = createInstance();
expect( experiment.isInBucket( 'treatment' ) ).toBe( true );
} );
} );
describe( 'isInTreatmentBucket when assigned to unsampled bucket (from server)', () => {
it( 'returns false', () => {
document.body.classList.add( 'name-of-experiment-unsampled' );
const experiment = createInstance();
expect( experiment.isInTreatmentBucket() ).toBe( false );
} );
} );
describe( 'isInTreatmentBucket when assigned to control bucket (from server)', () => {
it( 'returns false', () => {
document.body.classList.add( 'name-of-experiment-control' );
const experiment = createInstance();
expect( experiment.isInTreatmentBucket() ).toBe( false );
} );
} );
describe( 'isInTreatmentBucket when assigned to treatment bucket (from server)', () => {
it( 'returns true', () => {
document.body.classList.add( 'name-of-experiment-treatment' );
const experiment = createInstance();
expect( experiment.isInTreatmentBucket() ).toBe( true );
} );
} );
describe( 'isInTreatmentBucket when assigned to unsampled bucket (from client)', () => {
it( 'returns false', () => {
getBucketMock.mockReturnValueOnce( 'unsampled' );
const experiment = createInstance();
expect( experiment.isInTreatmentBucket() ).toBe( false );
} );
} );
describe( 'isInTreatmentBucket when assigned to control bucket (from client)', () => {
it( 'returns false', () => {
getBucketMock.mockReturnValueOnce( 'control' );
const experiment = createInstance();
expect( experiment.isInTreatmentBucket() ).toBe( false );
} );
} );
describe( 'isInTreatmentBucket when assigned to treatment bucket (from client)', () => {
it( 'returns true', () => {
getBucketMock.mockReturnValueOnce( 'treatment' );
const experiment = createInstance();
expect( experiment.isInTreatmentBucket() ).toBe( true );
} );
} );
describe( 'isInTreatmentBucket when assigned to treatment bucket (is case insensitive)', () => {
it( 'returns true', () => {
getBucketMock.mockReturnValueOnce( 'StickyHeaderVisibleTreatment' );
const experiment = createInstance();
expect( experiment.isInTreatmentBucket() ).toBe( true );
} );
} );
describe( 'isInSample when in unsampled bucket', () => {
it( 'returns false', () => {
document.body.classList.add( 'name-of-experiment-unsampled' );
const experiment = createInstance();
expect( experiment.isInSample() ).toBe( false );
} );
} );
describe( 'isInSample when in control bucket', () => {
it( 'returns true', () => {
document.body.classList.add( 'name-of-experiment-control' );
const experiment = createInstance();
expect( experiment.isInSample() ).toBe( true );
} );
} );
describe( 'isInSample when in treatment bucket', () => {
it( 'returns true', () => {
document.body.classList.add( 'name-of-experiment-treatment' );
const experiment = createInstance();
expect( experiment.isInSample() ).toBe( true );
} );
} );
} );

View File

@ -1,20 +0,0 @@
{
"wgVectorWebABTestEnrollment": {
"name": "vector.sticky_header",
"enabled": true,
"buckets": {
"unsampled": {
"samplingRate": 0.1
},
"control": {
"samplingRate": 0.3
},
"stickyHeaderDisabled": {
"samplingRate": 0.3
},
"stickyHeaderEnabled": {
"samplingRate": 0.3
}
}
}
}