mirror of
https://github.com/cypress-io/cypress.git
synced 2026-01-31 03:29:43 -06:00
feat: add logic for "cypress-triggered events" (#24101)
Co-authored-by: Zachary Williams <ZachJW34@gmail.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
9
packages/frontend-shared/src/store/index.ts
Normal file
9
packages/frontend-shared/src/store/index.ts
Normal 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()
|
||||
}
|
||||
141
packages/frontend-shared/src/store/login-connect-store.ts
Normal file
141
packages/frontend-shared/src/store/login-connect-store.ts
Normal 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>
|
||||
216
packages/frontend-shared/src/utils/isAllowedFeature.cy.ts
Normal file
216
packages/frontend-shared/src/utils/isAllowedFeature.cy.ts
Normal 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
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
104
packages/frontend-shared/src/utils/isAllowedFeature.ts
Normal file
104
packages/frontend-shared/src/utils/isAllowedFeature.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user