From eaa1de7ff76cf0ae6730e8df19594b60b762be01 Mon Sep 17 00:00:00 2001 From: Mark Noonan Date: Thu, 6 Oct 2022 13:38:50 -0400 Subject: [PATCH] feat: add logic for "cypress-triggered events" (#24101) Co-authored-by: Zachary Williams --- .../cypress/support/component.ts | 11 + packages/frontend-shared/src/store/index.ts | 9 + .../src/store/login-connect-store.ts | 141 ++++++++++++ .../src/utils/isAllowedFeature.cy.ts | 216 ++++++++++++++++++ .../src/utils/isAllowedFeature.ts | 104 +++++++++ packages/types/src/config.ts | 2 +- 6 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 packages/frontend-shared/src/store/index.ts create mode 100644 packages/frontend-shared/src/store/login-connect-store.ts create mode 100644 packages/frontend-shared/src/utils/isAllowedFeature.cy.ts create mode 100644 packages/frontend-shared/src/utils/isAllowedFeature.ts diff --git a/packages/frontend-shared/cypress/support/component.ts b/packages/frontend-shared/cypress/support/component.ts index 3d6657ee85..15283aa49f 100644 --- a/packages/frontend-shared/cypress/support/component.ts +++ b/packages/frontend-shared/cypress/support/component.ts @@ -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 { diff --git a/packages/frontend-shared/src/store/index.ts b/packages/frontend-shared/src/store/index.ts new file mode 100644 index 0000000000..aafb566e1c --- /dev/null +++ b/packages/frontend-shared/src/store/index.ts @@ -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() +} diff --git a/packages/frontend-shared/src/store/login-connect-store.ts b/packages/frontend-shared/src/store/login-connect-store.ts new file mode 100644 index 0000000000..6c3c14ca22 --- /dev/null +++ b/packages/frontend-shared/src/store/login-connect-store.ts @@ -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 diff --git a/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts b/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts new file mode 100644 index 0000000000..95902cd377 --- /dev/null +++ b/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts @@ -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 + }) + }) + }) + }) +}) diff --git a/packages/frontend-shared/src/utils/isAllowedFeature.ts b/packages/frontend-shared/src/utils/isAllowedFeature.ts new file mode 100644 index 0000000000..2b5f290bf0 --- /dev/null +++ b/packages/frontend-shared/src/utils/isAllowedFeature.ts @@ -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) +} diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 0a0a62e2ad..3c769fe360 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -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