From fd73d0e97529d4917af0e9a3cf6e1f8800911608 Mon Sep 17 00:00:00 2001 From: Martin Homola Date: Wed, 6 Nov 2024 16:54:57 +0100 Subject: [PATCH] feat(message-system): add ab testing message --- docs/features/message-system.md | 20 +- .../hooks/wallet/__fixtures__/useSendForm.ts | 1 + .../__tests__/messageSystemMiddleware.test.ts | 62 ++- .../suite/messageSystemMiddleware.ts | 12 +- .../schema/config.schema.v1.json | 407 ++++++++++-------- .../src/__fixtures__/messageSystemActions.ts | 3 + .../src/__fixtures__/messageSystemReducer.ts | 25 +- .../src/__fixtures__/messageSystemUtils.ts | 141 ++++++ .../__tests__/messageSystemActions.test.ts | 10 + .../src/__tests__/messageSystemUtils.test.ts | 41 ++ .../src/messageSystemActions.ts | 8 + .../src/messageSystemReducer.ts | 5 + .../src/messageSystemSelectors.ts | 22 +- .../message-system/src/messageSystemTypes.ts | 1 + .../message-system/src/messageSystemUtils.ts | 158 ++++--- suite-common/message-system/tsconfig.json | 1 + suite-common/suite-types/src/messageSystem.ts | 27 +- suite-common/test-utils/src/mocks.ts | 34 ++ 18 files changed, 715 insertions(+), 263 deletions(-) diff --git a/docs/features/message-system.md b/docs/features/message-system.md index f6d299ee9139..7ac2f4149f6d 100644 --- a/docs/features/message-system.md +++ b/docs/features/message-system.md @@ -20,6 +20,8 @@ There are multiple ways of displaying message to a user: - messages on specific places in app (e.g. settings page, banner in account page) - feature - disabling some feature with an explanation message +- distribution + - possibility to use AB testing on features/components ## Implementation @@ -205,7 +207,7 @@ Structure of config, types and optionality of specific keys can be found in the - critical (red) */ "variant": "warning", - // Options: banner, modal, context, feature + // Options: banner, modal, context, feature, distribution "category": "banner", /* - Message in language of Suite app is shown to a user. @@ -260,12 +262,26 @@ Structure of config, types and optionality of specific keys can be found in the "coins.btc" ] } - // Used only for feature + // Used only for feature "feature": [ { "domain": "coinjoin", "flag": false } + ], + /* + - Should be used only with category: "distribution" + - min distribution items are two + */ + "groups": [ + { + "variant": "A", + "percentage": 30 + }, + { + "variant": "B", + "percentage": 70 + } ] } } diff --git a/packages/suite/src/hooks/wallet/__fixtures__/useSendForm.ts b/packages/suite/src/hooks/wallet/__fixtures__/useSendForm.ts index 4004cecde7f5..3a4e0dff9c48 100644 --- a/packages/suite/src/hooks/wallet/__fixtures__/useSendForm.ts +++ b/packages/suite/src/hooks/wallet/__fixtures__/useSendForm.ts @@ -318,6 +318,7 @@ export const getRootReducer = (selectedAccount = BTC_ACCOUNT, fees = DEFAULT_FEE feature: [], }, dismissedMessages: {}, + validExperiments: [], }, () => ({}), ), diff --git a/packages/suite/src/middlewares/suite/__tests__/messageSystemMiddleware.test.ts b/packages/suite/src/middlewares/suite/__tests__/messageSystemMiddleware.test.ts index 026f1c6ec1a7..9d55883118a2 100644 --- a/packages/suite/src/middlewares/suite/__tests__/messageSystemMiddleware.test.ts +++ b/packages/suite/src/middlewares/suite/__tests__/messageSystemMiddleware.test.ts @@ -12,7 +12,7 @@ import { AppState } from 'src/reducers/store'; import messageSystemMiddleware from '../messageSystemMiddleware'; -// Type annotation as workaround for typecheck error "The inferred type of 'default' cannot be named..." +// Type annotation as workaround for type-check error "The inferred type of 'default' cannot be named..." const messageSystemReducer: Reducer = prepareMessageSystemReducer(extraDependencies); const deviceReducer = prepareDeviceReducer(extraDependencies); @@ -71,7 +71,7 @@ const initStore = (preloadedState: State) => { }; describe('Message system middleware', () => { - it('prepares valid messages for being displayed', async () => { + it('prepares valid messages for being displayed', () => { const message1 = { id: '22e6444d-a586-4593-bc8d-5d013f193eba', category: 'banner', @@ -96,9 +96,10 @@ describe('Message system middleware', () => { message3, message4, ]); + jest.spyOn(messageSystemUtils, 'getValidExperiments').mockImplementation(() => []); const store = initStore(getInitialState(undefined, undefined)); - await store.dispatch({ + store.dispatch({ type: messageSystemActions.fetchSuccessUpdate.type, payload: { config: { sequence: 1 }, timestamp: 0 }, }); @@ -118,14 +119,61 @@ describe('Message system middleware', () => { feature: [message4.id], }, }, + { + type: messageSystemActions.updateValidExperiments.type, + payload: [], + }, ]); }); - it('saves messages even if there are no valid messages', async () => { + it('saves messages even if there are no valid messages', () => { jest.spyOn(messageSystemUtils, 'getValidMessages').mockImplementation(() => []); + jest.spyOn(messageSystemUtils, 'getValidExperiments').mockImplementation(() => []); + + const store = initStore(getInitialState(undefined, undefined)); + store.dispatch({ + type: messageSystemActions.fetchSuccessUpdate.type, + payload: { config: { sequence: 1 }, timestamp: 0 }, + }); + + const result = store.getActions(); + expect(result).toEqual([ + { + type: messageSystemActions.fetchSuccessUpdate.type, + payload: { config: { sequence: 1 }, timestamp: 0 }, + }, + { + type: messageSystemActions.updateValidMessages.type, + payload: { banner: [], context: [], modal: [], feature: [] }, + }, + { + type: messageSystemActions.updateValidExperiments.type, + payload: [], + }, + ]); + }); + + it('test of experiment action', () => { + const experiment1 = { + id: '3bed56a4-ecd8-4e0f-9e5f-014b484c2afa', + groups: [ + { + variant: 'A', + percentage: 25, + }, + { + variant: 'B', + percentage: 75, + }, + ], + }; + + jest.spyOn(messageSystemUtils, 'getValidExperiments').mockImplementation(() => [ + experiment1, + ]); const store = initStore(getInitialState(undefined, undefined)); - await store.dispatch({ + store.dispatch({ type: messageSystemActions.fetchSuccessUpdate.type, payload: { config: { sequence: 1 }, timestamp: 0 }, }); @@ -140,6 +188,10 @@ describe('Message system middleware', () => { type: messageSystemActions.updateValidMessages.type, payload: { banner: [], context: [], modal: [], feature: [] }, }, + { + type: messageSystemActions.updateValidExperiments.type, + payload: [experiment1.id], + }, ]); }); }); diff --git a/packages/suite/src/middlewares/suite/messageSystemMiddleware.ts b/packages/suite/src/middlewares/suite/messageSystemMiddleware.ts index 63fabcfc5b2a..e1ceda1b1a1e 100644 --- a/packages/suite/src/middlewares/suite/messageSystemMiddleware.ts +++ b/packages/suite/src/middlewares/suite/messageSystemMiddleware.ts @@ -6,6 +6,7 @@ import { messageSystemActions, categorizeMessages, getValidMessages, + getValidExperiments, } from '@suite-common/message-system'; import { SUITE } from 'src/actions/suite/constants'; @@ -43,10 +44,19 @@ const messageSystemMiddleware = enabledNetworks, }, }); - const categorizedValidMessages = categorizeMessages(validMessages); + const validExperiments = getValidExperiments(config, { + device, + transport, + settings: { + tor: getIsTorEnabled(torStatus), + enabledNetworks, + }, + }).map(item => item.id); + api.dispatch(messageSystemActions.updateValidMessages(categorizedValidMessages)); + api.dispatch(messageSystemActions.updateValidExperiments(validExperiments)); } return action; diff --git a/suite-common/message-system/schema/config.schema.v1.json b/suite-common/message-system/schema/config.schema.v1.json index bf169a66cdb4..0427fdb05efd 100644 --- a/suite-common/message-system/schema/config.schema.v1.json +++ b/suite-common/message-system/schema/config.schema.v1.json @@ -4,7 +4,7 @@ "title": "Message system", "description": "JSON schema of the Trezor Suite messaging system.", "type": "object", - "required": ["version", "timestamp", "sequence", "actions"], + "required": ["version", "timestamp", "sequence", "actions", "experiments"], "properties": { "version": { "description": "A version of the messaging system. In case we would change the format of the config itself.", @@ -30,186 +30,7 @@ "required": ["conditions", "message"], "properties": { "conditions": { - "type": "array", - "items": { - "title": "Condition", - "type": "object", - "properties": { - "duration": { - "title": "Duration", - "type": "object", - "required": ["from", "to"], - "properties": { - "from": { - "$ref": "#/definitions/date-time" - }, - "to": { - "$ref": "#/definitions/date-time" - } - }, - "additionalProperties": false - }, - "os": { - "title": "Operating System", - "type": "object", - "required": [ - "macos", - "linux", - "windows", - "android", - "ios", - "chromeos" - ], - "properties": { - "macos": { - "$ref": "#/definitions/version" - }, - "linux": { - "$ref": "#/definitions/version" - }, - "windows": { - "$ref": "#/definitions/version" - }, - "android": { - "$ref": "#/definitions/version" - }, - "ios": { - "$ref": "#/definitions/version" - }, - "chromeos": { - "$ref": "#/definitions/version" - } - }, - "additionalProperties": { - "$ref": "#/definitions/version" - } - }, - "environment": { - "title": "Environment", - "type": "object", - "required": ["desktop", "mobile", "web"], - "properties": { - "desktop": { - "$ref": "#/definitions/version" - }, - "mobile": { - "$ref": "#/definitions/version" - }, - "web": { - "$ref": "#/definitions/version" - }, - "revision": { - "type": "string" - } - } - }, - "browser": { - "title": "Browser", - "type": "object", - "required": ["firefox", "chrome", "chromium"], - "properties": { - "firefox": { - "$ref": "#/definitions/version" - }, - "chrome": { - "$ref": "#/definitions/version" - }, - "chromium": { - "$ref": "#/definitions/version" - } - }, - "additionalProperties": { - "$ref": "#/definitions/version" - } - }, - "transport": { - "title": "Transport", - "type": "object", - "required": ["bridge", "webusbplugin"], - "properties": { - "bridge": { - "$ref": "#/definitions/version" - }, - "webusbplugin": { - "$ref": "#/definitions/version" - } - }, - "additionalProperties": { - "$ref": "#/definitions/version" - } - }, - "settings": { - "type": "array", - "minItems": 1, - "items": { - "title": "Settings", - "description": "If a setting is not specified, then it can be either true or false. Currently, 'tor' and coin symbols are supported.", - "type": "object", - "properties": { - "tor": { - "type": "boolean" - } - }, - "additionalProperties": true - } - }, - "devices": { - "type": "array", - "items": { - "title": "Device", - "type": "object", - "required": [ - "model", - "firmwareRevision", - "firmware", - "bootloader", - "variant", - "vendor" - ], - "properties": { - "model": { - "title": "Model", - "type": "string", - "enum": [ - "1", - "T", - "T1B1", - "T2T1", - "T2B1", - "Safe 3", - "T3B1", - "T3T1", - "" - ] - }, - "firmwareRevision": { - "title": "Firmware Revision", - "type": "string" - }, - "firmware": { - "$ref": "#/definitions/version" - }, - "bootloader": { - "$ref": "#/definitions/version" - }, - "variant": { - "title": "Firmware Variant", - "type": "string", - "enum": ["*", "bitcoin-only", "regular"] - }, - "vendor": { - "title": "Vendor", - "description": "Eligible authorized vendors.", - "type": "string", - "enum": ["*", "trezor.io"] - } - }, - "additionalProperties": false - } - } - }, - "additionalProperties": false - } + "$ref": "#/definitions/conditions" }, "message": { "title": "Message", @@ -346,6 +167,55 @@ }, "additionalProperties": false } + }, + "experiments": { + "type": "array", + "uniqueItems": true, + "items": { + "title": "Experiments", + "type": "object", + "required": ["conditions", "experiment"], + "properties": { + "conditions": { + "$ref": "#/definitions/conditions" + }, + "experiment": { + "title": "Experiments item", + "description": "Used for AB testing", + "type": "object", + "required": ["id", "groups"], + "properties": { + "id": { + "type": "string" + }, + "groups": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": ["variant", "percentage"], + "properties": { + "variant": { + "type": "string", + "description": "The name of the variant, e.g., 'A' or 'B'" + }, + "percentage": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Percentage of users for this variant (0-100)" + } + }, + "additionalProperties": false + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } } }, "additionalProperties": false, @@ -436,6 +306,181 @@ "description": "ISO 8601 date-time format.", "type": "string", "examples": ["2021-03-03T03:48:16+00:00", "2021-03-03T03:48:16Z"] + }, + "conditions": { + "type": "array", + "items": { + "title": "Condition", + "type": "object", + "properties": { + "duration": { + "title": "Duration", + "type": "object", + "required": ["from", "to"], + "properties": { + "from": { + "$ref": "#/definitions/date-time" + }, + "to": { + "$ref": "#/definitions/date-time" + } + }, + "additionalProperties": false + }, + "os": { + "title": "Operating System", + "type": "object", + "required": ["macos", "linux", "windows", "android", "ios", "chromeos"], + "properties": { + "macos": { + "$ref": "#/definitions/version" + }, + "linux": { + "$ref": "#/definitions/version" + }, + "windows": { + "$ref": "#/definitions/version" + }, + "android": { + "$ref": "#/definitions/version" + }, + "ios": { + "$ref": "#/definitions/version" + }, + "chromeos": { + "$ref": "#/definitions/version" + } + }, + "additionalProperties": { + "$ref": "#/definitions/version" + } + }, + "environment": { + "title": "Environment", + "type": "object", + "required": ["desktop", "mobile", "web"], + "properties": { + "desktop": { + "$ref": "#/definitions/version" + }, + "mobile": { + "$ref": "#/definitions/version" + }, + "web": { + "$ref": "#/definitions/version" + }, + "revision": { + "type": "string" + } + } + }, + "browser": { + "title": "Browser", + "type": "object", + "required": ["firefox", "chrome", "chromium"], + "properties": { + "firefox": { + "$ref": "#/definitions/version" + }, + "chrome": { + "$ref": "#/definitions/version" + }, + "chromium": { + "$ref": "#/definitions/version" + } + }, + "additionalProperties": { + "$ref": "#/definitions/version" + } + }, + "transport": { + "title": "Transport", + "type": "object", + "required": ["bridge", "webusbplugin"], + "properties": { + "bridge": { + "$ref": "#/definitions/version" + }, + "webusbplugin": { + "$ref": "#/definitions/version" + } + }, + "additionalProperties": { + "$ref": "#/definitions/version" + } + }, + "settings": { + "type": "array", + "minItems": 1, + "items": { + "title": "Settings", + "description": "If a setting is not specified, then it can be either true or false. Currently, 'tor' and coin symbols are supported.", + "type": "object", + "properties": { + "tor": { + "type": "boolean" + } + }, + "additionalProperties": true + } + }, + "devices": { + "type": "array", + "items": { + "title": "Device", + "type": "object", + "required": [ + "model", + "firmwareRevision", + "firmware", + "bootloader", + "variant", + "vendor" + ], + "properties": { + "model": { + "title": "Model", + "type": "string", + "enum": [ + "1", + "T", + "T1B1", + "T2T1", + "T2B1", + "Safe 3", + "T3B1", + "T3T1", + "" + ] + }, + "firmwareRevision": { + "title": "Firmware Revision", + "type": "string" + }, + "firmware": { + "$ref": "#/definitions/version" + }, + "bootloader": { + "$ref": "#/definitions/version" + }, + "variant": { + "title": "Firmware Variant", + "type": "string", + "enum": ["*", "bitcoin-only", "regular"] + }, + "vendor": { + "title": "Vendor", + "description": "Eligible authorized vendors.", + "type": "string", + "enum": ["*", "trezor.io"] + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } } } } diff --git a/suite-common/message-system/src/__fixtures__/messageSystemActions.ts b/suite-common/message-system/src/__fixtures__/messageSystemActions.ts index eec993ca849d..802909c870a0 100644 --- a/suite-common/message-system/src/__fixtures__/messageSystemActions.ts +++ b/suite-common/message-system/src/__fixtures__/messageSystemActions.ts @@ -1,6 +1,9 @@ export const messageId1 = '22e6444d-a586-4593-bc8d-5d013f193eba'; export const messageId2 = '469c65a8-8632-11eb-8dcd-0242ac130003'; export const messageId3 = '506b1322-8632-11eb-8dcd-0242ac130003'; +export const messageId4 = '2d5579ec-a7c2-4c50-9311-c404133c8804'; + +export const experimentId1 = '3bed56a4-ecd8-4e0f-9e5f-014b484c2aff'; /* JWS below is signed config with only mandatory fields: diff --git a/suite-common/message-system/src/__fixtures__/messageSystemReducer.ts b/suite-common/message-system/src/__fixtures__/messageSystemReducer.ts index 3a57af5202a3..615c78a3db93 100644 --- a/suite-common/message-system/src/__fixtures__/messageSystemReducer.ts +++ b/suite-common/message-system/src/__fixtures__/messageSystemReducer.ts @@ -11,12 +11,14 @@ const config = { actions: [], }; const messageIds = ['22e6444d-a586-4593-bc8d-5d013f193eba', '469c65a8-8632-11eb-8dcd-0242ac130003']; +const experimentIds = ['3bed56a4-ecd8-4e0f-9e5f-014b484c2aff']; const initialState = { config: { version: 1, timestamp: '2020-01-01T00:00:00+00:00', sequence: 1, actions: [], + experiments: [], }, currentSequence: 1, timestamp: 0, @@ -28,6 +30,8 @@ const initialState = { feature: [], }, dismissedMessages: {}, + + validExperiments: [], }; export const fixtures = [ @@ -109,7 +113,12 @@ export const fixtures = [ actions: [ { type: messageSystemActions.updateValidMessages.type, - payload: { banner: messageIds, context: [], modal: [], feature: [] }, + payload: { + banner: messageIds, + context: [], + modal: [], + feature: [], + }, }, ], result: { @@ -155,6 +164,20 @@ export const fixtures = [ }, }, }, + { + description: 'Save valid experiments', + initialState, + actions: [ + { + type: messageSystemActions.updateValidExperiments.type, + payload: experimentIds, + }, + ], + result: { + ...initialState, + validExperiments: [...initialState.validExperiments, experimentIds[0]], + }, + }, { description: 'Default', initialState, diff --git a/suite-common/message-system/src/__fixtures__/messageSystemUtils.ts b/suite-common/message-system/src/__fixtures__/messageSystemUtils.ts index e7ab02150ac8..cbcee40e2456 100644 --- a/suite-common/message-system/src/__fixtures__/messageSystemUtils.ts +++ b/suite-common/message-system/src/__fixtures__/messageSystemUtils.ts @@ -1827,3 +1827,144 @@ export const getValidMessages = [ result: [getMessageSystemConfig().actions[1].message], }, ]; + +export const validateExperiments = [ + { + conditions: [ + { + environment: { + desktop: '*', + mobile: '!', + web: '*', + }, + }, + ], + experiment: { + id: 'experiment - case 1', + groups: [ + { + variant: 'A', + percentage: 30, + }, + { + variant: 'B', + percentage: 70, + }, + ], + }, + }, + { + conditions: [ + { + environment: { + desktop: '*', + mobile: '!', + web: '*', + }, + }, + ], + experiment: { + id: 'experiment - case 3', + groups: [ + { + variant: 'A', + percentage: 70, + }, + ], + }, + }, + { + conditions: [ + { + environment: { + desktop: '*', + mobile: '!', + web: '*', + }, + }, + ], + experiment: { + id: 'experiment - case 4', + groups: [ + { + variant: 'A', + percentage: 70, + }, + { + variant: 'B', + percentage: 29, + }, + { + variant: 'C', + percentage: 1, + }, + ], + }, + }, + { + conditions: [ + { + environment: { + desktop: '*', + mobile: '!', + web: '*', + }, + }, + ], + experiment: { + id: 'experiment - case 5', + groups: [ + { + variant: 'A', + percentage: 70, + }, + { + variant: 'B', + percentage: 29, + }, + ], + }, + }, + { + conditions: [ + { + environment: { + desktop: '*', + mobile: '!', + web: '*', + }, + }, + ], + experiment: { + id: 'experiment - case 6', + groups: [ + { + variant: 'A', + percentage: 70, + }, + { + variant: 'B', + percentage: 70, + }, + { + variant: 'C', + percentage: 20, + }, + ], + }, + }, +]; + +export const getValidExperiments = [ + { + description: 'getValidExperiments - case 1', + currentDate: '2021-04-01T12:10:00.000Z', + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', + osName: 'macos', + environment: '', + suiteVersion: '', + config: getMessageSystemConfig(), + result: [getMessageSystemConfig().experiments[1].experiment], + }, +]; diff --git a/suite-common/message-system/src/__tests__/messageSystemActions.test.ts b/suite-common/message-system/src/__tests__/messageSystemActions.test.ts index 86affe6db956..fd247ea076b0 100644 --- a/suite-common/message-system/src/__tests__/messageSystemActions.test.ts +++ b/suite-common/message-system/src/__tests__/messageSystemActions.test.ts @@ -244,4 +244,14 @@ describe('Message system actions', () => { feature: false, }); }); + + it('updateValidExperiments', () => { + const store = initStore(getInitialState()); + + const payload = [fixtures.experimentId1]; + + store.dispatch(messageSystemActions.updateValidExperiments(payload)); + + expect(store.getState().messageSystem.validExperiments).toEqual(payload); + }); }); diff --git a/suite-common/message-system/src/__tests__/messageSystemUtils.test.ts b/suite-common/message-system/src/__tests__/messageSystemUtils.test.ts index c7d91512b863..3ca7ee917ea0 100644 --- a/suite-common/message-system/src/__tests__/messageSystemUtils.test.ts +++ b/suite-common/message-system/src/__tests__/messageSystemUtils.test.ts @@ -123,4 +123,45 @@ describe('Message system utils', () => { }); }); }); + + describe('validateExperiment - control of total sum of groups percentage', () => { + const idOfValidExperiment = [0, 2]; + + fixtures.validateExperiments.forEach((f, index) => { + it(f.experiment.id, () => { + expect(messageSystem.validateExperiment(f.experiment)).toEqual( + idOfValidExperiment.includes(index), + ); + }); + }); + }); + + describe('getValidExperiments', () => { + let userAgentGetter: any; + const OLD_ENV = { ...process.env }; + + beforeEach(() => { + userAgentGetter = jest.spyOn(window.navigator, 'userAgent', 'get'); + }); + + afterEach(() => { + jest.resetModules(); + process.env = OLD_ENV; + }); + + fixtures.getValidExperiments.forEach(f => { + it(f.description, () => { + jest.spyOn(Date, 'now').mockImplementation(() => new Date(f.currentDate).getTime()); + // @ts-expect-error (getOsName returns union of string literals) + jest.spyOn(envUtils, 'getOsName').mockImplementation(() => f.osName); + userAgentGetter.mockReturnValue(f.userAgent); + // @ts-expect-error + jest.spyOn(envUtils, 'getEnvironment').mockImplementation(() => f.environment); + process.env.VERSION = f.suiteVersion; + + // @ts-expect-error + expect(messageSystem.getValidExperiments(f.config, f.options)).toEqual(f.result); + }); + }); + }); }); diff --git a/suite-common/message-system/src/messageSystemActions.ts b/suite-common/message-system/src/messageSystemActions.ts index b2a6a64ea92c..3201715bd61f 100644 --- a/suite-common/message-system/src/messageSystemActions.ts +++ b/suite-common/message-system/src/messageSystemActions.ts @@ -36,8 +36,16 @@ const dismissMessage = createAction( }), ); +const updateValidExperiments = createAction( + `${ACTION_PREFIX}/updateValidExperiments`, + (payload: string[]) => ({ + payload, + }), +); + export const messageSystemActions = { updateValidMessages, + updateValidExperiments, dismissMessage, fetchSuccess, fetchSuccessUpdate, diff --git a/suite-common/message-system/src/messageSystemReducer.ts b/suite-common/message-system/src/messageSystemReducer.ts index 235f027941cd..7446fe2c94d0 100644 --- a/suite-common/message-system/src/messageSystemReducer.ts +++ b/suite-common/message-system/src/messageSystemReducer.ts @@ -17,6 +17,8 @@ const initialState: MessageSystemState = { feature: [], }, dismissedMessages: {}, + + validExperiments: [], }; export const messageSystemPersistedWhitelist: Array = [ @@ -63,6 +65,9 @@ export const prepareMessageSystemReducer = createReducerWithExtraDeps( const messageState = getMessageStateById(state, id); messageState[category] = true; }) + .addCase(messageSystemActions.updateValidExperiments, (state, { payload }) => { + state.validExperiments = payload; + }) .addMatcher( action => action.type === extra.actionTypes.storageLoad, (state, action: AnyAction) => ({ diff --git a/suite-common/message-system/src/messageSystemSelectors.ts b/suite-common/message-system/src/messageSystemSelectors.ts index 49e52594e5d3..a0098eb43970 100644 --- a/suite-common/message-system/src/messageSystemSelectors.ts +++ b/suite-common/message-system/src/messageSystemSelectors.ts @@ -73,7 +73,7 @@ export const selectContextMessageContent = createMemoizedSelector( return { ...message, - content: message?.content[language] ?? message?.content.en, + content: message.content[language] ?? message.content.en, cta: message?.cta ? { ...message.cta, @@ -130,6 +130,8 @@ export const selectIsFeatureDisabled = ( }; const selectValidMessages = (state: MessageSystemRootState) => state.messageSystem.validMessages; +const selectValidExperiments = (state: MessageSystemRootState) => + state.messageSystem.validExperiments; const selectConfig = (state: MessageSystemRootState) => state.messageSystem.config; export const selectAllValidMessages = createMemoizedSelector( @@ -147,3 +149,21 @@ export const selectAllValidMessages = createMemoizedSelector( ); }, ); + +export const selectAllValidExperiments = createMemoizedSelector( + [selectConfig, selectValidExperiments], + (config, validExperiments) => { + const experiments = config?.experiments + .filter(experiment => validExperiments.includes(experiment.experiment.id)) + .map(experiment => experiment.experiment); + + if (!experiments?.length) return []; + + return experiments; + }, +); + +export const selectExperimentById = (id: string) => + createMemoizedSelector([selectAllValidExperiments], allValidExperiments => + allValidExperiments.find(experiment => experiment.id === id), + ); diff --git a/suite-common/message-system/src/messageSystemTypes.ts b/suite-common/message-system/src/messageSystemTypes.ts index 05c3d336c077..87a55249b743 100644 --- a/suite-common/message-system/src/messageSystemTypes.ts +++ b/suite-common/message-system/src/messageSystemTypes.ts @@ -10,6 +10,7 @@ export type MessageSystemState = { dismissedMessages: { [key: string]: MessageState; }; + validExperiments: string[]; }; export type MessageSystemRootState = { diff --git a/suite-common/message-system/src/messageSystemUtils.ts b/suite-common/message-system/src/messageSystemUtils.ts index a9808fa590fb..b823deed5306 100644 --- a/suite-common/message-system/src/messageSystemUtils.ts +++ b/suite-common/message-system/src/messageSystemUtils.ts @@ -20,6 +20,8 @@ import type { Transport, Device, Environment, + Condition, + ExperimentsItem, } from '@suite-common/suite-types'; import type { NetworkSymbol } from '@suite-common/wallet-config'; import type { TransportInfo } from '@trezor/connect'; @@ -217,11 +219,7 @@ export const validateEnvironmentCompatibility = ( ); }; -export const getValidMessages = (config: MessageSystem | null, options: Options): Message[] => { - if (!config) { - return []; - } - +export const validateConditions = (condition: Condition, options: Options) => { const { device, transport, settings } = options; const currentOsName = getOsName(); @@ -234,76 +232,94 @@ export const getValidMessages = (config: MessageSystem | null, options: Options) const suiteVersion = transformVersionToSemverFormat(getSuiteVersion()); const commitHash = getCommitHash(); + const { + duration: durationCondition, + environment: environmentCondition, + os: osCondition, + browser: browserCondition, + transport: transportCondition, + settings: settingsCondition, + devices: deviceCondition, + } = condition; + + if (durationCondition && !validateDurationCompatibility(durationCondition)) { + return false; + } + + if ( + environmentCondition && + !validateEnvironmentCompatibility( + environmentCondition, + environment, + suiteVersion, + commitHash, + ) + ) { + return false; + } + + if ( + osCondition && + !validateVersionCompatibility(osCondition, currentOsName, currentOsVersion) + ) { + return false; + } + + if ( + environment === 'web' && + browserCondition && + !validateVersionCompatibility(browserCondition, currentBrowserName, currentBrowserVersion) + ) { + return false; + } + + if (settingsCondition && !validateSettingsCompatibility(settingsCondition, settings)) { + return false; + } + + if (transportCondition && !validateTransportCompatibility(transportCondition, transport)) { + return false; + } + + if (deviceCondition && !validateDeviceCompatibility(deviceCondition, device)) { + return false; + } + + return true; +}; + +export const getValidMessages = (config: MessageSystem | null, options: Options): Message[] => { + if (!config) { + return []; + } + return config.actions .filter( action => !action.conditions.length || - action.conditions.some(condition => { - const { - duration: durationCondition, - environment: environmentCondition, - os: osCondition, - browser: browserCondition, - transport: transportCondition, - settings: settingsCondition, - devices: deviceCondition, - } = condition; - - if (durationCondition && !validateDurationCompatibility(durationCondition)) { - return false; - } - - if ( - environmentCondition && - !validateEnvironmentCompatibility( - environmentCondition, - environment, - suiteVersion, - commitHash, - ) - ) { - return false; - } - - if ( - osCondition && - !validateVersionCompatibility(osCondition, currentOsName, currentOsVersion) - ) { - return false; - } - - if ( - environment === 'web' && - browserCondition && - !validateVersionCompatibility( - browserCondition, - currentBrowserName, - currentBrowserVersion, - ) - ) { - return false; - } - - if ( - settingsCondition && - !validateSettingsCompatibility(settingsCondition, settings) - ) { - return false; - } - - if ( - transportCondition && - !validateTransportCompatibility(transportCondition, transport) - ) { - return false; - } - - if (deviceCondition && !validateDeviceCompatibility(deviceCondition, device)) { - return false; - } - - return true; - }), + action.conditions.some(condition => validateConditions(condition, options)), ) .map(action => action.message); }; + +export const validateExperiment = (experiment: ExperimentsItem) => + experiment.groups.length >= 2 && + experiment.groups.reduce((acc, item) => acc + item.percentage, 0) === 100; + +export const getValidExperiments = ( + config: MessageSystem | null, + options: Options, +): ExperimentsItem[] => { + if (!config) { + return []; + } + + return config.experiments + .filter( + experiment => + !experiment.conditions.length || + experiment.conditions.some(condition => validateConditions(condition, options)), + ) + .filter(experiment => validateExperiment(experiment.experiment)) + .map(experiment => experiment.experiment); +}; diff --git a/suite-common/message-system/tsconfig.json b/suite-common/message-system/tsconfig.json index 16f3ee6bd944..ffb792d5a879 100644 --- a/suite-common/message-system/tsconfig.json +++ b/suite-common/message-system/tsconfig.json @@ -8,6 +8,7 @@ { "path": "../../packages/connect" }, { "path": "../../packages/device-utils" }, { "path": "../../packages/env-utils" }, + { "path": "../../packages/type-utils" }, { "path": "../../packages/utils" }, { "path": "../test-utils" } ] diff --git a/suite-common/suite-types/src/messageSystem.ts b/suite-common/suite-types/src/messageSystem.ts index 31f8cf16181b..6709c376ada6 100644 --- a/suite-common/suite-types/src/messageSystem.ts +++ b/suite-common/suite-types/src/messageSystem.ts @@ -15,6 +15,7 @@ export type FirmwareVariant = '*' | 'bitcoin-only' | 'regular'; * Eligible authorized vendors. */ export type Vendor = '*' | 'trezor.io'; +export type Conditions = Condition[]; export type Variant = 'info' | 'warning' | 'critical'; export type Category = 'banner' | 'context' | 'modal' | 'feature'; @@ -32,9 +33,10 @@ export interface MessageSystem { */ sequence: number; actions: Action[]; + experiments: Experiments[]; } export interface Action { - conditions: Condition[]; + conditions: Conditions; message: Message; } export interface Condition { @@ -164,3 +166,26 @@ export interface Context { */ domain: string | string[]; } +export interface Experiments { + conditions: Conditions; + experiment: ExperimentsItem; +} +/** + * Used for AB testing + */ +export interface ExperimentsItem { + id: string; + /** + * @minItems 2 + */ + groups: { + /** + * The name of the variant, e.g., 'A' or 'B' + */ + variant: string; + /** + * Percentage of users for this variant (0-100) + */ + percentage: number; + }[]; +} diff --git a/suite-common/test-utils/src/mocks.ts b/suite-common/test-utils/src/mocks.ts index 75f764d70863..ca579450468d 100644 --- a/suite-common/test-utils/src/mocks.ts +++ b/suite-common/test-utils/src/mocks.ts @@ -478,6 +478,40 @@ const getMessageSystemConfig = ( ...action2, }, ], + experiments: [ + { + conditions: [], + experiment: { + id: '3bed56a4-ecd8-4e0f-9e5f-014b484c2aff', + groups: [ + { + variant: 'A', + percentage: 25, + }, + { + variant: 'B', + percentage: 10, + }, + ], + }, + }, + { + conditions: [], + experiment: { + id: '3bed56a4-ecd8-4e0f-9e5f-014b484c2afa', + groups: [ + { + variant: 'A', + percentage: 25, + }, + { + variant: 'B', + percentage: 75, + }, + ], + }, + }, + ], ...root, });