1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 18:00:18 +01:00

Add a/b test for full screen transaction confirmations (#7162)

* Adds ab test controller with a fullScreenVsPopup test

* Add migration for fullScreenVsPopup state

* Move abtest state under an 'abtests' object.

* MetaMask shows fullScreen group of a/b test unapproved txs in a full browser tab

* Ensure cancel metrics event in confirm-transaction-base.component.js is sent in all cases

* Switch to existing tab for unapproved tx if it exists when opening in full screen

* Send metrics event for entering a/b test from confirm screen

* Fix lint, unit and integration tests related to a/b test code

* Remove unnecessary tabs.query call in triggerUiInNewTab
This commit is contained in:
Dan J Miller 2019-09-24 17:08:38 -04:00 committed by GitHub
parent 0ad6e2ada8
commit 1bd22b58c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 240 additions and 15 deletions

View File

@ -233,11 +233,13 @@ function setupController (initState, initLangCode) {
// //
// MetaMask Controller // MetaMask Controller
// //
const { ABTestController = {} } = initState
const { abTests = {} } = ABTestController
const controller = new MetamaskController({ const controller = new MetamaskController({
// User confirmation callbacks: // User confirmation callbacks:
showUnconfirmedMessage: triggerUi, showUnconfirmedMessage: triggerUi,
showUnapprovedTx: triggerUi, showUnapprovedTx: abTests.fullScreenVsPopup === 'fullScreen' ? triggerUiInNewTab : triggerUi,
openPopup: openPopup, openPopup: openPopup,
closePopup: notificationManager.closePopup.bind(notificationManager), closePopup: notificationManager.closePopup.bind(notificationManager),
// initial state // initial state
@ -441,6 +443,20 @@ function triggerUi () {
}) })
} }
/**
* Opens a new browser tab for user confirmation
*/
function triggerUiInNewTab () {
const tabIdsArray = Object.keys(openMetamaskTabsIDs)
if (tabIdsArray.length) {
extension.tabs.update(parseInt(tabIdsArray[0], 10), { 'active': true }, () => {
extension.tabs.reload(parseInt(tabIdsArray[0], 10))
})
} else {
platform.openExtensionInBrowser()
}
}
/** /**
* Opens the browser popup for user confirmation of watchAsset * Opens the browser popup for user confirmation of watchAsset
* then it waits until user interact with the UI * then it waits until user interact with the UI

View File

@ -0,0 +1,57 @@
const ObservableStore = require('obs-store')
const extend = require('xtend')
const { getRandomArrayItem } = require('../lib/util')
/**
* a/b test descriptions:
* - `fullScreenVsPopup`:
* - description: tests whether showing tx confirmations in full screen in the browser will increase rates of successful
* confirmations
* - groups:
* - popup: this is the control group, which follows the current UX of showing tx confirmations in the notification
* window
* - fullScreen: this is the only test group, which will cause users to be shown tx confirmations in a full screen
* browser tab
*/
class ABTestController {
/**
* @constructor
* @param opts
*/
constructor (opts = {}) {
const { initState } = opts
this.store = new ObservableStore(extend({
abTests: {
fullScreenVsPopup: this._getRandomizedTestGroupName('fullScreenVsPopup'),
},
}, initState))
}
/**
* Returns the name of the test group to which the current user has been assigned
* @param {string} abTestKey the key of the a/b test
* @return {string} the name of the assigned test group
*/
getAssignedABTestGroupName (abTestKey) {
return this.store.getState().abTests[abTestKey]
}
/**
* Returns a randomly chosen name of a test group from a given a/b test
* @param {string} abTestKey the key of the a/b test
* @return {string} the name of the randomly selected test group
* @private
*/
_getRandomizedTestGroupName (abTestKey) {
const nameArray = ABTestController.abTestGroupNames[abTestKey]
return getRandomArrayItem(nameArray)
}
}
ABTestController.abTestGroupNames = {
fullScreenVsPopup: ['control', 'fullScreen'],
}
module.exports = ABTestController

View File

@ -144,6 +144,10 @@ function removeListeners (listeners, emitter) {
}) })
} }
function getRandomArrayItem (array) {
return array[Math.floor((Math.random() * array.length))]
}
module.exports = { module.exports = {
removeListeners, removeListeners,
applyListeners, applyListeners,
@ -154,4 +158,5 @@ module.exports = {
hexToBn, hexToBn,
bnToHex, bnToHex,
BnMultiplyByFraction, BnMultiplyByFraction,
getRandomArrayItem,
} }

View File

@ -39,6 +39,7 @@ const TransactionController = require('./controllers/transactions')
const TokenRatesController = require('./controllers/token-rates') const TokenRatesController = require('./controllers/token-rates')
const DetectTokensController = require('./controllers/detect-tokens') const DetectTokensController = require('./controllers/detect-tokens')
const ProviderApprovalController = require('./controllers/provider-approval') const ProviderApprovalController = require('./controllers/provider-approval')
const ABTestController = require('./controllers/ab-test')
const nodeify = require('./lib/nodeify') const nodeify = require('./lib/nodeify')
const accountImporter = require('./account-import-strategies') const accountImporter = require('./account-import-strategies')
const getBuyEthUrl = require('./lib/buy-eth-url') const getBuyEthUrl = require('./lib/buy-eth-url')
@ -270,6 +271,10 @@ module.exports = class MetamaskController extends EventEmitter {
preferencesController: this.preferencesController, preferencesController: this.preferencesController,
}) })
this.abTestController = new ABTestController({
initState: initState.ABTestController,
})
this.store.updateStructure({ this.store.updateStructure({
AppStateController: this.appStateController.store, AppStateController: this.appStateController.store,
TransactionController: this.txController.store, TransactionController: this.txController.store,
@ -285,6 +290,7 @@ module.exports = class MetamaskController extends EventEmitter {
ProviderApprovalController: this.providerApprovalController.store, ProviderApprovalController: this.providerApprovalController.store,
IncomingTransactionsController: this.incomingTransactionsController.store, IncomingTransactionsController: this.incomingTransactionsController.store,
ThreeBoxController: this.threeBoxController.store, ThreeBoxController: this.threeBoxController.store,
ABTestController: this.abTestController.store,
}) })
this.memStore = new ComposableObservableStore(null, { this.memStore = new ComposableObservableStore(null, {
@ -311,6 +317,7 @@ module.exports = class MetamaskController extends EventEmitter {
IncomingTransactionsController: this.incomingTransactionsController.store, IncomingTransactionsController: this.incomingTransactionsController.store,
// ThreeBoxController // ThreeBoxController
ThreeBoxController: this.threeBoxController.store, ThreeBoxController: this.threeBoxController.store,
ABTestController: this.abTestController.store,
}) })
this.memStore.subscribe(this.sendUpdate.bind(this)) this.memStore.subscribe(this.sendUpdate.bind(this))
} }
@ -426,6 +433,7 @@ module.exports = class MetamaskController extends EventEmitter {
const providerApprovalController = this.providerApprovalController const providerApprovalController = this.providerApprovalController
const onboardingController = this.onboardingController const onboardingController = this.onboardingController
const threeBoxController = this.threeBoxController const threeBoxController = this.threeBoxController
const abTestController = this.abTestController
return { return {
// etc // etc
@ -539,6 +547,9 @@ module.exports = class MetamaskController extends EventEmitter {
getThreeBoxLastUpdated: nodeify(threeBoxController.getLastUpdated, threeBoxController), getThreeBoxLastUpdated: nodeify(threeBoxController.getLastUpdated, threeBoxController),
turnThreeBoxSyncingOn: nodeify(threeBoxController.turnThreeBoxSyncingOn, threeBoxController), turnThreeBoxSyncingOn: nodeify(threeBoxController.turnThreeBoxSyncingOn, threeBoxController),
initializeThreeBox: nodeify(this.initializeThreeBox, this), initializeThreeBox: nodeify(this.initializeThreeBox, this),
// a/b test controller
getAssignedABTestGroupName: nodeify(abTestController.getAssignedABTestGroupName, abTestController),
} }
} }

