feat: add logic for "cypress-triggered events" (#24101)

Co-authored-by: Zachary Williams <ZachJW34@gmail.com>
This commit is contained in:
Mark Noonan
2022-10-06 13:38:50 -04:00
committed by GitHub
parent 139046619b
commit eaa1de7ff7
6 changed files with 482 additions and 1 deletions

View File

@@ -7,6 +7,17 @@ import { installCustomPercyCommand } from './customPercyCommand'
import { addNetworkCommands } from './onlineNetwork'
import { GQLStubRegistry } from './mock-graphql/stubgql-Registry'
import { createPinia } from '../../src/store'
import { setActivePinia } from 'pinia'
import type { Pinia } from 'pinia'
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
declare global {
namespace Cypress {
interface Chainable {

View File

@@ -0,0 +1,9 @@
export * from './login-connect-store'
import { createPinia as _createPinia } from 'pinia'
// Reusable installation function, used as an entry point for tests that
// require an identical setup to main.ts
export const createPinia = () => {
return _createPinia()
}

View File

@@ -0,0 +1,141 @@
import type { BannersState } from '@packages/types'
import { defineStore } from 'pinia'
interface LoginUserData {
fullName: string | null
email: string | null
}
export interface LoginConnectState {
isLoginConnectOpen: boolean
utmMedium: string
cypressFirstOpened?: number
user: {
isLoggedIn: boolean
loginError: boolean
isOrganizationLoaded: boolean
isMemberOfOrganization: boolean
}
project: {
isProjectConnected: boolean
isConfigLoaded: boolean
hasNoRecordedRuns: boolean
hasNonExampleSpec: boolean
}
userData?: LoginUserData
promptsShown: {
ci1?: number
loginModalRecord?: number
}
bannersState: BannersState
_latestBannerShownTimeForTesting?: number
}
// The user can be in only one status at a time.
// These are specifically related to the dashboard
// and the progress from logging in to recording a run.
export const userStatuses = [
'isLoggedOut',
'needsOrgConnect',
'needsProjectConnect',
'needsRecordedRun',
'allTasksCompleted',
] as const
export type UserStatus = typeof userStatuses[number]
export const useLoginConnectStore = defineStore({
id: 'loginConnect',
state (): LoginConnectState {
return {
utmMedium: '',
isLoginConnectOpen: false,
cypressFirstOpened: undefined,
userData: undefined,
user: {
isLoggedIn: false,
loginError: false,
isOrganizationLoaded: false,
isMemberOfOrganization: false,
},
project: {
isProjectConnected: false,
isConfigLoaded: false,
hasNoRecordedRuns: false,
hasNonExampleSpec: true, // TODO: in #23762 initialize as false and set the real value
},
promptsShown: {},
bannersState: {},
_latestBannerShownTimeForTesting: undefined,
}
},
actions: {
openLoginConnectModal ({ utmMedium }: { utmMedium: string }) {
this.isLoginConnectOpen = true
this.utmMedium = utmMedium
},
closeLoginConnectModal () {
this.isLoginConnectOpen = false
this.utmMedium = ''
},
setUserFlag (name: keyof LoginConnectState['user'], newVal: boolean) {
this.user[name] = newVal
},
setProjectFlag (name: keyof LoginConnectState['project'], newVal: boolean) {
this.project[name] = newVal
},
setLoginError (error: boolean) {
this.user.loginError = error
},
setUserData (userData?: LoginUserData) {
this.userData = userData
},
setPromptShown (slug: string, timestamp: number) {
this.promptsShown[slug] = timestamp
},
setCypressFirstOpened (timestamp: number) {
this.cypressFirstOpened = timestamp
},
setBannersState (banners: BannersState) {
this.bannersState = banners
},
setLatestBannerShownTime (timestamp: number) {
this._latestBannerShownTimeForTesting = timestamp
},
},
getters: {
userStatus (state): UserStatus {
const { user, project } = state
switch (true) {
// the switch here ensures the uniqueness of states as we don't allow duplicate case labels
// https://eslint.org/docs/latest/rules/no-duplicate-case
case !user.isLoggedIn:
return 'isLoggedOut'
case user.isLoggedIn && user.isOrganizationLoaded && !user.isMemberOfOrganization:
return 'needsOrgConnect'
case user.isLoggedIn && user.isMemberOfOrganization && !project.isProjectConnected:
return 'needsProjectConnect'
case user.isLoggedIn && user.isMemberOfOrganization && project.isProjectConnected && project.hasNoRecordedRuns:
return 'needsRecordedRun'
default:
return 'allTasksCompleted'
}
},
userStatusMatches () {
// casting here sine ts seems to need a little extra help in this 'return a function from a getter' situation
return (status: UserStatus) => this.userStatus as unknown as UserStatus === status
},
projectStatus () {
// TODO: in #23762 look at projectConnectionStatus in SpecHeaderCloudDataTooltip
},
latestBannerShownTime (state) {
return state._latestBannerShownTimeForTesting
// TODO: in #23762 return based on bannersState
},
},
})
export type LoginConnectStore = ReturnType<typeof useLoginConnectStore>

View File

@@ -0,0 +1,216 @@
import { isAllowedFeature } from './isAllowedFeature'
import { LoginConnectStore, useLoginConnectStore, userStatuses } from '../store'
import type { UserStatus } from '../store'
import { BannerIds } from '@packages/types'
import interval from 'human-interval'
const bannerIds = {
isLoggedOut: BannerIds.ACI_082022_LOGIN,
needsOrgConnect: BannerIds.ACI_082022_CREATE_ORG,
needsProjectConnect: BannerIds.ACI_082022_CONNECT_PROJECT,
needsRecordedRun: BannerIds.ACI_082022_RECORD,
} as const
describe('isAllowedFeature', () => {
let store: LoginConnectStore
// this setup function acts as a test of the userStatus
// getter in loginConnectStore, since we set the individual flags here
// and assert on the expected user status derived from those flags
// and provided by loginConnectStore.userStatus
const setUpStatus = (status: UserStatus) => {
const { setCypressFirstOpened, setPromptShown, setUserFlag, setProjectFlag } = store
// set a default valid number of days since first open & nav prompt shown
// individual tests may override
setCypressFirstOpened(Date.now() - interval('5 days'))
setPromptShown('ci1', Date.now() - interval('5 days'))
switch (status) {
case 'isLoggedOut':
setUserFlag('isLoggedIn', false)
expect(store.userStatus).to.eq('isLoggedOut')
break
case 'needsOrgConnect':
setUserFlag('isLoggedIn', true)
setUserFlag('isOrganizationLoaded', true)
expect(store.userStatus).to.eq('needsOrgConnect')
break
case 'needsProjectConnect':
setUserFlag('isLoggedIn', true)
setUserFlag('isMemberOfOrganization', true)
expect(store.userStatus).to.eq('needsProjectConnect')
break
case 'needsRecordedRun':
setUserFlag('isLoggedIn', true)
setUserFlag('isMemberOfOrganization', true)
setProjectFlag('isProjectConnected', true)
setProjectFlag('hasNoRecordedRuns', true)
expect(store.userStatus).to.eq('needsRecordedRun')
break
case 'allTasksCompleted':
setUserFlag('isLoggedIn', true)
setUserFlag('isMemberOfOrganization', true)
setProjectFlag('isProjectConnected', true)
setProjectFlag('hasNoRecordedRuns', false)
expect(store.userStatus).to.eq('allTasksCompleted')
break
default:
return
}
}
beforeEach(() => {
store = useLoginConnectStore()
store.setProjectFlag('hasNonExampleSpec', true)
})
describe('specsListBanner', () => {
context('at least one non-example spec has been written', () => {
context('banners HAVE NOT been dismissed', () => {
userStatuses.forEach((status) => {
if (status === 'allTasksCompleted') {
it('returns false when user has no actions to take', () => {
setUpStatus('allTasksCompleted')
const result = isAllowedFeature('specsListBanner', store)
expect(result).to.be.false
})
} else {
it(`returns true for status ${status}`, () => {
setUpStatus(status)
const result = isAllowedFeature('specsListBanner', store)
expect(result).to.be.true
})
}
})
})
context('banners HAVE been dismissed', () => {
(Object.keys(bannerIds) as (keyof typeof bannerIds)[])
.forEach((status) => {
it(`returns false for banner ${ bannerIds[status] }`, () => {
setUpStatus(status)
// simulate banner for current status having been dismissed
store.setBannersState({
[bannerIds[status]]: {
'dismissed': Date.now() - interval('1 day'),
},
})
const result = isAllowedFeature('specsListBanner', store)
expect(result).to.be.false
})
})
})
context('banners have been disabled for testing', () => {
userStatuses.forEach((status) => {
it(`returns false for status ${ status }`, () => {
setUpStatus(status)
store.setBannersState({
_disabled: true,
})
const result = isAllowedFeature('specsListBanner', store)
expect(result).to.be.false
})
})
})
context('cypress was first opened less than 4 days ago', () => {
userStatuses.forEach((status) => {
it(`returns false for status ${status}`, () => {
setUpStatus(status)
store.setCypressFirstOpened(Date.now() - interval('3 days'))
const result = isAllowedFeature('specsListBanner', store)
expect(result).to.be.false
})
})
})
context('nav CI prompt was shown less than one day ago', () => {
userStatuses.forEach((status) => {
it(`returns false for status ${status}`, () => {
setUpStatus(status)
store.setPromptShown('ci1', Date.now() - interval('23 hours'))
const result = isAllowedFeature('specsListBanner', store)
expect(result).to.be.false
})
})
})
})
context('no non-example specs have been written', () => {
userStatuses.forEach((status) => {
if (status === 'allTasksCompleted' || status === 'needsRecordedRun') {
it(`returns false for status ${status}`, () => {
setUpStatus(status)
store.setProjectFlag('hasNonExampleSpec', false)
const result = isAllowedFeature('specsListBanner', store)
expect(result).to.be.false
})
} else {
it(`returns true for status ${status}`, () => {
setUpStatus(status)
store.setProjectFlag('hasNonExampleSpec', false)
const result = isAllowedFeature('specsListBanner', store)
expect(result).to.be.true
})
}
})
})
})
describe('docsCiPrompt', () => {
context('a banner WAS NOT shown in the last day', () => {
userStatuses.forEach((status) => {
it(`returns true with status ${ status } `, () => {
setUpStatus(status)
const result = isAllowedFeature('docsCiPrompt', store)
expect(result).to.be.true
})
})
})
context('a banner WAS shown in the last day', () => {
userStatuses.forEach((status) => {
it(`returns false with status ${ status } `, () => {
setUpStatus(status)
store.setLatestBannerShownTime(Date.now() - interval('23 hours'))
const result = isAllowedFeature('docsCiPrompt', store)
expect(result).to.be.false
})
})
})
context('cypress was first opened less than 4 days ago', () => {
userStatuses.forEach((status) => {
it(`returns false for status ${ status } `, () => {
setUpStatus(status)
store.setCypressFirstOpened(Date.now() - interval('3 days'))
const result = isAllowedFeature('docsCiPrompt', store)
expect(result).to.be.false
})
})
})
})
})

View File

@@ -0,0 +1,104 @@
import interval from 'human-interval'
import { BannerIds } from '@packages/types'
import type { LoginConnectStore } from '../store'
const bannerIds = {
isLoggedOut: BannerIds.ACI_082022_LOGIN,
needsOrgConnect: BannerIds.ACI_082022_CREATE_ORG,
needsProjectConnect: BannerIds.ACI_082022_CONNECT_PROJECT,
needsRecordedRun: BannerIds.ACI_082022_RECORD,
}
/**
* Enures a cooldown period between two cypress-triggered events, if one has already happened
* @param eventTime - timestamp of an event - if undefined, this function will always return true, no cooldown is needed
* @param waitTime - time to compare with, such as `1 day`, `20 minutes`, etc, to be parsed by `human-interval` package
*/
const minTimeSinceEvent = (eventTime: number | undefined, waitTime: string) => {
if (!eventTime) {
return true
}
// converting to lowercase since `interval` will error on "Day" vs "day"
const waitTimestamp = interval(waitTime.toLocaleLowerCase())
if (isNaN(waitTimestamp)) {
throw new Error(`incorrect format for waitTime provided, value must be \`n days\`, \`n minutes\` etc. Value received was ${waitTime}`)
}
return (Date.now() - eventTime) > waitTimestamp
}
export const isAllowedFeature = (
featureName: 'specsListBanner' | 'docsCiPrompt',
loginConnectStore: LoginConnectStore,
) => {
const {
cypressFirstOpened,
promptsShown,
latestBannerShownTime,
bannersState,
userStatus,
project,
} = loginConnectStore
const events = {
cypressFirstOpened,
navCiPromptAutoOpened: promptsShown.ci1,
loginModalRecordPromptShown: promptsShown.loginModalRecord,
latestSmartBannerShown: latestBannerShownTime,
}
function bannerForCurrentStatusWasNotDismissed () {
const bannerId = bannerIds[userStatus]
return !bannersState?.[bannerId]?.dismissed
}
function bannersAreNotDisabledForTesting () {
return !bannersState?._disabled
}
// For each feature, we define an array of rules for every `UserStatus`.
// The `base` rule is applied to all statuses, additional rules are
// nested in their respective statuses.
const rules = {
specsListBanner: {
base: [
minTimeSinceEvent(events.cypressFirstOpened, '4 days'),
minTimeSinceEvent(events.navCiPromptAutoOpened, '1 day'),
bannerForCurrentStatusWasNotDismissed(),
bannersAreNotDisabledForTesting(),
],
needsRecordedRun: [
minTimeSinceEvent(events.loginModalRecordPromptShown, '1 day'),
project.hasNonExampleSpec,
],
needsOrgConnect: [],
needsProjectConnect: [],
isLoggedOut: [],
},
docsCiPrompt: {
base: [
minTimeSinceEvent(events.cypressFirstOpened, '4 days'),
minTimeSinceEvent(events.latestSmartBannerShown, '1 day'),
],
needsRecordedRun: [],
needsOrgConnect: [],
needsProjectConnect: [],
isLoggedOut: [],
allTasksCompleted: [],
},
}
const baseRules = [...rules[featureName].base]
// if the `userStatus` is not explicitly listed for a feature, then
// we don't have anything that we are allowed to show for that status
// so the fallback rules array of [false] is used
const statusSpecificRules = rules[featureName][userStatus] ?? [false]
const rulesToCheck = baseRules.concat(statusSpecificRules)
return rulesToCheck.every((rule: boolean) => rule === true)
}

View File

@@ -53,4 +53,4 @@ type BannerKeys = keyof typeof BannerIds
type BannerId = typeof BannerIds[BannerKeys]
export type BannersState = {
[bannerId in BannerId]?: BannerState
} & { _disabled?: boolean }
} & { _disabled?: boolean } // used for testing