mediawiki-skins-Vector/resources/skins.vector.es6/AB.js
Jan Drewniak d7e6690b52 Sticky header edit button A/B test bucketing (updated)
Adds behaviour for conditionally adding the edit button
to the sticky-header based on A/B test bucketing.

This behaviour depends on having the `$wgVectorStickyHeaderEdit` config
set to true for logged-in users:

    $wgVectorStickyHeaderEdit = [
        "logged_in" => true,
        "logged_out" => false
    ];

as well as an AB test configured with the following buckets:

    $wgVectorWebABTestEnrollment = [
        'name' => 'vector.sticky_header_edit',
        'enabled' => true,
        'buckets' => [
            'unsampled' => [
                'samplingRate' => 0
            ],
            'stickyHeaderEditButtonControl' => [
                'samplingRate' => 0
            ],
            'stickyHeaderEditButtonTreatment' => [
                'samplingRate' => 1
            ]
        ]
    ];

With that config, this change hides the sticky header for all users
except those in the stickyHeaderEditButtonTreatment bucket.

NOTE: This patch address the sticky header being visible on incorrect
namespaces when the AB test is enabled and the revert of
42b808738a.

Bug: T299959
Bug: T309370
Change-Id: I3effbb3e5f0bb1c8663255936458e3849511dfca
2022-05-31 15:58:35 -07:00

195 lines
4.9 KiB
JavaScript

/** @module WebABTest */
const EXCLUDED_BUCKET = 'unsampled';
const TREATMENT_BUCKET_SUBSTRING = 'treatment';
const WEB_AB_TEST_ENROLLMENT_HOOK = 'mediawiki.web_AB_test_enrollment';
/**
* @typedef {Object} WebABTest
* @property {string} name
* @property {function(): string} getBucket
* @property {function(string): boolean} isInBucket
* @property {function(): boolean} isInSample
* @property {function(): boolean} isInTreatmentBucket
*/
/**
* @typedef {Object} SamplingRate
* @property {number} samplingRate The desired sampling rate for the group in the range [0, 1].
*/
/**
* @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.
*/
/**
* Initializes an AB test experiment.
*
* 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}
*/
module.exports = function webABTest( props ) {
let /** @type {string} */ cachedBucket;
/**
* Get the names of all the buckets from props.buckets.
*
* @return {string[]}
*/
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
buckets:
getBucketNames().reduce( ( buckets, key ) => {
// @ts-ignore
buckets[ key ] = props.buckets[ key ].samplingRate;
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() ) {
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 {
name: props.name,
getBucket,
isInBucket,
isInSample,
isInTreatmentBucket
};
};