View File

@ -0,0 +1,37 @@
const version = 38
const clone = require('clone')
const ABTestController = require('../controllers/ab-test')
const { getRandomArrayItem } = require('../lib/util')
/**
* The purpose of this migration is to assign all users to a test group for the fullScreenVsPopup a/b test
*/
module.exports = {
version,
migrate: async function (originalVersionedData) {
const versionedData = clone(originalVersionedData)
versionedData.meta.version = version
const state = versionedData.data
versionedData.data = transformState(state)
return versionedData
},
}
function transformState (state) {
const { ABTestController: ABTestControllerState = {} } = state
const { abTests = {} } = ABTestControllerState
if (!abTests.fullScreenVsPopup) {
state = {
...state,
ABTestController: {
...ABTestControllerState,
abTests: {
...abTests,
fullScreenVsPopup: getRandomArrayItem(ABTestController.abTestGroupNames.fullScreenVsPopup),
},
},
}
}
return state
}

View File

@ -48,4 +48,5 @@ module.exports = [
require('./035'), require('./035'),
require('./036'), require('./036'),
require('./037'), require('./037'),
require('./038'),
] ]

View File

@ -23,6 +23,9 @@
"name": "Send Account 4" "name": "Send Account 4"
} }
}, },
"abTests": {
"fullScreenVsPopup": "control"
},
"cachedBalances": {}, "cachedBalances": {},
"conversionRate": 1200.88200327, "conversionRate": 1200.88200327,
"conversionDate": 1489013762, "conversionDate": 1489013762,

