Merge branch '335331-ensure-experiment-is-in-gon-object' into 'master'

Ensure trial status popover events include gitlab_experiment context

See merge request gitlab-org/gitlab!69327
This commit is contained in:
Nikola Milojevic 2021-11-10 17:26:41 +00:00
commit a40517e8ca
10 changed files with 231 additions and 101 deletions

View file

@ -3,7 +3,12 @@ import { get } from 'lodash';
import { DEFAULT_VARIANT, CANDIDATE_VARIANT, TRACKING_CONTEXT_SCHEMA } from './constants';
function getExperimentsData() {
return get(window, ['gon', 'experiment'], {});
// Pull from deprecated window.gon.experiment
const experimentsFromGon = get(window, ['gon', 'experiment'], {});
// Pull from preferred window.gl.experiments
const experimentsFromGl = get(window, ['gl', 'experiments'], {});
return { ...experimentsFromGon, ...experimentsFromGl };
}
function convertExperimentDataToExperimentContext(experimentData) {

View file

@ -0,0 +1,4 @@
= javascript_tag(nonce: content_security_policy_nonce) do
:plain
gl = window.gl || {};
gl.experiments = #{raw ApplicationExperiment.published_experiments.reject { |name, data| data[:excluded] }.to_json};

View file

@ -16,4 +16,5 @@
= render 'layouts/img_loader'
= render 'layouts/published_experiments'
= yield :scripts_body

View file

@ -92,7 +92,7 @@ end
```
When this code executes, the experiment is run, a variant is assigned, and (if within a
controller or view) a `window.gon.experiment.pill_color` object will be available in the
controller or view) a `window.gl.experiments.pill_color` object will be available in the
client layer, with details like:
- The assigned variant.
@ -522,14 +522,14 @@ shared example: [tracks assignment and records the subject](https://gitlab.com/g
This is in flux as of GitLab 13.10, and can't be documented just yet.
Any experiment that's been run in the request lifecycle surfaces in `window.gon.experiment`,
Any experiment that's been run in the request lifecycle surfaces in and `window.gl.experiments`,
and matches [this schema](https://gitlab.com/gitlab-org/iglu/-/blob/master/public/schemas/com.gitlab/gitlab_experiment/jsonschema/1-0-0)
so you can use it when resolving some concepts around experimentation in the client layer.
### Use experiments in Vue
With the `gitlab-experiment` component, you can define slots that match the name of the
variants pushed to `window.gon.experiment`. For example, if we alter the `pill_color`
variants pushed to `window.gl.experiments`. For example, if we alter the `pill_color`
experiment to just use the default variants of `control` and `candidate` like so:
```ruby
@ -587,7 +587,51 @@ For example, the Vue component for the previously-defined `pill_color` experimen
```
NOTE:
When there is no experiment data in the `window.gon.experiment` object for the given experiment name, the `control` slot will be used, if it exists.
When there is no experiment data in the `window.gl.experiments` object for the given experiment name, the `control` slot will be used, if it exists.
## Test with Jest
### Stub Helpers
You can stub experiments using the `stubExperiments` helper defined in `spec/frontend/__helpers__/experimentation_helper.js`.
```javascript
import { stubExperiments } from 'helpers/experimentation_helper';
import { getExperimentData } from '~/experimentation/utils';
describe('when my_experiment is enabled', () => {
beforeEach(() => {
stubExperiments({ my_experiment: 'candidate' });
});
it('sets the correct data', () => {
expect(getExperimentData('my_experiment')).toEqual({ experiment: 'my_experiment', variant: 'candidate' });
});
});
```
NOTE:
This method of stubbing in Jest specs will not automatically un-stub itself at the end of the test. We merge our stubbed experiment in with all the other global data in `window.gl`. If you need to remove the stubbed experiment(s) after your test or ensure a clean global object before your test, you'll need to manage the global object directly yourself:
```javascript
desribe('tests that care about global state', () => {
const originalObjects = [];
beforeEach(() => {
// For backwards compatibility for now, we're using both window.gon & window.gl
originalObjects.push(window.gon, window.gl);
});
afterEach(() => {
[window.gon, window.gl] = originalObjects;
});
it('stubs experiment in fresh global state', () => {
stubExperiment({ my_experiment: 'candidate' });
// ...
});
})
```
## Notes on feature flags

View file

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils';
import MrWidgetEnableFeaturePrompt from 'ee/vue_merge_request_widget/components/states/mr_widget_enable_feature_prompt.vue';
import { assignGitlabExperiment } from 'helpers/experimentation_helper';
import { stubExperiments } from 'helpers/experimentation_helper';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
const FEATURE = 'my_feature_name';
@ -30,19 +30,16 @@ describe('MrWidgetEnableFeaturePrompt', () => {
});
describe('when the experiment is not enabled', () => {
beforeAll(() => {
assignGitlabExperiment(FEATURE, 'control');
});
it('renders nothing', () => {
stubExperiments({ [FEATURE]: 'control' });
expect(wrapper.text()).toBe('');
});
});
describe('when the experiment is enabled', () => {
beforeAll(() => {
stubExperiments({ [FEATURE]: 'candidate' });
localStorage.removeItem(LOCAL_STORAGE_KEY);
assignGitlabExperiment(FEATURE, 'candidate');
});
it('shows a neutral icon', () => {

View file

@ -24,7 +24,7 @@ import {
coverageFuzzingDiffSuccessMock,
apiFuzzingDiffSuccessMock,
} from 'ee_jest/vue_shared/security_reports/mock_data';
import { assignGitlabExperiment } from 'helpers/experimentation_helper';
import { stubExperiments } from 'helpers/experimentation_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import { trimText } from 'helpers/text_helper';
@ -221,7 +221,9 @@ describe('ee merge request widget options', () => {
});
describe('security_reports_mr_widget_prompt experiment', () => {
assignGitlabExperiment('security_reports_mr_widget_prompt', 'candidate');
beforeEach(() => {
stubExperiments({ security_reports_mr_widget_prompt: 'candidate' });
});
it('prompts to enable the feature', () => {
createComponent({ propsData: { mrData: mockData } });

View file

@ -1,5 +1,6 @@
import { merge } from 'lodash';
// This helper is for specs that use `gitlab/experimentation` module
export function withGonExperiment(experimentKey, value = true) {
let origGon;
@ -12,16 +13,26 @@ export function withGonExperiment(experimentKey, value = true) {
window.gon = origGon;
});
}
// This helper is for specs that use `gitlab-experiment` utilities, which have a different schema that gets pushed via Gon compared to `Experimentation Module`
export function assignGitlabExperiment(experimentKey, variant) {
let origGon;
beforeEach(() => {
origGon = window.gon;
window.gon = { experiment: { [experimentKey]: { variant } } };
});
// The following helper is for specs that use `gitlab-experiment` utilities,
// which have a different schema that gets pushed to the frontend compared to
// the `Experimentation` Module.
//
// Usage: stubExperiments({ experiment_feature_flag_name: 'variant_name', ... })
export function stubExperiments(experiments = {}) {
// Deprecated
window.gon = window.gon || {};
window.gon.experiment = window.gon.experiment || {};
// Preferred
window.gl = window.gl || {};
window.gl.experiments = window.gl.experiemnts || {};
afterEach(() => {
window.gon = origGon;
Object.entries(experiments).forEach(([name, variant]) => {
const experimentData = { experiment: name, variant };
// Deprecated
window.gon.experiment[name] = experimentData;
// Preferred
window.gl.experiments[name] = experimentData;
});
}

View file

@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import NewBoardButton from '~/boards/components/new_board_button.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { assignGitlabExperiment } from 'helpers/experimentation_helper';
import { stubExperiments } from 'helpers/experimentation_helper';
import eventHub from '~/boards/eventhub';
const FEATURE = 'prominent_create_board_btn';
@ -28,7 +28,9 @@ describe('NewBoardButton', () => {
});
describe('control variant', () => {
assignGitlabExperiment(FEATURE, 'control');
beforeAll(() => {
stubExperiments({ [FEATURE]: 'control' });
});
it('renders nothing', () => {
wrapper = createComponent();
@ -38,7 +40,9 @@ describe('NewBoardButton', () => {
});
describe('candidate variant', () => {
assignGitlabExperiment(FEATURE, 'candidate');
beforeAll(() => {
stubExperiments({ [FEATURE]: 'candidate' });
});
it('renders New board button when `candidate` variant', () => {
wrapper = createComponent();

View file

@ -1,4 +1,4 @@
import { assignGitlabExperiment } from 'helpers/experimentation_helper';
import { stubExperiments } from 'helpers/experimentation_helper';
import {
DEFAULT_VARIANT,
CANDIDATE_VARIANT,
@ -7,15 +7,45 @@ import {
import * as experimentUtils from '~/experimentation/utils';
describe('experiment Utilities', () => {
const TEST_KEY = 'abc';
const ABC_KEY = 'abc';
const DEF_KEY = 'def';
let origGon;
let origGl;
beforeEach(() => {
origGon = window.gon;
origGl = window.gl;
window.gon.experiment = {};
window.gl.experiments = {};
});
afterEach(() => {
window.gon = origGon;
window.gl = origGl;
});
describe('getExperimentData', () => {
const ABC_DATA = '_abc_data_';
const ABC_DATA2 = '_updated_abc_data_';
const DEF_DATA = '_def_data_';
describe.each`
gon | input | output
${[TEST_KEY, '_data_']} | ${[TEST_KEY]} | ${{ variant: '_data_' }}
${[]} | ${[TEST_KEY]} | ${undefined}
`('with input=$input and gon=$gon', ({ gon, input, output }) => {
assignGitlabExperiment(...gon);
gonData | glData | input | output
${[ABC_KEY, ABC_DATA]} | ${[]} | ${[ABC_KEY]} | ${{ experiment: ABC_KEY, variant: ABC_DATA }}
${[]} | ${[ABC_KEY, ABC_DATA]} | ${[ABC_KEY]} | ${{ experiment: ABC_KEY, variant: ABC_DATA }}
${[ABC_KEY, ABC_DATA]} | ${[DEF_KEY, DEF_DATA]} | ${[ABC_KEY]} | ${{ experiment: ABC_KEY, variant: ABC_DATA }}
${[ABC_KEY, ABC_DATA]} | ${[DEF_KEY, DEF_DATA]} | ${[DEF_KEY]} | ${{ experiment: DEF_KEY, variant: DEF_DATA }}
${[ABC_KEY, ABC_DATA]} | ${[ABC_KEY, ABC_DATA2]} | ${[ABC_KEY]} | ${{ experiment: ABC_KEY, variant: ABC_DATA2 }}
${[]} | ${[]} | ${[ABC_KEY]} | ${undefined}
`('with input=$input, gon=$gonData, & gl=$glData', ({ gonData, glData, input, output }) => {
beforeEach(() => {
const [gonKey, gonVariant] = gonData;
const [glKey, glVariant] = glData;
if (gonKey) window.gon.experiment[gonKey] = { experiment: gonKey, variant: gonVariant };
if (glKey) window.gl.experiments[glKey] = { experiment: glKey, variant: glVariant };
});
it(`returns ${output}`, () => {
expect(experimentUtils.getExperimentData(...input)).toEqual(output);
@ -25,66 +55,47 @@ describe('experiment Utilities', () => {
describe('getAllExperimentContexts', () => {
const schema = TRACKING_CONTEXT_SCHEMA;
let origGon;
beforeEach(() => {
origGon = window.gon;
});
afterEach(() => {
window.gon = origGon;
});
it('collects all of the experiment contexts into a single array', () => {
const experiments = [
{ experiment: 'abc', variant: 'candidate' },
{ experiment: 'def', variant: 'control' },
{ experiment: 'ghi', variant: 'blue' },
];
window.gon = {
experiment: experiments.reduce((collector, { experiment, variant }) => {
return { ...collector, [experiment]: { experiment, variant } };
}, {}),
};
const experiments = { [ABC_KEY]: 'candidate', [DEF_KEY]: 'control', ghi: 'blue' };
stubExperiments(experiments);
expect(experimentUtils.getAllExperimentContexts()).toEqual(
experiments.map((data) => ({ schema, data })),
Object.entries(experiments).map(([experiment, variant]) => ({
schema,
data: { experiment, variant },
})),
);
});
it('returns an empty array if there are no experiments', () => {
window.gon.experiment = {};
expect(experimentUtils.getAllExperimentContexts()).toEqual([]);
});
it('includes all additional experiment data', () => {
const experiment = 'experimentWithCustomData';
const data = { experiment, variant: 'control', color: 'blue', style: 'rounded' };
window.gon.experiment[experiment] = data;
expect(experimentUtils.getAllExperimentContexts()).toContainEqual({ schema, data });
});
});
describe('isExperimentVariant', () => {
describe.each`
gon | input | output
${[TEST_KEY, DEFAULT_VARIANT]} | ${[TEST_KEY, DEFAULT_VARIANT]} | ${true}
${[TEST_KEY, '_variant_name']} | ${[TEST_KEY, '_variant_name']} | ${true}
${[TEST_KEY, '_variant_name']} | ${[TEST_KEY, '_bogus_name']} | ${false}
${[TEST_KEY, '_variant_name']} | ${['boguskey', '_variant_name']} | ${false}
${[]} | ${[TEST_KEY, '_variant_name']} | ${false}
`('with input=$input and gon=$gon', ({ gon, input, output }) => {
assignGitlabExperiment(...gon);
experiment | variant | input | output
${ABC_KEY} | ${DEFAULT_VARIANT} | ${[ABC_KEY, DEFAULT_VARIANT]} | ${true}
${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_variant_name']} | ${true}
${ABC_KEY} | ${'_variant_name'} | ${[ABC_KEY, '_bogus_name']} | ${false}
${ABC_KEY} | ${'_variant_name'} | ${['boguskey', '_variant_name']} | ${false}
${undefined} | ${undefined} | ${[ABC_KEY, '_variant_name']} | ${false}
`(
'with input=$input, experiment=$experiment, variant=$variant',
({ experiment, variant, input, output }) => {
it(`returns ${output}`, () => {
if (experiment) stubExperiments({ [experiment]: variant });
it(`returns ${output}`, () => {
expect(experimentUtils.isExperimentVariant(...input)).toEqual(output);
});
});
expect(experimentUtils.isExperimentVariant(...input)).toEqual(output);
});
},
);
});
describe('experiment', () => {
const experiment = 'marley';
const useSpy = jest.fn();
const controlSpy = jest.fn();
const trySpy = jest.fn();
@ -98,49 +109,62 @@ describe('experiment Utilities', () => {
};
describe('when there is no experiment data', () => {
it('calls control variant', () => {
experimentUtils.experiment('marley', variants);
expect(useSpy).toHaveBeenCalled();
});
});
describe('when experiment variant is "control"', () => {
assignGitlabExperiment('marley', DEFAULT_VARIANT);
it('calls the control variant', () => {
experimentUtils.experiment('marley', variants);
it('calls the use variant', () => {
experimentUtils.experiment(experiment, variants);
expect(useSpy).toHaveBeenCalled();
});
describe("when 'control' is provided instead of 'use'", () => {
it('calls the control variant', () => {
experimentUtils.experiment('marley', { control: controlSpy });
experimentUtils.experiment(experiment, { control: controlSpy });
expect(controlSpy).toHaveBeenCalled();
});
});
});
describe('when experiment variant is "control"', () => {
beforeEach(() => {
stubExperiments({ [experiment]: DEFAULT_VARIANT });
});
it('calls the use variant', () => {
experimentUtils.experiment(experiment, variants);
expect(useSpy).toHaveBeenCalled();
});
describe("when 'control' is provided instead of 'use'", () => {
it('calls the control variant', () => {
experimentUtils.experiment(experiment, { control: controlSpy });
expect(controlSpy).toHaveBeenCalled();
});
});
});
describe('when experiment variant is "candidate"', () => {
assignGitlabExperiment('marley', CANDIDATE_VARIANT);
beforeEach(() => {
stubExperiments({ [experiment]: CANDIDATE_VARIANT });
});
it('calls the candidate variant', () => {
experimentUtils.experiment('marley', variants);
it('calls the try variant', () => {
experimentUtils.experiment(experiment, variants);
expect(trySpy).toHaveBeenCalled();
});
describe("when 'candidate' is provided instead of 'try'", () => {
it('calls the control variant', () => {
experimentUtils.experiment('marley', { candidate: candidateSpy });
it('calls the candidate variant', () => {
experimentUtils.experiment(experiment, { candidate: candidateSpy });
expect(candidateSpy).toHaveBeenCalled();
});
});
});
describe('when experiment variant is "get_up_stand_up"', () => {
assignGitlabExperiment('marley', 'get_up_stand_up');
beforeEach(() => {
stubExperiments({ [experiment]: 'get_up_stand_up' });
});
it('calls the get-up-stand-up variant', () => {
experimentUtils.experiment('marley', variants);
experimentUtils.experiment(experiment, variants);
expect(getUpStandUpSpy).toHaveBeenCalled();
});
});
@ -148,14 +172,17 @@ describe('experiment Utilities', () => {
describe('getExperimentVariant', () => {
it.each`
gon | input | output
${{ experiment: { [TEST_KEY]: { variant: DEFAULT_VARIANT } } }} | ${[TEST_KEY]} | ${DEFAULT_VARIANT}
${{ experiment: { [TEST_KEY]: { variant: CANDIDATE_VARIANT } } }} | ${[TEST_KEY]} | ${CANDIDATE_VARIANT}
${{}} | ${[TEST_KEY]} | ${DEFAULT_VARIANT}
`('with input=$input and gon=$gon, returns $output', ({ gon, input, output }) => {
window.gon = gon;
experiment | variant | input | output
${ABC_KEY} | ${DEFAULT_VARIANT} | ${ABC_KEY} | ${DEFAULT_VARIANT}
${ABC_KEY} | ${CANDIDATE_VARIANT} | ${ABC_KEY} | ${CANDIDATE_VARIANT}
${undefined} | ${undefined} | ${ABC_KEY} | ${DEFAULT_VARIANT}
`(
'with input=$input, experiment=$experiment, & variant=$variant; returns $output',
({ experiment, variant, input, output }) => {
stubExperiments({ [experiment]: variant });
expect(experimentUtils.getExperimentVariant(...input)).toEqual(output);
});
expect(experimentUtils.getExperimentVariant(input)).toEqual(output);
},
);
});
});

View file

@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'layouts/_published_experiments', :experiment do
before do
stub_const('TestControlExperiment', ApplicationExperiment)
stub_const('TestCandidateExperiment', ApplicationExperiment)
stub_const('TestExcludedExperiment', ApplicationExperiment)
TestControlExperiment.new('test_control').tap do |e|
e.variant(:control)
e.publish
end
TestCandidateExperiment.new('test_candidate').tap do |e|
e.variant(:candidate)
e.publish
end
TestExcludedExperiment.new('test_excluded').tap do |e|
e.exclude!
e.publish
end
render
end
it 'renders out data for all non-excluded, published experiments' do
output = rendered
expect(output).to include('gl.experiments = {')
expect(output).to match(/"test_control":\{[^}]*"variant":"control"/)
expect(output).to match(/"test_candidate":\{[^}]*"variant":"candidate"/)
expect(output).not_to include('"test_excluded"')
end
end