View File

@ -23,6 +23,9 @@
"name": "Send Account 4" "name": "Send Account 4"
} }
}, },
"abTests": {
"fullScreenVsPopup": "control"
},
"cachedBalances": {}, "cachedBalances": {},
"unapprovedTxs": {}, "unapprovedTxs": {},
"conversionRate": 19855, "conversionRate": 19855,

View File

@ -23,6 +23,9 @@
"name": "Send Account 4" "name": "Send Account 4"
} }
}, },
"abTests": {
"fullScreenVsPopup": "control"
},
"cachedBalances": {}, "cachedBalances": {},
"currentCurrency": "USD", "currentCurrency": "USD",
"conversionRate": 1200.88200327, "conversionRate": 1200.88200327,

View File

@ -0,0 +1,60 @@
const assert = require('assert')
const migration38 = require('../../../app/scripts/migrations/038')
describe('migration #38', () => {
it('should update the version metadata', (done) => {
const oldStorage = {
'meta': {
'version': 37,
},
'data': {},
}
migration38.migrate(oldStorage)
.then((newStorage) => {
assert.deepEqual(newStorage.meta, {
'version': 38,
})
done()
})
.catch(done)
})
it('should add a fullScreenVsPopup property set to either "control" or "fullScreen"', (done) => {
const oldStorage = {
'meta': {},
'data': {},
}
migration38.migrate(oldStorage)
.then((newStorage) => {
assert(newStorage.data.ABTestController.abTests.fullScreenVsPopup.match(/control|fullScreen/))
done()
})
.catch(done)
})
it('should leave the fullScreenVsPopup property unchanged if it exists', (done) => {
const oldStorage = {
'meta': {},
'data': {
'ABTestController': {
abTests: {
'fullScreenVsPopup': 'fullScreen',
},
},
},
}
migration38.migrate(oldStorage)
.then((newStorage) => {
assert.deepEqual(newStorage.data.ABTestController, {
abTests: {
'fullScreenVsPopup': 'fullScreen',
},
})
done()
})
.catch(done)
})
})

View File

@ -349,19 +349,19 @@ export default class ConfirmTransactionBase extends Component {
const { metricsEvent } = this.context const { metricsEvent } = this.context
const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction, actionKey, txData: { origin }, methodData = {} } = this.props const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction, actionKey, txData: { origin }, methodData = {} } = this.props
metricsEvent({
eventOpts: {
category: 'Transactions',
action: 'Confirm Screen',
name: 'Cancel',
},
customVariables: {
recipientKnown: null,
functionType: actionKey || getMethodName(methodData.name) || 'contractInteraction',
origin,
},
})
if (onCancel) { if (onCancel) {
metricsEvent({
eventOpts: {
category: 'Transactions',
action: 'Confirm Screen',
name: 'Cancel',
},
customVariables: {
recipientKnown: null,
functionType: actionKey || getMethodName(methodData.name) || 'contractInteraction',
origin,
},
})
onCancel(txData) onCancel(txData)
} else { } else {
cancelTransaction(txData) cancelTransaction(txData)

View File

@ -23,6 +23,10 @@ import {
} from '../../helpers/constants/routes' } from '../../helpers/constants/routes'
export default class ConfirmTransaction extends Component { export default class ConfirmTransaction extends Component {
static contextTypes = {
metricsEvent: PropTypes.func,
}
static propTypes = { static propTypes = {
history: PropTypes.object.isRequired, history: PropTypes.object.isRequired,
totalUnapprovedCount: PropTypes.number.isRequired, totalUnapprovedCount: PropTypes.number.isRequired,
@ -39,6 +43,8 @@ export default class ConfirmTransaction extends Component {
paramsTransactionId: PropTypes.string, paramsTransactionId: PropTypes.string,
getTokenParams: PropTypes.func, getTokenParams: PropTypes.func,
isTokenMethodAction: PropTypes.bool, isTokenMethodAction: PropTypes.bool,
fullScreenVsPopupTestGroup: PropTypes.string,
trackABTest: PropTypes.bool,
} }
componentDidMount () { componentDidMount () {
@ -53,6 +59,8 @@ export default class ConfirmTransaction extends Component {
paramsTransactionId, paramsTransactionId,
getTokenParams, getTokenParams,
isTokenMethodAction, isTokenMethodAction,
fullScreenVsPopupTestGroup,
trackABTest,
} = this.props } = this.props
if (!totalUnapprovedCount && !send.to) { if (!totalUnapprovedCount && !send.to) {
@ -67,6 +75,16 @@ export default class ConfirmTransaction extends Component {
} }
const txId = transactionId || paramsTransactionId const txId = transactionId || paramsTransactionId
if (txId) this.props.setTransactionToConfirm(txId) if (txId) this.props.setTransactionToConfirm(txId)
if (trackABTest) {
this.context.metricsEvent({
eventOpts: {
category: 'abtesting',
action: 'fullScreenVsPopup',
name: fullScreenVsPopupTestGroup === 'fullScreen' ? 'fullscreen' : 'original',
},
})
}
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {

View File

@ -20,7 +20,14 @@ import ConfirmTransaction from './confirm-transaction.component'
import { unconfirmedTransactionsListSelector } from '../../selectors/confirm-transaction' import { unconfirmedTransactionsListSelector } from '../../selectors/confirm-transaction'
const mapStateToProps = (state, ownProps) => { const mapStateToProps = (state, ownProps) => {
const { metamask: { send, unapprovedTxs }, confirmTransaction } = state const {
metamask: {
send,
unapprovedTxs,
abTests: { fullScreenVsPopup },
},
confirmTransaction,
} = state
const { match: { params = {} } } = ownProps const { match: { params = {} } } = ownProps
const { id } = params const { id } = params
@ -29,7 +36,9 @@ const mapStateToProps = (state, ownProps) => {
const transaction = totalUnconfirmed const transaction = totalUnconfirmed
? unapprovedTxs[id] || unconfirmedTransactions[totalUnconfirmed - 1] ? unapprovedTxs[id] || unconfirmedTransactions[totalUnconfirmed - 1]
: {} : {}
const { id: transactionId, transactionCategory } = transaction const { id: transactionId, transactionCategory, origin } = transaction
const trackABTest = origin !== 'MetaMask'
return { return {
totalUnapprovedCount: totalUnconfirmed, totalUnapprovedCount: totalUnconfirmed,
@ -42,6 +51,8 @@ const mapStateToProps = (state, ownProps) => {
unconfirmedTransactions, unconfirmedTransactions,
transaction, transaction,
isTokenMethodAction: isTokenMethodAction(transactionCategory), isTokenMethodAction: isTokenMethodAction(transactionCategory),
trackABTest,
fullScreenVsPopupTestGroup: fullScreenVsPopup,
} }
} }