feat: Component Testing banner (#26625)

Co-authored-by: elevatebart <bart@cypress.io>
This commit is contained in:
Mike Plummer
2023-05-05 16:07:41 -05:00
committed by GitHub
parent 63be85f465
commit adc4c5e32c
66 changed files with 965 additions and 386 deletions

View File

@@ -3,6 +3,10 @@
_Released 05/09/2023 (PENDING)_
**Features:**
- Added a new informational banner to help get started with component testing from an existing end-to-end test suite. Addresses [#26511](https://github.com/cypress-io/cypress/issues/26511).
**Bugfixes:**
- Fixed an issue in Electron where devtools gets out of sync with the DOM occasionally. Addresses [#15932](https://github.com/cypress-io/cypress/issues/15932).

View File

@@ -20,8 +20,9 @@
},
"dependencies": {},
"devDependencies": {
"@cypress-design/vue-icon": "0.20.0",
"@cypress-design/vue-statusicon": "0.3.0",
"@cypress-design/vue-button": "0.9.2",
"@cypress-design/vue-icon": "0.22.1",
"@cypress-design/vue-statusicon": "0.4.3",
"@graphql-typed-document-node/core": "^3.1.0",
"@headlessui/vue": "1.4.0",
"@iconify/iconify": "2.1.2",

View File

@@ -1,6 +1,6 @@
import { gql, useSubscription } from '@urql/vue'
import { Debug_RelevantRuns_SubscriptionDocument, Sidebar_RelevantRuns_SubscriptionDocument } from '@packages/app/src/generated/graphql'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
import { computed } from 'vue'
import { uniq } from 'lodash'
@@ -43,10 +43,10 @@ gql`
`
export function useRelevantRun (location: 'SIDEBAR' | 'DEBUG') {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
const shouldPause = computed(() => {
return !loginConnectStore.project.isProjectConnected
return !userProjectStatusStore.project.isProjectConnected
})
//Switch the subscription query depending on where it was registered from

View File

@@ -1,7 +1,7 @@
import { DebugSpecListGroupsFragment, DebugSpecListSpecFragment, DebugSpecListTestsFragment, DebugSpecsFragment, DebugSpecsFragmentDoc, UseCohorts_DetermineCohortDocument } from '../generated/graphql-test'
import DebugContainer from './DebugContainer.vue'
import { defaultMessages } from '@cy/i18n'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
import { specsList } from './utils/DebugMapping'
import { CloudRunStubs, createCloudRun } from '@packages/graphql/test/stubCloudTypes'
import { DEBUG_SLIDESHOW } from './utils/constants'
@@ -51,31 +51,31 @@ describe('<DebugContainer />', () => {
}
it('shows not logged in', () => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setHasInitiallyLoaded()
userProjectStatusStore.setHasInitiallyLoaded()
validateEmptyState([defaultMessages.debugPage.emptyStates.connectToCypressCloud, defaultMessages.debugPage.emptyStates.debugDirectlyInCypress, defaultMessages.debugPage.emptyStates.notLoggedInTestMessage])
cy.findByRole('button', { name: 'Connect to Cypress Cloud' }).should('be.visible')
})
it('is logged in with no project', () => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setProjectFlag('isProjectConnected', false)
loginConnectStore.setHasInitiallyLoaded()
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setProjectFlag('isProjectConnected', false)
userProjectStatusStore.setHasInitiallyLoaded()
validateEmptyState([defaultMessages.debugPage.emptyStates.debugDirectlyInCypress, defaultMessages.debugPage.emptyStates.reviewRerunAndDebug, defaultMessages.debugPage.emptyStates.noProjectTestMessage])
cy.findByRole('button', { name: 'Connect a Cypress Cloud project' }).should('be.visible')
})
it('has no runs', () => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setProjectFlag('isProjectConnected', true)
loginConnectStore.setHasInitiallyLoaded()
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setProjectFlag('isProjectConnected', true)
userProjectStatusStore.setHasInitiallyLoaded()
cy.mountFragment(DebugSpecsFragmentDoc, {
variableTypes: DebugSpecVariableTypes,
variables: defaultVariables,
@@ -87,11 +87,11 @@ describe('<DebugContainer />', () => {
})
it('errors', () => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setProjectFlag('isProjectConnected', true)
loginConnectStore.setHasInitiallyLoaded()
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setProjectFlag('isProjectConnected', true)
userProjectStatusStore.setHasInitiallyLoaded()
cy.mountFragment(DebugSpecsFragmentDoc, {
variableTypes: DebugSpecVariableTypes,
variables: defaultVariables,
@@ -105,11 +105,11 @@ describe('<DebugContainer />', () => {
describe('run states', { viewportWidth: 900 }, () => {
beforeEach(() => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setProjectFlag('isProjectConnected', true)
loginConnectStore.setHasInitiallyLoaded()
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setProjectFlag('isProjectConnected', true)
userProjectStatusStore.setHasInitiallyLoaded()
})
function mountTestRun (runName: string) {
@@ -229,14 +229,14 @@ describe('<DebugContainer />', () => {
})
describe('when logged in and connected', () => {
let loginConnectStore
let userProjectStatusStore
beforeEach(() => {
loginConnectStore = useLoginConnectStore()
userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setProjectFlag('isProjectConnected', true)
loginConnectStore.setHasInitiallyLoaded()
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setProjectFlag('isProjectConnected', true)
userProjectStatusStore.setHasInitiallyLoaded()
})
it('renders running run', () => {

View File

@@ -4,16 +4,16 @@
<NoInternetConnection v-if="!online">
{{ t('launchpadErrors.noInternet.connectProject') }}
</NoInternetConnection>
<DebugLoading v-else-if="!loginConnectStore.hasInitiallyLoaded || loginConnectStore.project.isProjectConnected && isLoading" />
<DebugLoading v-else-if="!userProjectStatusStore.hasInitiallyLoaded || userProjectStatusStore.project.isProjectConnected && isLoading" />
<DebugError
v-else-if="showError"
/>
<DebugNotLoggedIn
v-else-if="!loginConnectStore.user.isLoggedIn"
v-else-if="!userProjectStatusStore.user.isLoggedIn"
data-cy="debug-empty"
/>
<DebugNoProject
v-else-if="!loginConnectStore.project.isProjectConnected"
v-else-if="!userProjectStatusStore.project.isProjectConnected"
data-cy="debug-empty"
/>
<DebugNoRuns
@@ -85,7 +85,7 @@
import { gql } from '@urql/vue'
import { computed } from 'vue'
import type { CloudRunStatus, DebugSpecsFragment, TestingTypeEnum } from '../generated/graphql'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
import NoInternetConnection from '@packages/frontend-shared/src/components/NoInternetConnection.vue'
import DebugLoading from '../debug/empty/DebugLoading.vue'
import DebugPageHeader from './DebugPageHeader.vue'
@@ -205,7 +205,7 @@ const props = withDefaults(defineProps<{
currentCommitInfo: undefined,
})
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
const cloudProject = computed(() => {
return props.gql?.currentProject?.cloudProject?.__typename === 'CloudProject'

View File

@@ -4,7 +4,7 @@ import DebugNoRuns from './DebugNoRuns.vue'
import DebugLoading from './DebugLoading.vue'
import DebugError from './DebugError.vue'
import DebugEmptyView from './DebugEmptyView.vue'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
import { DebugEmptyView_RecordEventDocument, DebugEmptyView_SetPreferencesDocument, UseCohorts_DetermineCohortDocument, _DebugEmptyViewFragment, _DebugEmptyViewFragmentDoc } from '../../generated/graphql-test'
import { DEBUG_SLIDESHOW } from '../utils/constants'
@@ -77,10 +77,10 @@ describe('Debug page empty states', () => {
context('not logged in', () => {
it('renders', () => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
// We need to set isLoggedIn so that CloudConnectButton shows the correct state
loginConnectStore.setUserFlag('isLoggedIn', false)
userProjectStatusStore.setUserFlag('isLoggedIn', false)
mountWithGql(<DebugNotLoggedIn />)
@@ -90,7 +90,7 @@ describe('Debug page empty states', () => {
})
it('sends record event upon seeing slideshow', () => {
useLoginConnectStore().setUserFlag('isLoggedIn', false)
useUserProjectStatusStore().setUserFlag('isLoggedIn', false)
mountWithGql(<DebugNotLoggedIn />, { debugSlideshowComplete: false })
cy.get('@recordEvent').should('have.been.calledWithMatch', { campaign: DEBUG_SLIDESHOW.campaigns.login, messageId: Cypress.sinon.match.string, medium: DEBUG_SLIDESHOW.medium, cohort: Cypress.sinon.match(/A|B/) })
})
@@ -98,10 +98,10 @@ describe('Debug page empty states', () => {
context('no project', () => {
it('renders', () => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
// We need to set isLoggedIn so that CloudConnectButton shows the correct state
loginConnectStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setUserFlag('isLoggedIn', true)
mountWithGql(<DebugNoProject />)
@@ -115,7 +115,7 @@ describe('Debug page empty states', () => {
})
it('sends record event upon seeing slideshow', () => {
useLoginConnectStore().setUserFlag('isLoggedIn', false)
useUserProjectStatusStore().setUserFlag('isLoggedIn', false)
mountWithGql(<DebugNoProject />, { debugSlideshowComplete: false })
cy.get('@recordEvent').should('have.been.calledWithMatch', { campaign: DEBUG_SLIDESHOW.campaigns.connectProject, messageId: Cypress.sinon.match.string, medium: DEBUG_SLIDESHOW.medium, cohort: Cypress.sinon.match(/A|B/) })
})
@@ -131,7 +131,7 @@ describe('Debug page empty states', () => {
})
it('sends record event upon seeing slideshow', () => {
useLoginConnectStore().setUserFlag('isLoggedIn', false)
useUserProjectStatusStore().setUserFlag('isLoggedIn', false)
mountWithGql(<DebugNoRuns />, { debugSlideshowComplete: false })
cy.get('@recordEvent').should('have.been.calledWithMatch', { campaign: DEBUG_SLIDESHOW.campaigns.recordRun, messageId: Cypress.sinon.match.string, medium: DEBUG_SLIDESHOW.medium, cohort: Cypress.sinon.match(/A|B/) })
})
@@ -184,7 +184,7 @@ describe('Debug page empty states', () => {
}
it('renders slideshow if debugSlideshowComplete = false', () => {
useLoginConnectStore().setUserFlag('isLoggedIn', false)
useUserProjectStatusStore().setUserFlag('isLoggedIn', false)
mountWithGql(<DebugNoRuns />, { cohort: 'B', debugSlideshowComplete: false })
cy.get('@recordEvent').should('have.been.calledWithMatch', { campaign: DEBUG_SLIDESHOW.campaigns.recordRun, messageId: Cypress.sinon.match.string, medium: DEBUG_SLIDESHOW.medium, cohort: Cypress.sinon.match(/A|B/) })
moveThroughSlideshow({ cohort: 'B', percy: true })
@@ -198,7 +198,7 @@ describe('Debug page empty states', () => {
})
it('renders default empty state if debugSlideshowComplete = true', () => {
useLoginConnectStore().setUserFlag('isLoggedIn', false)
useUserProjectStatusStore().setUserFlag('isLoggedIn', false)
mountWithGql(<DebugNoRuns />, { cohort: 'A', debugSlideshowComplete: true })
cy.findByTestId('debug-default-empty-state')

View File

@@ -3,7 +3,7 @@ import { defaultMessages } from '@cy/i18n'
import { CloudRunStatus, SidebarNavigationFragment, SidebarNavigationFragmentDoc, SideBarNavigation_SetPreferencesDocument } from '../generated/graphql-test'
import { CloudRunStubs } from '@packages/graphql/test/stubCloudTypes'
import { cloneDeep } from 'lodash'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
function mountComponent (props: { initialNavExpandedVal?: boolean, cloudProject?: { status: CloudRunStatus, numFailedTests: number }, isLoading?: boolean, online?: boolean} = {}) {
const withDefaults = { initialNavExpandedVal: false, isLoading: false, online: true, ...props }
@@ -186,9 +186,9 @@ describe('SidebarNavigation', () => {
})
it('renders no badge when query is loading', () => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setProjectFlag('isProjectConnected', true)
userProjectStatusStore.setProjectFlag('isProjectConnected', true)
mountComponent({ isLoading: true })

View File

@@ -108,7 +108,7 @@ import { useI18n } from '@cy/i18n'
import { useRoute } from 'vue-router'
import SidebarNavigationHeader from './SidebarNavigationHeader.vue'
import { useDebounceFn, useWindowSize } from '@vueuse/core'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
const { t } = useI18n()
@@ -162,7 +162,7 @@ const props = defineProps<{
const NAV_EXPAND_MIN_SCREEN_WIDTH = 1024
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
const debugBadge = ref<Badge | undefined>()
@@ -171,7 +171,7 @@ const setDebugBadge = useDebounceFn((badge) => {
}, 500)
watchEffect(() => {
if (props.isLoading && loginConnectStore.project.isProjectConnected) {
if (props.isLoading && userProjectStatusStore.project.isProjectConnected) {
setDebugBadge(undefined)
return

View File

@@ -1,5 +1,5 @@
import CloudConnectButton from './CloudConnectButton.vue'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
describe('<CloudConnectButton />', { viewportHeight: 60, viewportWidth: 400 }, () => {
context('not logged in ', () => {
@@ -11,12 +11,12 @@ describe('<CloudConnectButton />', { viewportHeight: 60, viewportWidth: 400 }, (
})
context('logged in', () => {
let loginConnectStore
let userProjectStatusStore
beforeEach(() => {
loginConnectStore = useLoginConnectStore()
userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setUserFlag('isLoggedIn', true)
})
it('show project connect if not connected', () => {
@@ -26,7 +26,7 @@ describe('<CloudConnectButton />', { viewportHeight: 60, viewportWidth: 400 }, (
})
it('uses the store to open the Login Connect modal', () => {
loginConnectStore.openLoginConnectModal = cy.spy().as('openLoginConnectModal')
userProjectStatusStore.openLoginConnectModal = cy.spy().as('openLoginConnectModal')
cy.mount(() => <div class="h-screen"><CloudConnectButton utmMedium="testing" /></div>)
cy.contains('button', 'Connect a Cypress Cloud project').click()
@@ -35,7 +35,7 @@ describe('<CloudConnectButton />', { viewportHeight: 60, viewportWidth: 400 }, (
})
it('uses the store to open the Login Connect modal with utmContent', () => {
loginConnectStore.openLoginConnectModal = cy.spy().as('openLoginConnectModal')
userProjectStatusStore.openLoginConnectModal = cy.spy().as('openLoginConnectModal')
cy.mount(() => <div class="h-screen"><CloudConnectButton utmMedium="testing" utmContent="content"/></div>)
cy.contains('button', 'Connect a Cypress Cloud project').click()

View File

@@ -15,9 +15,9 @@ import ChainIcon from '~icons/cy/chain-link_x16.svg'
import CypressIcon from '~icons/cy/cypress-logo_x16.svg'
import Button from '@cy/components/Button.vue'
import { useI18n } from '@cy/i18n'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
const { openLoginConnectModal, user } = useLoginConnectStore()
const { openLoginConnectModal, user } = useUserProjectStatusStore()
const { t } = useI18n()

View File

@@ -1,7 +1,7 @@
import RunsContainer from './RunsContainer.vue'
import { RunsContainerFragmentDoc } from '../generated/graphql-test'
import { CloudUserStubs } from '@packages/graphql/test/stubCloudTypes'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
import { defaultMessages } from '@cy/i18n'
@@ -16,9 +16,9 @@ describe('<RunsContainer />', { keystrokeDelay: 0 }, () => {
context('when the user is logged in', () => {
beforeEach(() => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setUserFlag('isLoggedIn', true)
})
it('renders with expected runs if there is a cloud project id', () => {

View File

@@ -52,7 +52,7 @@ import RunsEmpty from './RunsEmpty.vue'
import { RunsContainerFragment, RunsContainer_FetchNewerRunsDocument } from '../generated/graphql'
import Warning from '@packages/frontend-shared/src/warning/Warning.vue'
import RunsErrorRenderer from './RunsErrorRenderer.vue'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
const { t } = useI18n()
@@ -188,9 +188,9 @@ const props = defineProps<{
const showConnectSuccessAlert = ref(false)
const connectionFailed = computed(() => !props.gql.currentProject?.cloudProject && props.online)
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
watch(() => loginConnectStore.project.isProjectConnected, (newVal, oldVal) => {
watch(() => userProjectStatusStore.project.isProjectConnected, (newVal, oldVal) => {
if (newVal && oldVal === false) {
// only show this alert if we have just connected
showConnectSuccessAlert.value = true

View File

@@ -1,7 +1,7 @@
import { RunsErrorRendererFragmentDoc } from '../generated/graphql-test'
import RunsErrorRenderer from './RunsErrorRenderer.vue'
import { defaultMessages } from '@cy/i18n'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
const text = defaultMessages.runs.errors
@@ -34,9 +34,9 @@ describe('<RunsErrorRenderer />', () => {
},
})
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
cy.spy(loginConnectStore, 'openLoginConnectModal').as('loginConnectSpy')
cy.spy(userProjectStatusStore, 'openLoginConnectModal').as('loginConnectSpy')
cy.contains(text.notFound.title).should('be.visible')
cy.contains(text.notFound.description.replace('{0}', 'projectId: "test-project-id"')).should('be.visible')

View File

@@ -71,9 +71,9 @@ import SendIcon from '~icons/cy/paper-airplane_x16.svg'
import { useI18n } from '@cy/i18n'
import CodeTag from '../../../frontend-shared/src/components/CodeTag.vue'
import ExternalLink from '@cy/gql-components/ExternalLink.vue'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
const { openLoginConnectModal } = useLoginConnectStore()
const { openLoginConnectModal } = useUserProjectStatusStore()
const { t } = useI18n()

View File

@@ -2,7 +2,7 @@ import { SpecHeaderCloudDataTooltipFragmentDoc } from '../generated/graphql-test
import SpecHeaderCloudDataTooltip from './SpecHeaderCloudDataTooltip.vue'
import { get, set } from 'lodash'
import { defaultMessages } from '@cy/i18n'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
const tooltipContentSelector = '.v-popper'
@@ -18,7 +18,7 @@ describe('<SpecHeaderCloudDataTooltip />', () => {
docs: string
},
) {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
cy.mountFragment(SpecHeaderCloudDataTooltipFragmentDoc, {
onResult: (result) => {
@@ -26,22 +26,22 @@ describe('<SpecHeaderCloudDataTooltip />', () => {
switch (status) {
case 'LOGGED_OUT':
loginConnectStore.setUserFlag('isLoggedIn', false)
userProjectStatusStore.setUserFlag('isLoggedIn', false)
break
case 'NOT_CONNECTED':
loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setUserFlag('isOrganizationLoaded', true)
loginConnectStore.setUserFlag('isMemberOfOrganization', true)
loginConnectStore.setProjectFlag('isProjectConnected', false)
loginConnectStore.setProjectFlag('isConfigLoaded', true)
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setUserFlag('isOrganizationLoaded', true)
userProjectStatusStore.setUserFlag('isMemberOfOrganization', true)
userProjectStatusStore.setProjectFlag('isProjectConnected', false)
userProjectStatusStore.setProjectFlag('isConfigLoaded', true)
break
case 'NOT_FOUND':
loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setProjectFlag('isNotFound', true)
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setProjectFlag('isNotFound', true)
break
case 'ACCESS_REQUESTED':
loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setProjectFlag('isNotAuthorized', true)
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setProjectFlag('isNotAuthorized', true)
set(result, 'currentProject.cloudProject', {
__typename: 'CloudProjectUnauthorized',
@@ -51,16 +51,16 @@ describe('<SpecHeaderCloudDataTooltip />', () => {
break
case 'UNAUTHORIZED':
loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setProjectFlag('isNotAuthorized', true)
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setProjectFlag('isNotAuthorized', true)
break
case 'CONNECTED':
default:
loginConnectStore.setUserFlag('isLoggedIn', true)
loginConnectStore.setUserFlag('isOrganizationLoaded', true)
loginConnectStore.setUserFlag('isMemberOfOrganization', true)
loginConnectStore.setProjectFlag('isProjectConnected', true)
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setUserFlag('isOrganizationLoaded', true)
userProjectStatusStore.setUserFlag('isMemberOfOrganization', true)
userProjectStatusStore.setProjectFlag('isProjectConnected', true)
break
}
},

View File

@@ -44,7 +44,7 @@
</div>
<div>
<Button
v-if="userStatusMatches('isLoggedOut')"
v-if="cloudStatusMatches('isLoggedOut')"
:prefix-icon="UserOutlineIcon"
prefix-icon-class="icon-dark-white icon-light-transparent"
data-cy="login-button"
@@ -53,7 +53,7 @@
{{ t('specPage.cloudLoginButton') }}
</Button>
<Button
v-else-if="userStatusMatches('needsProjectConnect')"
v-else-if="cloudStatusMatches('needsProjectConnect')"
:prefix-icon="ConnectIcon"
prefix-icon-class="icon-dark-white icon-light-transparent"
data-cy="connect-button"
@@ -92,9 +92,9 @@ import type { SpecHeaderCloudDataTooltipFragment } from '../generated/graphql'
import { useI18n } from '@cy/i18n'
import { computed } from 'vue'
import { gql } from '@urql/vue'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
const { userStatusMatches, project } = useLoginConnectStore()
const { cloudStatusMatches, project } = useUserProjectStatusStore()
const { t } = useI18n()

View File

@@ -201,13 +201,13 @@ import { useRoute } from 'vue-router'
import FlakyInformation from './flaky-badge/FlakyInformation.vue'
import { useCloudSpecData } from '../composables/useCloudSpecData'
import { useSpecFilter } from '../composables/useSpecFilter'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
import SpecsRunAllSpecs from './SpecsRunAllSpecs.vue'
import { useRunAllSpecsStore } from '../store/run-all-specs-store'
import { posixify } from '../paths'
import { useSubscription } from '../graphql'
const { openLoginConnectModal } = useLoginConnectStore()
const { openLoginConnectModal } = useUserProjectStatusStore()
const route = useRoute()
const { t } = useI18n()

View File

@@ -6,15 +6,16 @@ import interval from 'human-interval'
import { CloudUserStubs, CloudProjectStubs } from '@packages/graphql/test/stubCloudTypes'
import { AllowedState, BannerIds } from '@packages/types'
import { assignIn, set } from 'lodash'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import type { LoginConnectState } from '@packages/frontend-shared/src/store/login-connect-store'
import { UserProjectStatusStore, useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
import type { UserProjectStatusState } from '@packages/frontend-shared/src/store/user-project-status-store'
const AlertSelector = 'alert-header'
const AlertCloseBtnSelector = 'alert-suffix-icon'
type BannerKey = keyof typeof BannerIds
type BannerId = typeof BannerIds[BannerKey]
describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
describe('<SpecsListBanners />', { viewportHeight: 260, defaultCommandTimeout: 1000 }, () => {
const validateBaseRender = () => {
it('should render as expected', () => {
cy.findByTestId(AlertSelector).should('be.visible')
@@ -68,9 +69,9 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
const validateSmartNotificationBehaviors = (bannerId: BannerId, bannerTestId: string, gql: Partial<SpecsListBannersFragment>) => {
it('should not render when using cypress < 4 days', () => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setCypressFirstOpened(Date.now() - interval('3 days'))
userProjectStatusStore.setCypressFirstOpened(Date.now() - interval('3 days'))
cy.mountFragment(SpecsListBannersFragmentDoc, {
render: (gqlVal) => <SpecsListBanners gql={gqlVal} />,
@@ -80,9 +81,9 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
})
it('should not render when previously-dismissed', () => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setBannersState({
userProjectStatusStore.setBannersState({
[bannerId]: {
dismissed: Date.now(),
},
@@ -97,9 +98,9 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
context('banner conditions are met and when cypress use >= 4 days', () => {
beforeEach(() => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setCypressFirstOpened(Date.now() - interval('4 days'))
userProjectStatusStore.setCypressFirstOpened(Date.now() - interval('4 days'))
cy.stubMutationResolver(UseCohorts_DetermineCohortDocument, (defineResult) => {
return defineResult({ determineCohort: { __typename: 'Cohort', name: 'foo', cohort: 'A' } })
@@ -113,27 +114,42 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
/>),
})
const bannerTrueUserConditions = {
'login-banner': [],
'create-organization-banner': ['isLoggedIn', 'isOrganizationLoaded'],
'connect-project-banner': ['isLoggedIn', 'isMemberOfOrganization'],
'record-banner': ['isLoggedIn', 'isMemberOfOrganization'],
} as const
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}
const bannerTrueProjectConditions = {
'login-banner': [],
'create-organization-banner': [],
'connect-project-banner': ['isConfigLoaded'],
'record-banner': ['isProjectConnected', 'hasNoRecordedRuns', 'hasNonExampleSpec', 'isConfigLoaded'],
} as const
const loginConnectStore = useLoginConnectStore()
const bannerTrueConditions: Record<string, DeepPartial<UserProjectStatusState>> = {
'login-banner': {},
'create-organization-banner': {
user: { isLoggedIn: true, isOrganizationLoaded: true },
},
'connect-project-banner': {
user: { isLoggedIn: true, isMemberOfOrganization: true },
project: { isConfigLoaded: true },
},
'record-banner': {
user: { isLoggedIn: true, isMemberOfOrganization: true },
project: { isProjectConnected: true, hasNoRecordedRuns: true, hasNonExampleSpec: true, isConfigLoaded: true },
},
'component-testing-banner': {
testingType: 'e2e',
user: { isLoggedIn: true, isMemberOfOrganization: true },
project: { isProjectConnected: true, hasNonExampleSpec: true, isConfigLoaded: true, hasDetectedCtFramework: true },
},
}
bannerTrueUserConditions[bannerTestId].forEach((status: keyof LoginConnectState['user']) => {
loginConnectStore.setUserFlag(status, true)
})
const userProjectStatusStore = useUserProjectStatusStore()
bannerTrueProjectConditions[bannerTestId].forEach((status: keyof LoginConnectState['project']) => {
loginConnectStore.setProjectFlag(status, true)
const stateToSet = bannerTrueConditions[bannerTestId]
Object.entries(stateToSet).forEach(([key, value]) => {
if (key === 'user') {
Object.entries(value).forEach(([key, value]) => userProjectStatusStore.setUserFlag(key as any, value))
} else if (key === 'project') {
Object.entries(value).forEach(([key, value]) => userProjectStatusStore.setProjectFlag(key as any, value))
} else if (key === 'testingType') {
userProjectStatusStore.setTestingType(value as any)
}
})
cy.get(`[data-cy="${bannerTestId}"]`).should('be.visible')
@@ -142,26 +158,31 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
it('should be preempted by spec not found banner', () => {
mountWithState(gql, {}, { isSpecNotFound: true })
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
cy.contains('Spec not found').should('exist')
})
it('should be preempted by offline warning banner', () => {
mountWithState(gql, {}, { isOffline: true })
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
cy.contains('No internet connection').should('exist')
})
it('should be preempted by fetch error banner', () => {
mountWithState(gql, {}, { isFetchError: true })
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
cy.contains('Lost connection').should('exist')
})
it('should be preempted by project not found banner', () => {
mountWithState(gql, {}, { isProjectNotFound: true })
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
cy.contains('Couldn\'t find your project').should('exist')
})
it('should be preempted by request access banner', () => {
mountWithState(gql, {}, { isProjectUnauthorized: true })
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
cy.contains('Request access').should('exist')
})
})
}
@@ -171,9 +192,9 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
beforeEach(() => {
visible.value = true
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setCypressFirstOpened(Date.now() - interval('3 days'))
userProjectStatusStore.setCypressFirstOpened(Date.now() - interval('3 days'))
cy.mountFragment(SpecsListBannersFragmentDoc, { render: (gql) => <SpecsListBanners gql={gql} isSpecNotFound={visible} /> })
})
@@ -188,9 +209,9 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
beforeEach(() => {
visible.value = true
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setCypressFirstOpened(Date.now() - interval('3 days'))
userProjectStatusStore.setCypressFirstOpened(Date.now() - interval('3 days'))
cy.mountFragment(SpecsListBannersFragmentDoc, { render: (gql) => <SpecsListBanners gql={gql} isOffline={visible} /> })
})
@@ -208,9 +229,9 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
beforeEach(() => {
visible.value = true
refetchCallback = cy.stub()
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setCypressFirstOpened(Date.now() - interval('3 days'))
userProjectStatusStore.setCypressFirstOpened(Date.now() - interval('3 days'))
cy.mountFragment(SpecsListBannersFragmentDoc, { render: (gql) => <SpecsListBanners gql={gql} onRefetchFailedCloudData={refetchCallback} isFetchError={visible} /> })
})
@@ -234,11 +255,11 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
beforeEach(() => {
visible.value = true
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
reconnectCallback = cy.stub(loginConnectStore, 'openLoginConnectModal')
reconnectCallback = cy.stub(userProjectStatusStore, 'openLoginConnectModal')
loginConnectStore.setCypressFirstOpened(Date.now() - interval('3 days'))
userProjectStatusStore.setCypressFirstOpened(Date.now() - interval('3 days'))
cy.mountFragment(SpecsListBannersFragmentDoc, { render: (gql) => <SpecsListBanners gql={gql} isProjectNotFound={visible} /> })
})
@@ -261,9 +282,9 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
beforeEach(() => {
visible.value = true
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setCypressFirstOpened(Date.now() - interval('3 days'))
userProjectStatusStore.setCypressFirstOpened(Date.now() - interval('3 days'))
cy.mountFragment(SpecsListBannersFragmentDoc, {
onResult: (result) => {
@@ -277,6 +298,8 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
hasRequestedAccess: false,
},
savedState: {},
currentTestingType: 'e2e',
config: {},
}
},
render: (gql) => <SpecsListBanners gql={gql} isProjectUnauthorized={visible} />,
@@ -294,9 +317,9 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
beforeEach(() => {
visible.value = true
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setCypressFirstOpened(Date.now() - interval('3 days'))
userProjectStatusStore.setCypressFirstOpened(Date.now() - interval('3 days'))
cy.mountFragment(SpecsListBannersFragmentDoc, {
onResult: (result) => {
@@ -310,6 +333,8 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
hasRequestedAccess: true,
},
savedState: {},
currentTestingType: 'e2e',
config: {},
}
},
render: (gql) => <SpecsListBanners gql={gql} isProjectUnauthorized={visible} hasRequestedAccess />,
@@ -408,4 +433,87 @@ describe('<SpecsListBanners />', { viewportHeight: 260 }, () => {
validateSmartNotificationBehaviors(BannerIds.ACI_082022_RECORD, 'record-banner', gql)
})
describe('component testing', () => {
const gql: Partial<SpecsListBannersFragment> = {
cloudViewer: {
...CloudUserStubs.me,
firstOrganization: {
__typename: 'CloudOrganizationConnection',
nodes: [{ __typename: 'CloudOrganization', id: '987' }],
},
},
currentProject: {
__typename: 'CurrentProject',
id: 'abc123',
title: 'my-test-project',
currentTestingType: 'e2e',
projectId: 'abcd',
cloudProject: {
...CloudProjectStubs.componentProject,
runs: {
__typename: 'CloudRunConnection',
nodes: [{ __typename: 'CloudRun', id: 111 }],
},
},
config: {
component: {},
},
} as any,
wizard: {
__typename: 'Wizard',
framework: {
id: 'react',
name: 'React',
type: 'react',
} as any,
bundler: {
id: 'bundler',
name: 'webpack',
},
},
}
let userProjectStatusStore: UserProjectStatusStore
beforeEach(() => {
cy.gqlStub.Query.currentProject = gql.currentProject as any
cy.gqlStub.Query.cloudViewer = gql.cloudViewer as any
cy.gqlStub.Query.wizard = gql.wizard as any
userProjectStatusStore = useUserProjectStatusStore()
userProjectStatusStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setUserFlag('isMemberOfOrganization', true)
userProjectStatusStore.setProjectFlag('isProjectConnected', true)
userProjectStatusStore.setProjectFlag('hasDetectedCtFramework', true)
userProjectStatusStore.setProjectFlag('isCTConfigured', false)
userProjectStatusStore.setTestingType('e2e')
cy.mountFragment(SpecsListBannersFragmentDoc, {
render: (gqlVal) => <SpecsListBanners gql={gqlVal} />,
})
})
validateBaseRender()
validateCloseControl()
validateSmartNotificationBehaviors(BannerIds.CT_052023_AVAILABLE, 'component-testing-banner', gql)
it('should not render when another smart banner has been dismissed within two days', () => {
userProjectStatusStore.setBannersState({
[BannerIds.ACI_082022_CONNECT_PROJECT]: {
dismissed: Date.now() - interval('3 days'),
},
})
cy.findByTestId('component-testing-banner').should('be.visible').then(() => {
userProjectStatusStore.setBannersState({
[BannerIds.ACI_082022_CONNECT_PROJECT]: {
dismissed: Date.now() - interval('1 day'),
},
})
cy.findByTestId('component-testing-banner').should('not.exist')
})
})
})
})

View File

@@ -90,7 +90,7 @@
:prefix-icon="ConnectIcon"
class="mt-[24px]"
data-cy="reconnect-button"
@click="loginConnectStore.openLoginConnectModal({utmMedium: 'Tests Tab'})"
@click="userProjectStatusStore.openLoginConnectModal({utmMedium: 'Tests Tab'})"
>
{{ t('runs.errors.notFound.button') }}
</Button>
@@ -112,10 +112,13 @@
</Alert>
<component
:is="bannerToShow"
v-else-if="isBannerAllowed && bannerToShow"
:is="bannerComponentToShow"
v-else-if="bannerComponentToShow"
:has-banner-been-shown="hasCurrentBannerBeenShown"
:cohort-option="currentCohortOption.cohort"
:framework="ctFramework"
:bundler="ctBundler"
:machine-id="props.gql.machineId"
/>
</template>
@@ -135,15 +138,15 @@ import RequestAccessButton from './RequestAccessButton.vue'
import { gql } from '@urql/vue'
import { SpecsListBannersFragment, SpecsListBanners_CheckCloudOrgMembershipDocument } from '../generated/graphql'
import { AllowedState, BannerIds } from '@packages/types'
import { LoginBanner, CreateOrganizationBanner, ConnectProjectBanner, RecordBanner } from './banners'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { LoginBanner, ComponentTestingAvailableBanner, CreateOrganizationBanner, ConnectProjectBanner, RecordBanner } from './banners'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
import { usePromptManager } from '@packages/frontend-shared/src/gql-components/composables/usePromptManager'
import { CohortConfig, CohortOption, useCohorts } from '@packages/frontend-shared/src/gql-components/composables/useCohorts'
import { useSubscription } from '../graphql'
const route = useRoute()
const { t } = useI18n()
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
gql`
fragment SpecsListBanners on Query {
@@ -163,6 +166,21 @@ fragment SpecsListBanners on Query {
id
projectId
savedState
currentTestingType
config
}
machineId
wizard {
framework {
id
name
icon
type
}
bundler {
id
name
}
}
}
`
@@ -203,13 +221,12 @@ const showFetchError = ref(props.isFetchError)
const showProjectNotFound = ref(props.isProjectNotFound)
const showProjectRequestAccess = ref(props.isProjectUnauthorized)
const isBannerAllowed = ref(false)
const bannerIds = {
isLoggedOut: BannerIds.ACI_082022_LOGIN,
needsOrgConnect: BannerIds.ACI_082022_CREATE_ORG,
needsProjectConnect: BannerIds.ACI_082022_CONNECT_PROJECT,
needsRecordedRun: BannerIds.ACI_082022_RECORD,
isComponentTestingCandidate: BannerIds.CT_052023_AVAILABLE,
} as const
watch(
@@ -223,32 +240,30 @@ watch(
},
)
const cloudData = computed(() => ([props.gql.cloudViewer, props.gql.cachedUser, props.gql.currentProject] as const))
const bannerToShow = computed(() => {
const { getEffectiveBannerState } = usePromptManager()
const bannerComponentToShow = computed(() => {
const componentsByStatus = {
isLoggedOut: LoginBanner,
needsOrgConnect: CreateOrganizationBanner,
needsProjectConnect: ConnectProjectBanner,
needsRecordedRun: RecordBanner,
isComponentTestingCandidate: ComponentTestingAvailableBanner,
}
return componentsByStatus[loginConnectStore.userStatus] ?? null
const bannerStateToShow = getEffectiveBannerState('specsListBanner')
return bannerStateToShow ? componentsByStatus[bannerStateToShow] : null
})
const hasCurrentBannerBeenShown = computed(() => {
const bannerStateToShow = getEffectiveBannerState('specsListBanner')
const bannersState = (props.gql.currentProject?.savedState as AllowedState)?.banners
const bannerId = bannerStateToShow && bannerIds[bannerStateToShow]
return !!bannersState?._disabled || !!bannersState?.[bannerIds[loginConnectStore.userStatus]]?.lastShown
return !!bannersState?._disabled || (!!bannerId && !!bannersState?.[bannerId]?.lastShown)
})
const { isAllowedFeature } = usePromptManager()
watch(cloudData, () => {
// when cloud data updates, recheck if banner is allowed
isBannerAllowed.value = isAllowedFeature('specsListBanner')
},
{ immediate: true })
type BannerKeys = keyof typeof BannerIds
type BannerId = typeof BannerIds[BannerKeys]
type BannerCohortOptions = Partial<Record<BannerId, CohortOption[]>>
@@ -280,11 +295,21 @@ const getCohortForBanner = (bannerId: BannerId): Ref<CohortOption | undefined> =
}
const currentCohortOption = computed(() => {
if (!bannerCohortOptions[bannerIds[loginConnectStore.userStatus]]) {
if (!bannerCohortOptions[bannerIds[userProjectStatusStore.cloudStatus]]) {
return { cohort: null }
}
return reactive({ cohort: getCohortForBanner(bannerIds[loginConnectStore.userStatus]) })
return reactive({ cohort: getCohortForBanner(bannerIds[userProjectStatusStore.cloudStatus]) })
})
const ctFramework = computed(() => {
return {
name: props.gql.wizard?.framework?.name,
type: props.gql.wizard?.framework?.type,
icon: props.gql.wizard?.framework?.icon,
}
})
const ctBundler = computed(() => props.gql.wizard?.bundler?.name)
</script>

View File

@@ -0,0 +1,87 @@
import { defaultMessages } from '@cy/i18n'
import ComponentTestingAvailableBanner from './ComponentTestingAvailableBanner.vue'
import { TrackedBanner_RecordBannerSeenDocument, TrackedBanner_SetProjectStateDocument } from '../../generated/graphql'
import type Sinon from 'sinon'
const frameworks = [
{ name: 'React', type: 'react' },
{ name: 'Create React App', type: 'reactscripts' },
{ name: 'Nuxt.js (v2)', type: 'nuxtjs' },
{ name: 'Vue', type: 'vue3' },
{ name: 'Angular', type: 'angular' },
{ name: 'Next.js', type: 'nextjs' },
{ name: 'Svelte.js', type: 'svelte' },
]
describe('<ComponentTestingBanner />', { viewportWidth: 1200 }, () => {
it('should render expected content', () => {
cy.mount(<ComponentTestingAvailableBanner hasBannerBeenShown={true} framework={frameworks[0]} machineId="abc" />)
})
frameworks.map((framework) => {
it(`should render expected content for ${framework.name}`, () => {
cy.mount(
<ComponentTestingAvailableBanner hasBannerBeenShown={true} framework={framework} machineId="abc" />,
)
cy.findByTestId('alert-prefix-icon').should('be.visible')
cy.contains(defaultMessages.specPage.banners.ct.title.replace('{0}', framework.name)).should('be.visible')
})
})
context('events', () => {
beforeEach(() => {
const recordEvent = cy.stub().as('recordEvent')
const setPrefs = cy.stub().as('setPrefs')
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
return defineResult({ recordEvent: true })
})
cy.stubMutationResolver(TrackedBanner_SetProjectStateDocument, (defineResult, event) => {
setPrefs(event)
return defineResult({ __typename: 'Mutation', setPreferences: { __typename: 'Query' } as any })
})
})
it('should record expected event on mount', () => {
cy.mount(
<ComponentTestingAvailableBanner hasBannerBeenShown={false} framework={frameworks[0]} machineId="abc" />,
)
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'CT Available',
medium: 'Specs CT Available Banner',
messageId: Cypress.sinon.match.string,
cohort: null,
})
})
it('should not record event on mount if already shown', () => {
cy.mount(
<ComponentTestingAvailableBanner hasBannerBeenShown={true} framework={frameworks[0]} machineId="abc" />,
)
cy.get('@recordEvent').should('not.have.been.called')
})
it('should record dismissal event when clicking survey link', () => {
cy.mount(
<ComponentTestingAvailableBanner hasBannerBeenShown={true} framework={frameworks[0]} machineId="abc" />,
)
cy.findByTestId('survey-link').click()
cy.get('@setPrefs').should('have.been.calledTwice')
cy.get('@setPrefs').should(($stub) => {
const arg = ($stub as unknown as Sinon.SinonStub).getCall(1).args[0]
expect(arg.value).to.contain('ct_052023_available')
expect(arg.value).to.contain('dismissed')
})
})
})
})

View File

@@ -0,0 +1,145 @@
<template>
<TrackedBanner
:banner-id="bannerId"
data-cy="component-testing-banner"
status="promo"
:title="title"
class="mb-[16px]"
dismissible
:icon="iconFromType"
:has-banner-been-shown="hasBannerBeenShown"
:event-data="{
campaign: 'CT Available',
medium: 'Specs CT Available Banner',
cohort: ''
}"
>
<template #default="{ dismiss, bannerInstanceId }">
<p class="pb-[16px] text-gray-700">
{{ t('specPage.banners.ct.content') }}
</p>
<div class="flex flex-row items-center text-sm border-t border-gray-100 pt-[8px] -mb-[8px] -mx-[16px] px-[16px]">
<Button
data-cy="setup-button"
variant="outline-indigo"
size="32"
class="mr-[16px]"
@click="handlePrimary(bannerInstanceId)"
>
{{ t('specPage.banners.ct.primaryAction') }}
</Button>
<ExternalLink
data-cy="docs-link"
:href="docsLink"
@click="handleDocsClick(bannerInstanceId)"
>
<span class="font-medium">{{ t('specPage.banners.ct.secondaryAction') }}</span>
</ExternalLink>
<span class="flex-grow" />
<ExternalLink
data-cy="survey-link"
:href="surveyLink"
@click="handleSurveyClick(bannerInstanceId, dismiss)"
>
<span class="font-medium">{{ t('specPage.banners.ct.dismissAction') }}</span>
</ExternalLink>
</div>
</template>
</TrackedBanner>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { gql, useMutation } from '@urql/vue'
import { useI18n } from '@cy/i18n'
import TrackedBanner from './TrackedBanner.vue'
import { BannerIds } from '@packages/types'
import Button from '@cypress-design/vue-button'
import { FrameworkBundlerLogos } from '@packages/frontend-shared/src/utils/icons'
import ExternalLink from '@cy/gql-components/ExternalLink.vue'
import { getUrlWithParams } from '@packages/frontend-shared/src/utils/getUrlWithParams'
import { SwitchToComponentTestingDocument, ComponentTestingAvailable_RecordEventDocument } from '../../generated/graphql'
gql`
mutation SwitchToComponentTesting {
switchTestingTypeAndRelaunch(testingType: component)
}
`
gql`
mutation ComponentTestingAvailable_RecordEvent($campaign: String!, $messageId: String!, $payload: String!) {
recordEvent(campaign: $campaign, medium: "CT Available Banner", messageId: $messageId, includeMachineId: true, payload: $payload)
}
`
const switchToCtAndRelaunch = useMutation(SwitchToComponentTestingDocument)
const recordEvent = useMutation(ComponentTestingAvailable_RecordEventDocument)
const props = defineProps<{
hasBannerBeenShown: boolean
framework: {
name: string
icon?: string | null
type: string
}
bundler?: 'vite' | 'webpack'
machineId: string | undefined
}>()
const { t } = useI18n()
const bannerId = BannerIds.CT_052023_AVAILABLE
const title = computed(() => t('specPage.banners.ct.title', [props.framework?.name]))
const iconFromType = computed(() => FrameworkBundlerLogos[props.framework?.type])
const handlePrimary = async (bannerInstanceId: string) => {
await recordCampaignEvent(bannerInstanceId, 'Quick setup')
await switchToCtAndRelaunch.executeMutation({})
}
const handleDocsClick = async (bannerInstanceId: string) => {
await recordCampaignEvent(bannerInstanceId, 'Read our guide')
}
const handleSurveyClick = async (bannerInstanceId: string, dismiss: () => Promise<void>) => {
await recordCampaignEvent(bannerInstanceId, 'Give feedback')
await dismiss()
}
const recordCampaignEvent = async (bannerInstanceId: string, campaign: string) => {
await recordEvent.executeMutation({
campaign,
messageId: bannerInstanceId,
payload: JSON.stringify({
framework: props.framework.name,
bundler: props.bundler,
}),
})
}
const docsLink = computed(() => {
return getUrlWithParams({
url: 'https://on.cypress.io/component',
params: {
utm_medium: 'CT Available Banner',
utm_campaign: 'Read the Docs',
utm_content: [props.framework.name, props.bundler].filter((val) => !!val).join('-'),
},
})
})
const surveyLink = computed(() => {
return getUrlWithParams({
url: 'https://on.cypress.io/component-survey-q2-23',
params: {
utm_medium: 'CT Available Banner',
utm_campaign: 'Give feedback',
utm_content: [props.framework.name, props.bundler].filter((val) => !!val).join('-'),
machine_id: props.machineId ?? '',
},
})
})
</script>

View File

@@ -37,8 +37,8 @@ import Button from '@cy/components/Button.vue'
import TrackedBanner from './TrackedBanner.vue'
import type { CohortOption } from '@packages/frontend-shared/src/gql-components/composables/useCohorts'
import { BannerIds } from '@packages/types'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
const { openLoginConnectModal } = useLoginConnectStore()
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
const { openLoginConnectModal } = useUserProjectStatusStore()
defineProps<{
hasBannerBeenShown: boolean

View File

@@ -37,8 +37,8 @@ import Button from '@cy/components/Button.vue'
import TrackedBanner from './TrackedBanner.vue'
import type { CohortOption } from '@packages/frontend-shared/src/gql-components/composables/useCohorts'
import { BannerIds } from '@packages/types'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
const { openLoginConnectModal } = useLoginConnectStore()
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
const { openLoginConnectModal } = useUserProjectStatusStore()
defineProps<{
hasBannerBeenShown: boolean

View File

@@ -3,7 +3,10 @@
v-model="isAlertDisplayed"
v-bind="$attrs"
>
<slot />
<slot
:dismiss="dismiss"
:bannerInstanceId="bannerInstanceId"
/>
</Alert>
</template>
@@ -71,22 +74,22 @@ watchEffect(() => {
}
})
watch(() => isAlertDisplayed.value, (newVal) => {
watch(() => isAlertDisplayed.value, async (newVal) => {
if (!newVal) {
updateBannerState('dismissed')
await updateBannerState('dismissed')
}
})
onMounted(() => {
updateBannerState('lastShown')
onMounted(async () => {
await updateBannerState('lastShown')
})
function updateBannerState (field: 'lastShown' | 'dismissed') {
async function updateBannerState (field: 'lastShown' | 'dismissed') {
const savedBannerState = stateQuery.data.value?.currentProject?.savedState?.banners ?? {}
set(savedBannerState, [props.bannerId, field], Date.now())
setStateMutation.executeMutation({ value: JSON.stringify({ banners: savedBannerState }) })
await setStateMutation.executeMutation({ value: JSON.stringify({ banners: savedBannerState }) })
}
function recordBannerShown ({ campaign, medium, cohort }: EventData): void {
@@ -98,4 +101,8 @@ function recordBannerShown ({ campaign, medium, cohort }: EventData): void {
})
}
async function dismiss (): Promise<void> {
await updateBannerState('dismissed')
}
</script>

View File

@@ -5,3 +5,5 @@ export { default as CreateOrganizationBanner } from './CreateOrganizationBanner.
export { default as ConnectProjectBanner } from './ConnectProjectBanner.vue'
export { default as RecordBanner } from './RecordBanner.vue'
export { default as ComponentTestingAvailableBanner } from './ComponentTestingAvailableBanner.vue'

View File

@@ -1,8 +1,5 @@
const config = require('@packages/frontend-shared/tailwind.config.cjs')
config.content.files = [
'./src/**/*.{vue,js,ts,jsx,tsx,scss,css}', //
'../frontend-shared/src/**/*.{vue,js,ts,jsx,tsx,scss,css}',
],
config.content.files.push('../frontend-shared/src/**/*.{vue,js,ts,jsx,tsx,scss,css}')
module.exports = config

View File

@@ -5,11 +5,13 @@ const pkg = require('@packages/root')
const debug = Debug('cypress:data-context:actions:EventCollectorActions')
interface CollectableEvent {
interface CollectibleEvent {
campaign: string
messageId: string
medium: string
cohort?: string
payload?: object
machineId?: string
}
/**
@@ -23,23 +25,29 @@ export class EventCollectorActions {
debug('Using %s environment for Event Collection', cloudEnv)
}
async recordEvent (event: CollectableEvent): Promise<boolean> {
async recordEvent (event: CollectibleEvent, includeMachineId: boolean): Promise<boolean> {
try {
const cloudUrl = this.ctx.cloud.getCloudUrl(cloudEnv)
const eventUrl = includeMachineId ? `${cloudUrl}/machine-collect` : `${cloudUrl}/anon-collect`
const headers = {
'Content-Type': 'application/json',
'x-cypress-version': pkg.version,
}
if (includeMachineId) {
event.machineId = (await this.ctx.coreData.machineId) || undefined
}
await this.ctx.util.fetch(
`${cloudUrl}/anon-collect`,
eventUrl,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-cypress-version': pkg.version,
},
headers,
body: JSON.stringify(event),
},
)
debug(`Recorded event: %o`, event)
debug(`Recorded %s event: %o`, includeMachineId ? 'machine-linked' : 'anonymous', event)
return true
} catch (err) {

View File

@@ -100,6 +100,14 @@ export class WizardActions {
this.resetWizard()
await this.initializeFramework()
}
async initializeFramework () {
if (!this.ctx.currentProject) {
return
}
const detected = await detectFramework(this.ctx.currentProject, this.ctx.coreData.wizard.frameworks)
debug('detected %o', detected)

View File

@@ -7,6 +7,7 @@ import type { SocketIONamespace, SocketIOServer } from '@packages/socket'
import type { Server } from 'http'
import type { ErrorWrapperSource } from '@packages/errors'
import type { GitDataSource, LegacyCypressConfigJson } from '../sources'
import { machineId as getMachineId } from 'node-machine-id'
export type Maybe<T> = T | null | undefined
@@ -123,6 +124,7 @@ export interface CoreDataShape {
cliBrowser: string | null
cliTestingType: string | null
activeBrowser: FoundBrowser | null
machineId: Promise<string | null>
machineBrowsers: Promise<FoundBrowser[]> | null
allBrowsers: Promise<FoundBrowser[]> | null
servers: {
@@ -166,6 +168,7 @@ export function makeCoreData (modeOptions: Partial<AllModeOptions> = {}): CoreDa
servers: {},
cliBrowser: modeOptions.browser ?? null,
cliTestingType: modeOptions.testingType ?? null,
machineId: machineId(),
machineBrowsers: null,
allBrowsers: null,
hasInitializedMode: null,
@@ -233,4 +236,12 @@ export function makeCoreData (modeOptions: Partial<AllModeOptions> = {}): CoreDa
testsForRunResults: {},
},
}
async function machineId (): Promise<string | null> {
try {
return await getMachineId()
} catch (error) {
return null
}
}
}

View File

@@ -9,7 +9,6 @@ import semver from 'semver'
const debug = Debug('cypress:data-context:sources:VersionsDataSource')
const pkg = require('@packages/root')
const nmi = require('node-machine-id')
interface Version {
id: string
@@ -131,7 +130,7 @@ export class VersionsDataSource {
return pkg.version
}
const id = await VersionsDataSource.machineId()
const id = (await this.ctx.coreData.machineId) || undefined
const manifestHeaders: HeadersInit = {
'Content-Type': 'application/json',
@@ -226,14 +225,6 @@ export class VersionsDataSource {
}
}
private static async machineId (): Promise<string | undefined> {
try {
return await nmi.machineId()
} catch (error) {
return undefined
}
}
private isFulfilled<R> (item: PromiseSettledResult<R>): item is PromiseFulfilledResult<R> {
return item.status === 'fulfilled'
}

View File

@@ -24,13 +24,13 @@ describe('EventCollectorActions', () => {
})
context('.recordEvent', () => {
it('makes expected request', async () => {
it('makes expected request for anonymous event', async () => {
await actions.recordEvent({
campaign: 'abc',
medium: 'def',
messageId: 'ghi',
cohort: '123',
})
}, false)
expect(ctx.util.fetch).to.have.been.calledOnceWith(
sinon.match(/anon-collect$/), // Verify URL ends with expected 'anon-collect' path
@@ -38,10 +38,26 @@ describe('EventCollectorActions', () => {
)
})
it('makes expected request for machine-linked event', async () => {
ctx.coreData.machineId = Promise.resolve('xyz')
await actions.recordEvent({
campaign: 'abc',
medium: 'def',
messageId: 'ghi',
cohort: '123',
}, true)
expect(ctx.util.fetch).to.have.been.calledOnceWith(
sinon.match(/machine-collect$/), // Verify URL ends with expected 'machine-collect' path
{ method: 'POST', headers: { 'Content-Type': 'application/json', 'x-cypress-version': pkg.version }, body: '{"campaign":"abc","medium":"def","messageId":"ghi","cohort":"123","machineId":"xyz"}' },
)
})
it('resolve true if request succeeds', async () => {
(ctx.util.fetch as SinonStub).resolves({} as any)
const result = await actions.recordEvent({ campaign: '', medium: '', messageId: '', cohort: '' })
const result = await actions.recordEvent({ campaign: '', medium: '', messageId: '', cohort: '' }, false)
expect(result).to.eql(true)
})
@@ -49,7 +65,7 @@ describe('EventCollectorActions', () => {
it('resolves false if request fails', async () => {
(ctx.util.fetch as SinonStub).rejects({} as any)
const result = await actions.recordEvent({ campaign: '', medium: '', messageId: '', cohort: '' })
const result = await actions.recordEvent({ campaign: '', medium: '', messageId: '', cohort: '' }, false)
expect(result).to.eql(false)
})

View File

@@ -9,12 +9,10 @@ import { createTestDataContext } from '../helper'
import { CYPRESS_REMOTE_MANIFEST_URL, NPM_CYPRESS_REGISTRY_URL } from '@packages/types'
const pkg = require('@packages/root')
const nmi = require('node-machine-id')
describe('VersionsDataSource', () => {
context('.versions', () => {
let ctx: DataContext
let nmiStub: sinon.SinonStub
let fetchStub: sinon.SinonStub
let isDependencyInstalledStub: sinon.SinonStub
let mockNow: Date = new Date()
@@ -33,6 +31,7 @@ describe('VersionsDataSource', () => {
},
}
ctx.coreData.machineId = Promise.resolve('abcd123')
ctx.coreData.currentProject = '/abc'
ctx.coreData.currentTestingType = 'e2e'
@@ -41,7 +40,6 @@ describe('VersionsDataSource', () => {
})
beforeEach(() => {
nmiStub = sinon.stub(nmi, 'machineId')
sinon.stub(ctx.util, 'fetch').callsFake(fetchStub)
sinon.stub(ctx.util, 'isDependencyInstalled').callsFake(isDependencyInstalledStub)
sinon.stub(os, 'platform').returns('darwin')
@@ -54,8 +52,6 @@ describe('VersionsDataSource', () => {
})
it('loads the manifest for the latest version with all headers and queries npm for release dates', async () => {
nmiStub.resolves('abcd123')
fetchStub
.withArgs(CYPRESS_REMOTE_MANIFEST_URL, {
headers: sinon.match({
@@ -107,7 +103,7 @@ describe('VersionsDataSource', () => {
it('resets telemetry data triggering a new call to get the latest version', async () => {
const currentCypressVersion = pkg.version
nmiStub.rejects('Error while obtaining machine id')
ctx.coreData.machineId = Promise.resolve(null)
ctx.coreData.currentTestingType = 'component'
fetchStub
@@ -140,8 +136,6 @@ describe('VersionsDataSource', () => {
})
it('handles errors fetching version data', async () => {
nmiStub.resolves('abcd123')
fetchStub
.withArgs(CYPRESS_REMOTE_MANIFEST_URL, {
headers: sinon.match({
@@ -167,8 +161,6 @@ describe('VersionsDataSource', () => {
})
it('handles invalid response errors', async () => {
nmiStub.resolves('abcd123')
fetchStub
.withArgs(CYPRESS_REMOTE_MANIFEST_URL, {
headers: sinon.match({

View File

@@ -19,7 +19,7 @@
"dependencies": {},
"devDependencies": {
"@antfu/utils": "^0.3.0",
"@cypress-design/css": "^0.11.0",
"@cypress-design/css": "^0.13.1",
"@graphql-typed-document-node/core": "^3.1.0",
"@headlessui/vue": "1.4.0",
"@iconify/json": "1.1.368",

View File

Before

Width:  |  Height:  |  Size: 788 B

After

Width:  |  Height:  |  Size: 788 B

View File

Before

Width:  |  Height:  |  Size: 688 B

After

Width:  |  Height:  |  Size: 688 B

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 486 B

After

Width:  |  Height:  |  Size: 486 B

View File

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 696 B

View File

@@ -23,7 +23,7 @@
:title="title"
:header-class="`${props.headerClass} ${canCollapse ? 'group-hocus:underline' : ''}`"
:prefix-icon="prefix?.icon"
:prefix-icon-class="open ? prefix?.classes + ' rotate-180' : prefix?.classes"
:prefix-icon-class="(open && collapsible) ? prefix?.classes + ' rotate-180' : prefix?.classes"
:suffix-icon-aria-label="props.dismissible ? t('components.alert.dismissAriaLabel') : ''"
:suffix-icon="props.dismissible ? DeleteIcon : null"
:suffix-button-class="classes.suffixButtonClass"
@@ -73,7 +73,7 @@
</template>
<script lang="ts">
export type AlertStatus = 'error' | 'warning' | 'info' | 'default' | 'success'
export type AlertStatus = 'error' | 'warning' | 'info' | 'default' | 'success' | 'promo'
export type AlertClasses = {
headerClass: string
@@ -162,6 +162,13 @@ const alertStyles: Record<AlertStatus, AlertClasses> = {
bodyClass: 'bg-success-50',
ring: 'hocus:ring-success-200 hocus:border-success-300',
},
promo: {
headerClass: 'text-gray-900 bg-white border border-gray-100',
suffixIconClass: 'icon-dark-gray-700',
suffixButtonClass: 'text-gray-700',
bodyClass: 'bg-white border-t border-gray-100',
ring: 'hocus:ring-gray-100 hocus:border-gray-100',
},
}
const classes = computed(() => {

View File

@@ -82,9 +82,9 @@ import type {
} from '../generated/graphql'
import Button from '@cy/components/Button.vue'
import { useI18n } from '@cy/i18n'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
const { t } = useI18n()
@@ -178,7 +178,7 @@ onBeforeUnmount(() => {
})
const showConnectButton = computed(() => {
return loginConnectStore.project.isConfigLoaded && loginConnectStore.userStatusMatches('needsProjectConnect')
return userProjectStatusStore.project.isConfigLoaded && userProjectStatusStore.cloudStatusMatches('needsProjectConnect')
})
const emit = defineEmits<{
@@ -205,7 +205,7 @@ const loginMutationIsPending = computed(() => {
})
const handleLoginOrContinue = async () => {
if (loginConnectStore.user.isLoggedIn) {
if (userProjectStatusStore.user.isLoggedIn) {
// user is already logged in, just emit close event & return early
emit('close')
@@ -244,7 +244,7 @@ const buttonText = computed(() => {
return strings.connectProject
}
if (loginConnectStore.user.isLoggedIn) {
if (userProjectStatusStore.user.isLoggedIn) {
return strings.continue
}

View File

@@ -7,10 +7,10 @@
-->
<script setup lang="ts">
import { watchEffect } from 'vue'
import { gql, useQuery, useSubscription } from '@urql/vue'
import { CloudViewerAndProject_RequiredDataDocument, CloudViewerAndProject_CheckCloudOrgMembershipDocument } from '../generated/graphql'
import { useLoginConnectStore } from '../store/login-connect-store'
import { watchEffect, ref } from 'vue'
import { gql, useMutation, useQuery, useSubscription } from '@urql/vue'
import { CloudViewerAndProject_RequiredDataDocument, CloudViewerAndProject_CheckCloudOrgMembershipDocument, CloudViewerAndProject_DetectCtFrameworksDocument } from '../generated/graphql'
import { useUserProjectStatusStore } from '../store/user-project-status-store'
gql`
fragment CloudViewerAndProject on Query {
@@ -35,7 +35,9 @@ fragment CloudViewerAndProject on Query {
currentProject {
id
config
currentTestingType
isFullConfigReady
isCTConfigured
hasNonExampleSpec
savedState
cloudProject {
@@ -52,6 +54,14 @@ fragment CloudViewerAndProject on Query {
}
}
}
wizard {
framework {
id
name
icon
isDetected
}
}
}
`
@@ -69,7 +79,14 @@ subscription CloudViewerAndProject_CheckCloudOrgMembership {
}
`
const loginConnectStore = useLoginConnectStore()
gql`
mutation CloudViewerAndProject_DetectCtFrameworks {
initializeCtFrameworks
}
`
const hasDetectedFrameworks = ref(false)
const userProjectStatusStore = useUserProjectStatusStore()
const {
setHasInitiallyLoaded,
setUserFlag,
@@ -77,18 +94,29 @@ const {
setUserData,
setPromptShown,
setCypressFirstOpened,
setTestingType,
setBannersState,
} = loginConnectStore
} = userProjectStatusStore
useSubscription({ query: CloudViewerAndProject_CheckCloudOrgMembershipDocument })
const query = useQuery({ query: CloudViewerAndProject_RequiredDataDocument })
watchEffect(() => {
const detectCtFrameworks = useMutation(CloudViewerAndProject_DetectCtFrameworksDocument)
watchEffect(async () => {
if (!query.data.value) {
return
}
if (!hasDetectedFrameworks.value && query.data.value.currentProject?.currentTestingType === 'e2e') {
await detectCtFrameworks.executeMutation({})
hasDetectedFrameworks.value = true
return
}
/**
* Indicates that the CloudViewerAndProject has received its initial data response to use to set flags. It can be used
* to detect that the app or launchpad has completed the first initialization of determining if the user is logged in,
@@ -101,6 +129,7 @@ watchEffect(() => {
cachedUser,
cloudViewer,
authState,
wizard,
} = query.data.value
const savedState = currentProject?.savedState
@@ -119,6 +148,8 @@ watchEffect(() => {
setBannersState(savedState.banners)
}
setTestingType(currentProject?.currentTestingType ?? undefined)
const AUTH_STATE_ERRORS = ['AUTH_COULD_NOT_LAUNCH_BROWSER', 'AUTH_ERROR_DURING_LOGIN', 'AUTH_COULD_NOT_LAUNCH_BROWSER']
// 1. set user-related information in store
@@ -140,9 +171,12 @@ watchEffect(() => {
setProjectFlag('hasNonExampleSpec', !!currentProject?.hasNonExampleSpec)
setProjectFlag('hasNoRecordedRuns', currentProject?.cloudProject?.__typename === 'CloudProject' && (currentProject.cloudProject?.runs?.nodes?.length ?? 0) === 0)
if (currentProject?.cloudProject || !loginConnectStore.user.isLoggedIn) {
if (currentProject?.cloudProject || !userProjectStatusStore.user.isLoggedIn) {
setProjectFlag('isProjectConnected', currentProject?.cloudProject?.__typename === 'CloudProject')
}
setProjectFlag('isCTConfigured', !!currentProject?.isCTConfigured)
setProjectFlag('hasDetectedCtFramework', wizard?.framework?.isDetected ?? false)
})
</script>

View File

@@ -2,7 +2,7 @@ import { HeaderBar_HeaderBarContentFragmentDoc } from '../generated/graphql-test
import HeaderBarContent from './HeaderBarContent.vue'
import { defaultMessages } from '@cy/i18n'
import { CloudUserStubs } from '@packages/graphql/test/stubCloudTypes'
import { useLoginConnectStore } from '../store/login-connect-store'
import { useUserProjectStatusStore } from '../store/user-project-status-store'
const text = defaultMessages.topNav
@@ -297,9 +297,9 @@ describe('<HeaderBarContent />', { viewportWidth: 1000, viewportHeight: 750 }, (
})
it('the logged in state is correctly presented in header', () => {
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
loginConnectStore.setUserFlag('isLoggedIn', true)
userProjectStatusStore.setUserFlag('isLoggedIn', true)
const cloudViewer = {
...CloudUserStubs.me,
@@ -313,7 +313,7 @@ describe('<HeaderBarContent />', { viewportWidth: 1000, viewportHeight: 750 }, (
fullName: 'Tester Test',
}
loginConnectStore.setUserData(cloudViewer)
userProjectStatusStore.setUserData(cloudViewer)
cy.mountFragment(HeaderBar_HeaderBarContentFragmentDoc, {
onResult: (result) => {

View File

@@ -94,18 +94,18 @@
@clear-force-open="isForceOpenAllowed = false"
>
<template
v-if="loginConnectStore.user.isLoggedIn"
v-if="userProjectStatusStore.user.isLoggedIn"
#login-title
>
<UserAvatar
:email="loginConnectStore.userData?.email"
:email="userProjectStatusStore.userData?.email"
class="h-[24px] w-[24px]"
data-cy="user-avatar-title"
/>
<span class="sr-only">{{ t('topNav.login.profileMenuLabel') }}</span>
</template>
<template
v-if="loginConnectStore.userData"
v-if="userProjectStatusStore.userData"
#login-panel
>
<div
@@ -114,14 +114,14 @@
>
<div class="border-b flex border-b-gray-100 p-[16px]">
<UserAvatar
:email="loginConnectStore.userData?.email"
:email="userProjectStatusStore.userData?.email"
class="h-[48px] mr-[16px] w-[48px]"
data-cy="user-avatar-panel"
/>
<div>
<span class="text-gray-800">{{ loginConnectStore.userData?.fullName }}</span>
<span class="text-gray-800">{{ userProjectStatusStore.userData?.fullName }}</span>
<br>
<span class="text-gray-600">{{ loginConnectStore.userData?.email }}</span>
<span class="text-gray-600">{{ userProjectStatusStore.userData?.email }}</span>
<br>
<ExternalLink
href="https://on.cypress.io/dashboard/profile"
@@ -141,10 +141,10 @@
</div>
</template>
</TopNav>
<div v-if="!loginConnectStore.user.isLoggedIn">
<div v-if="!userProjectStatusStore.user.isLoggedIn">
<button
class="flex text-gray-600 items-center group focus:outline-transparent"
@click="loginConnectStore.openLoginConnectModal({ utmMedium: 'Nav' })"
@click="userProjectStatusStore.openLoginConnectModal({ utmMedium: 'Nav' })"
>
<i-cy-profile_x16
class="h-[16px] mr-[8px] w-[16px] block icon-dark-gray-500 icon-light-gray-100 group-hocus:icon-dark-indigo-500 group-hocus:icon-light-indigo-50"
@@ -174,9 +174,9 @@ import interval from 'human-interval'
import { sortBy } from 'lodash'
import Tooltip from '../components/Tooltip.vue'
import type { AllowedState } from '@packages/types'
import { useLoginConnectStore } from '../store/login-connect-store'
import { useUserProjectStatusStore } from '../store/user-project-status-store'
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
gql`
fragment HeaderBarContent_Auth on Query {

View File

@@ -22,7 +22,7 @@
<template>
<LoginConnectModalsContent
v-if="loginConnectStore.isLoginConnectOpen"
v-if="userProjectStatusStore.isLoginConnectOpen"
:gql="query.data.value"
/>
</template>
@@ -30,9 +30,9 @@
import LoginConnectModalsContent from './LoginConnectModalsContent.vue'
import { gql, useQuery } from '@urql/vue'
import { LoginConnectModals_LoginConnectModalsQueryDocument } from '../generated/graphql'
import { useLoginConnectStore } from '../store/login-connect-store'
import { useUserProjectStatusStore } from '../store/user-project-status-store'
import { whenever } from '@vueuse/core'
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
gql`
query LoginConnectModals_LoginConnectModalsQuery {
@@ -46,6 +46,6 @@ const executeQuery = async () => {
await query.executeQuery()
}
whenever(() => loginConnectStore.isLoginConnectOpen, executeQuery)
whenever(() => userProjectStatusStore.isLoginConnectOpen, executeQuery)
</script>

View File

@@ -3,13 +3,13 @@ import LoginConnectModalsContent from './LoginConnectModalsContent.vue'
import { CloudUserStubs } from '@packages/graphql/test/stubCloudTypes'
import { SelectCloudProjectModal_CreateCloudProjectDocument } from '../generated/graphql'
import { useLoginConnectStore } from '../store/login-connect-store'
import { useUserProjectStatusStore } from '../store/user-project-status-store'
describe('<LoginConnectModalsContent />', () => {
context('when user is logged out', () => {
[undefined, 'testContent'].forEach((content) => {
it(`shows login modal with utmContent: ${content}`, () => {
const { openLoginConnectModal } = useLoginConnectStore()
const { openLoginConnectModal } = useUserProjectStatusStore()
cy.mountFragment(LoginConnectModalsContentFragmentDoc, {
onResult: (result) => {
@@ -52,7 +52,7 @@ describe('<LoginConnectModalsContent />', () => {
context('when user is logged in', () => {
[undefined, 'testContent'].forEach((content) => {
it('shows "Create Project" state if project is not set up', () => {
const { openLoginConnectModal, setUserFlag, setProjectFlag } = useLoginConnectStore()
const { openLoginConnectModal, setUserFlag, setProjectFlag } = useUserProjectStatusStore()
setUserFlag('isLoggedIn', true)
setUserFlag('isMemberOfOrganization', true)

View File

@@ -1,25 +1,25 @@
<template>
<template v-if="loginConnectStore.isLoginConnectOpen && gqlRef">
<template v-if="userProjectStatusStore.isLoginConnectOpen && gqlRef">
<LoginModal
v-if="userStatusMatches('isLoggedOut') || keepLoginOpen"
v-if="cloudStatusMatches('isLoggedOut') || keepLoginOpen"
:gql="gqlRef"
:utm-medium="loginConnectStore.utmMedium"
:utm-content="loginConnectStore.utmContent"
:utm-medium="userProjectStatusStore.utmMedium"
:utm-content="userProjectStatusStore.utmContent"
@cancel="closeLoginConnectModal"
@close="handleCloseLogin"
/>
<RecordRunModal
v-else-if="userStatusMatches('needsRecordedRun')"
:utm-medium="loginConnectStore.utmMedium"
:utm-content="loginConnectStore.utmContent"
v-else-if="cloudStatusMatches('needsRecordedRun')"
:utm-medium="userProjectStatusStore.utmMedium"
:utm-content="userProjectStatusStore.utmContent"
@cancel="closeLoginConnectModal"
/>
<CloudConnectModals
v-else-if="userStatusMatches('needsProjectConnect') || userStatusMatches('needsOrgConnect')"
:show="loginConnectStore.user.isLoggedIn"
v-else-if="cloudStatusMatches('needsProjectConnect') || cloudStatusMatches('needsOrgConnect')"
:show="userProjectStatusStore.user.isLoggedIn"
:gql="gqlRef"
:utm-medium="loginConnectStore.utmMedium"
:utm-content="loginConnectStore.utmContent"
:utm-medium="userProjectStatusStore.utmMedium"
:utm-content="userProjectStatusStore.utmContent"
@cancel="closeLoginConnectModal"
@success="closeLoginConnectModal"
/>
@@ -30,7 +30,7 @@ import { gql } from '@urql/vue'
import type { LoginConnectModalsContentFragment } from '../generated/graphql'
import LoginModal from './modals/LoginModal.vue'
import { ref, watch } from 'vue'
import { useLoginConnectStore } from '../store/login-connect-store'
import { useUserProjectStatusStore } from '../store/user-project-status-store'
import CloudConnectModals from './modals/CloudConnectModals.vue'
import RecordRunModal from './RecordRunModal.vue'
import { debouncedWatch } from '@vueuse/core'
@@ -50,15 +50,15 @@ const props = defineProps<{
gql?: LoginConnectModalsContentFragment
}>()
const loginConnectStore = useLoginConnectStore()
const { closeLoginConnectModal, userStatusMatches } = loginConnectStore
const userProjectStatusStore = useUserProjectStatusStore()
const { closeLoginConnectModal, cloudStatusMatches } = userProjectStatusStore
// use this to hold login open after the transition between logged out and logged in
// this is to show the temporary "continue" state and its variations
// that only exist if you have used the modal to log in
const keepLoginOpen = ref(false)
watch(() => loginConnectStore.userStatus, (newVal, oldVal) => {
watch(() => userProjectStatusStore.cloudStatus, (newVal, oldVal) => {
if (oldVal === 'isLoggedOut' && newVal !== 'isLoggedOut') {
keepLoginOpen.value = true
}
@@ -67,7 +67,7 @@ watch(() => loginConnectStore.userStatus, (newVal, oldVal) => {
})
const handleCloseLogin = () => {
if (userStatusMatches('allTasksCompleted')) {
if (cloudStatusMatches('allTasksCompleted')) {
closeLoginConnectModal()
} else {
keepLoginOpen.value = false

View File

@@ -1,6 +1,6 @@
import { gql, useMutation } from '@urql/vue'
import { UsePromptManager_SetProjectPreferencesDocument, UsePromptManager_SetGlobalPreferencesDocument } from '../../generated/graphql'
import { useLoginConnectStore } from '@packages/frontend-shared/src/store/login-connect-store'
import { CloudStatus, ProjectStatus, useUserProjectStatusStore } from '@packages/frontend-shared/src/store/user-project-status-store'
import { isAllowedFeature } from '../../utils/isAllowedFeature'
gql`
@@ -26,10 +26,12 @@ mutation UsePromptManager_SetGlobalPreferences($value: String!) {
}
`
type FeatureName = Parameters<typeof isAllowedFeature>[0]
export function usePromptManager () {
const setProjectPreferencesMutation = useMutation(UsePromptManager_SetProjectPreferencesDocument)
const setGlobalPreferencesMutation = useMutation(UsePromptManager_SetGlobalPreferencesDocument)
const loginConnectStore = useLoginConnectStore()
const userProjectStatusStore = useUserProjectStatusStore()
// TODO: get Nav CI prompts using this in #23768 and retire the old setPromptShown mutation
function setPromptShown (slug: 'ci1' | 'orchestration1' | 'loginModalRecord') {
@@ -40,13 +42,31 @@ export function usePromptManager () {
return setGlobalPreferencesMutation.executeMutation({ value: JSON.stringify({ majorVersionWelcomeDismissed: { [majorVersion]: Date.now() } }) })
}
const wrappedIsAllowedFeature = (featureName: 'specsListBanner' | 'docsCiPrompt') => {
return isAllowedFeature(featureName, loginConnectStore)
const wrappedIsAllowedFeature = (featureName: FeatureName, state: CloudStatus | ProjectStatus) => {
return isAllowedFeature(featureName, userProjectStatusStore, state)
}
const getEffectiveBannerState = (featureName: FeatureName) => {
const cloudStatus = userProjectStatusStore.cloudStatus
const projectStatus = userProjectStatusStore.projectStatus
if (featureName === 'specsListBanner') {
if (cloudStatus !== 'allTasksCompleted' && wrappedIsAllowedFeature('specsListBanner', cloudStatus)) {
return cloudStatus
}
if (projectStatus !== 'allTasksCompleted' && wrappedIsAllowedFeature('specsListBanner', projectStatus)) {
return projectStatus
}
}
return null
}
return {
setPromptShown,
isAllowedFeature: wrappedIsAllowedFeature,
setMajorVersionWelcomeDismissed,
getEffectiveBannerState,
}
}

View File

@@ -4,7 +4,7 @@ import { defaultMessages } from '@cy/i18n'
import Tooltip from '../../components/Tooltip.vue'
import { ref } from 'vue'
import { CloudUserStubs } from '@packages/graphql/test/stubCloudTypes'
import { useLoginConnectStore } from '../../store'
import { useUserProjectStatusStore } from '../../store'
const text = defaultMessages.topNav
@@ -38,7 +38,7 @@ const mountSuccess = (viewer: TestCloudViewer = cloudViewer) => {
...viewer,
}
const { setUserFlag } = useLoginConnectStore()
const { setUserFlag } = useUserProjectStatusStore()
setUserFlag('isLoggedIn', true)
cy.mountFragment(LoginModalFragmentDoc, {

View File

@@ -215,6 +215,13 @@
"record": {
"title": "Record your first run",
"content": "Record a run to see your test results in Cypress Cloud. You can then optimize your test suite, debug failing and flaky tests, and integrate with your favorite tools."
},
"ct": {
"title": "{0} component testing is available for this project",
"content": "You can now use Cypress to develop and test individual components without running your whole application. Generate the config in just a few clicks.",
"primaryAction": "Quick setup",
"secondaryAction": "Read our guide",
"dismissAction": "Give feedback"
}
},
"runSelectedSpecs": "Run {n} spec | Run {n} specs"

View File

@@ -1,4 +1,4 @@
export * from './login-connect-store'
export * from './user-project-status-store'
import { createPinia as _createPinia } from 'pinia'

View File

@@ -6,12 +6,13 @@ interface LoginUserData {
email: string | null
}
export interface LoginConnectState {
export interface UserProjectStatusState {
hasInitiallyLoaded: boolean
isLoginConnectOpen: boolean
utmMedium: string
utmContent?: string
cypressFirstOpened?: number
testingType?: 'e2e' | 'component'
user: {
isLoggedIn: boolean
loginError: boolean
@@ -25,6 +26,8 @@ export interface LoginConnectState {
hasNonExampleSpec: boolean
isNotAuthorized: boolean
isNotFound: boolean
isCTConfigured: boolean
hasDetectedCtFramework: boolean
}
userData?: LoginUserData
promptsShown: {
@@ -35,10 +38,7 @@ export interface LoginConnectState {
_latestBannerShownTimeForTesting?: number
}
// The user can be in only one status at a time.
// These are specifically related to Cypress Cloud
// and the progress from logging in to recording a run.
export const userStatuses = [
export const CLOUD_STATUSES = [
'isLoggedOut',
'needsOrgConnect',
'needsProjectConnect',
@@ -46,12 +46,19 @@ export const userStatuses = [
'allTasksCompleted',
] as const
export type UserStatus = typeof userStatuses[number]
export const PROJECT_STATUSES = [
'isComponentTestingCandidate',
'allTasksCompleted',
] as const
export const useLoginConnectStore = defineStore({
id: 'loginConnect',
export type CloudStatus = typeof CLOUD_STATUSES[number]
state (): LoginConnectState {
export type ProjectStatus = typeof PROJECT_STATUSES[number]
export const useUserProjectStatusStore = defineStore({
id: 'userProjectStatus',
state (): UserProjectStatusState {
return {
hasInitiallyLoaded: false,
utmMedium: '',
@@ -59,6 +66,7 @@ export const useLoginConnectStore = defineStore({
isLoginConnectOpen: false,
cypressFirstOpened: undefined,
userData: undefined,
testingType: undefined,
user: {
isLoggedIn: false,
loginError: false,
@@ -72,6 +80,8 @@ export const useLoginConnectStore = defineStore({
hasNonExampleSpec: false,
isNotAuthorized: false,
isNotFound: false,
isCTConfigured: false,
hasDetectedCtFramework: false,
},
promptsShown: {},
bannersState: {},
@@ -92,10 +102,10 @@ export const useLoginConnectStore = defineStore({
this.utmMedium = ''
this.utmContent = undefined
},
setUserFlag (name: keyof LoginConnectState['user'], newVal: boolean) {
setUserFlag (name: keyof UserProjectStatusState['user'], newVal: boolean) {
this.user[name] = newVal
},
setProjectFlag (name: keyof LoginConnectState['project'], newVal: boolean) {
setProjectFlag (name: keyof UserProjectStatusState['project'], newVal: boolean) {
this.project[name] = newVal
},
setLoginError (error: boolean) {
@@ -110,6 +120,9 @@ export const useLoginConnectStore = defineStore({
setCypressFirstOpened (timestamp: number) {
this.cypressFirstOpened = timestamp
},
setTestingType (testingType: 'e2e' | 'component' | undefined) {
this.testingType = testingType
},
setBannersState (banners: BannersState) {
this.bannersState = banners
},
@@ -118,34 +131,38 @@ export const useLoginConnectStore = defineStore({
},
},
getters: {
userStatus (state): UserStatus {
cloudStatus (state): CloudStatus {
const { user, project } = state
let userStatus: UserStatus
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:
userStatus = 'isLoggedOut'
break
return 'isLoggedOut'
case user.isLoggedIn && user.isOrganizationLoaded && !user.isMemberOfOrganization:
userStatus = 'needsOrgConnect'
break
return 'needsOrgConnect'
case user.isLoggedIn && user.isMemberOfOrganization && !project.isProjectConnected && project.isConfigLoaded:
userStatus = 'needsProjectConnect'
break
return 'needsProjectConnect'
case user.isLoggedIn && user.isMemberOfOrganization && project.isProjectConnected && project.hasNoRecordedRuns && project.hasNonExampleSpec && project.isConfigLoaded:
userStatus = 'needsRecordedRun'
break
return 'needsRecordedRun'
default:
userStatus = 'allTasksCompleted'
return 'allTasksCompleted'
}
},
projectStatus (state): ProjectStatus {
const { project } = state
if (state.testingType === 'e2e' && !project.isCTConfigured && project.hasDetectedCtFramework) {
return 'isComponentTestingCandidate'
}
return userStatus
return 'allTasksCompleted'
},
userStatusMatches () {
// casting here since 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
cloudStatusMatches () {
return (status: CloudStatus) => this.cloudStatus === status
},
projectStatusMatches () {
return (status: ProjectStatus) => this.projectStatus === status
},
latestBannerShownTime (state) {
return state._latestBannerShownTimeForTesting
@@ -155,4 +172,4 @@ export const useLoginConnectStore = defineStore({
},
})
export type LoginConnectStore = ReturnType<typeof useLoginConnectStore>
export type UserProjectStatusStore = ReturnType<typeof useUserProjectStatusStore>

View File

@@ -0,0 +1,23 @@
import LogoWebpack from '../assets/logos/webpack.svg'
import LogoVite from '../assets/logos/vite.svg'
import LogoNext from '../assets/logos/nextjs.svg'
import LogoNuxt from '../assets/logos/nuxt.svg'
import LogoVue from '../assets/logos/vue.svg'
import LogoReact from '../assets/logos/react.svg'
import LogoAngular from '../assets/logos/angular.svg'
import LogoSvelte from '../assets/logos/svelte.svg'
export const FrameworkBundlerLogos = {
webpack: LogoWebpack,
vite: LogoVite,
vue2: LogoVue,
vue3: LogoVue,
vueclivue2: LogoVue,
vueclivue3: LogoVue,
nextjs: LogoNext,
nuxtjs: LogoNuxt,
react: LogoReact,
reactscripts: LogoReact,
angular: LogoAngular,
svelte: LogoSvelte,
} as const

View File

@@ -1,6 +1,6 @@
import { isAllowedFeature } from './isAllowedFeature'
import { LoginConnectStore, useLoginConnectStore, userStatuses } from '../store'
import type { UserStatus } from '../store'
import { UserProjectStatusStore, useUserProjectStatusStore, CLOUD_STATUSES, ProjectStatus } from '../store'
import type { CloudStatus } from '../store'
import { BannerIds } from '@packages/types'
import interval from 'human-interval'
@@ -12,14 +12,14 @@ const bannerIds = {
} as const
describe('isAllowedFeature', () => {
let store: LoginConnectStore
let store: UserProjectStatusStore
// this setup function acts as a test of the userStatus
// getter in loginConnectStore, since we set the individual flags here
// getter in userProjectStatusStore, 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
// and provided by userProjectStatusStore.userStatus
const setUpStatus = (status: CloudStatus | ProjectStatus) => {
const { setCypressFirstOpened, setPromptShown, setTestingType, setUserFlag, setProjectFlag } = store
// set a default valid number of days since first open & nav prompt shown
// individual tests may override
@@ -29,12 +29,12 @@ describe('isAllowedFeature', () => {
switch (status) {
case 'isLoggedOut':
setUserFlag('isLoggedIn', false)
expect(store.userStatus).to.eq('isLoggedOut')
expect(store.cloudStatus).to.eq('isLoggedOut')
break
case 'needsOrgConnect':
setUserFlag('isLoggedIn', true)
setUserFlag('isOrganizationLoaded', true)
expect(store.userStatus).to.eq('needsOrgConnect')
expect(store.cloudStatus).to.eq('needsOrgConnect')
break
case 'needsProjectConnect':
setUserFlag('isLoggedIn', true)
@@ -42,7 +42,7 @@ describe('isAllowedFeature', () => {
setUserFlag('isOrganizationLoaded', true)
setProjectFlag('isConfigLoaded', true)
setProjectFlag('isProjectConnected', false)
expect(store.userStatus).to.eq('needsProjectConnect')
expect(store.cloudStatus).to.eq('needsProjectConnect')
break
case 'needsRecordedRun':
setUserFlag('isLoggedIn', true)
@@ -52,7 +52,7 @@ describe('isAllowedFeature', () => {
setProjectFlag('isConfigLoaded', true)
setProjectFlag('hasNonExampleSpec', true)
expect(store.userStatus).to.eq('needsRecordedRun')
expect(store.cloudStatus).to.eq('needsRecordedRun')
break
case 'allTasksCompleted':
setUserFlag('isLoggedIn', true)
@@ -60,7 +60,18 @@ describe('isAllowedFeature', () => {
setProjectFlag('isProjectConnected', true)
setProjectFlag('hasNoRecordedRuns', false)
expect(store.userStatus).to.eq('allTasksCompleted')
expect(store.cloudStatus).to.eq('allTasksCompleted')
break
case 'isComponentTestingCandidate':
setTestingType('e2e')
setUserFlag('isLoggedIn', true)
setUserFlag('isMemberOfOrganization', true)
setProjectFlag('isProjectConnected', true)
setProjectFlag('hasNoRecordedRuns', false)
setProjectFlag('isCTConfigured', false)
setProjectFlag('hasDetectedCtFramework', true)
expect(store.projectStatus).to.eq('isComponentTestingCandidate')
break
default:
return
@@ -68,25 +79,25 @@ describe('isAllowedFeature', () => {
}
beforeEach(() => {
store = useLoginConnectStore()
store = useUserProjectStatusStore()
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) => {
CLOUD_STATUSES.forEach((status) => {
if (status === 'allTasksCompleted') {
it('returns false when user has no actions to take', () => {
setUpStatus('allTasksCompleted')
const result = isAllowedFeature('specsListBanner', store)
const result = isAllowedFeature('specsListBanner', store, store.cloudStatus)
expect(result).to.be.false
})
} else {
it(`returns true for status ${status}`, () => {
setUpStatus(status)
const result = isAllowedFeature('specsListBanner', store)
const result = isAllowedFeature('specsListBanner', store, store.cloudStatus)
expect(result).to.be.true
})
@@ -107,7 +118,7 @@ describe('isAllowedFeature', () => {
},
})
const result = isAllowedFeature('specsListBanner', store)
const result = isAllowedFeature('specsListBanner', store, store.cloudStatus)
expect(result).to.be.false
})
@@ -115,7 +126,7 @@ describe('isAllowedFeature', () => {
})
context('banners have been disabled for testing', () => {
userStatuses.forEach((status) => {
CLOUD_STATUSES.forEach((status) => {
it(`returns false for status ${ status }`, () => {
setUpStatus(status)
@@ -123,7 +134,7 @@ describe('isAllowedFeature', () => {
_disabled: true,
})
const result = isAllowedFeature('specsListBanner', store)
const result = isAllowedFeature('specsListBanner', store, store.cloudStatus)
expect(result).to.be.false
})
@@ -131,12 +142,12 @@ describe('isAllowedFeature', () => {
})
context('cypress was first opened less than 4 days ago', () => {
userStatuses.forEach((status) => {
CLOUD_STATUSES.forEach((status) => {
it(`returns false for status ${status}`, () => {
setUpStatus(status)
store.setCypressFirstOpened(Date.now() - interval('3 days'))
const result = isAllowedFeature('specsListBanner', store)
const result = isAllowedFeature('specsListBanner', store, store.cloudStatus)
expect(result).to.be.false
})
@@ -144,12 +155,12 @@ describe('isAllowedFeature', () => {
})
context('nav CI prompt was shown less than one day ago', () => {
userStatuses.forEach((status) => {
CLOUD_STATUSES.forEach((status) => {
it(`returns false for status ${status}`, () => {
setUpStatus(status)
store.setPromptShown('ci1', Date.now() - interval('23 hours'))
const result = isAllowedFeature('specsListBanner', store)
const result = isAllowedFeature('specsListBanner', store, store.cloudStatus)
expect(result).to.be.false
})
@@ -158,13 +169,13 @@ describe('isAllowedFeature', () => {
})
context('no non-example specs have been written', () => {
userStatuses.forEach((status) => {
CLOUD_STATUSES.forEach((status) => {
if (status === 'allTasksCompleted' || status === 'needsRecordedRun') {
it(`returns false for status ${status}`, () => {
setUpStatus(status)
store.setProjectFlag('hasNonExampleSpec', false)
const result = isAllowedFeature('specsListBanner', store)
const result = isAllowedFeature('specsListBanner', store, store.cloudStatus)
expect(result).to.be.false
})
@@ -173,7 +184,7 @@ describe('isAllowedFeature', () => {
setUpStatus(status)
store.setProjectFlag('hasNonExampleSpec', false)
const result = isAllowedFeature('specsListBanner', store)
const result = isAllowedFeature('specsListBanner', store, store.cloudStatus)
expect(result).to.be.true
})
@@ -184,10 +195,10 @@ describe('isAllowedFeature', () => {
describe('docsCiPrompt', () => {
context('a banner WAS NOT shown in the last day', () => {
userStatuses.forEach((status) => {
CLOUD_STATUSES.forEach((status) => {
it(`returns true with status ${ status } `, () => {
setUpStatus(status)
const result = isAllowedFeature('docsCiPrompt', store)
const result = isAllowedFeature('docsCiPrompt', store, store.cloudStatus)
expect(result).to.be.true
})
@@ -195,11 +206,11 @@ describe('isAllowedFeature', () => {
})
context('a banner WAS shown in the last day', () => {
userStatuses.forEach((status) => {
CLOUD_STATUSES.forEach((status) => {
it(`returns false with status ${ status } `, () => {
setUpStatus(status)
store.setLatestBannerShownTime(Date.now() - interval('23 hours'))
const result = isAllowedFeature('docsCiPrompt', store)
const result = isAllowedFeature('docsCiPrompt', store, store.cloudStatus)
expect(result).to.be.false
})
@@ -207,11 +218,11 @@ describe('isAllowedFeature', () => {
})
context('cypress was first opened less than 4 days ago', () => {
userStatuses.forEach((status) => {
CLOUD_STATUSES.forEach((status) => {
it(`returns false for status ${ status } `, () => {
setUpStatus(status)
store.setCypressFirstOpened(Date.now() - interval('3 days'))
const result = isAllowedFeature('docsCiPrompt', store)
const result = isAllowedFeature('docsCiPrompt', store, store.cloudStatus)
expect(result).to.be.false
})

View File

@@ -1,16 +1,18 @@
import interval from 'human-interval'
import { BannerIds } from '@packages/types'
import type { LoginConnectStore, UserStatus } from '../store'
import type { UserProjectStatusStore, CloudStatus, ProjectStatus } from '../store'
type Feature = 'specsListBanner' | 'docsCiPrompt'
type RulesSet = { base: boolean[] } & Partial<Record<UserStatus, boolean[]>>
type RulesSet = { base: boolean[] } & Partial<Record<CloudStatus | ProjectStatus, boolean[]>>
type Rules = Record<Feature, RulesSet>
type BannerId = typeof BannerIds[keyof typeof BannerIds]
const bannerIds = {
const BANNER_ID_BY_STATE: Partial<Record<CloudStatus | ProjectStatus, BannerId>> = {
isLoggedOut: BannerIds.ACI_082022_LOGIN,
needsOrgConnect: BannerIds.ACI_082022_CREATE_ORG,
needsProjectConnect: BannerIds.ACI_082022_CONNECT_PROJECT,
needsRecordedRun: BannerIds.ACI_082022_RECORD,
isComponentTestingCandidate: BannerIds.CT_052023_AVAILABLE,
}
/**
@@ -33,20 +35,18 @@ const minTimeSinceEvent = (eventTime: number | undefined, waitTime: string) => {
return (Date.now() - eventTime) > waitTimestamp
}
export const IATR_RELEASE = new Date('2023-01-31T00:00:00').getTime()
export const isAllowedFeature = (
featureName: Feature,
loginConnectStore: LoginConnectStore,
userProjectStatusStore: UserProjectStatusStore,
state: CloudStatus | ProjectStatus = 'allTasksCompleted',
) => {
const {
cypressFirstOpened,
promptsShown,
latestBannerShownTime,
bannersState,
userStatus,
project,
} = loginConnectStore
} = userProjectStatusStore
const events = {
cypressFirstOpened,
@@ -56,16 +56,34 @@ export const isAllowedFeature = (
}
function bannerForCurrentStatusWasNotDismissed () {
const bannerId = bannerIds[userStatus]
if (!state) {
return true
}
const bannerId = BANNER_ID_BY_STATE[state]
if (!bannerId) {
return true
}
return !bannersState?.[bannerId]?.dismissed
}
function noOtherSmartBannerShownWithin (interval: string) {
const currentBannerId = BANNER_ID_BY_STATE[state]
return Object.entries(BannerIds)
.map(([_, bannerId]) => bannerId)
.filter((bannerId) => bannerId !== currentBannerId)
.map((bannerId) => bannersState[bannerId]?.dismissed)
.every((bannerDismissed) => minTimeSinceEvent(bannerDismissed, interval))
}
function bannersAreNotDisabledForTesting () {
return !bannersState?._disabled
}
// For each feature, we define an array of rules for every `UserStatus`.
// For each feature, we define an array of rules for every `BannerStatus`.
// The `base` rule is applied to all statuses, additional rules are
// nested in their respective statuses.
const rules: Rules = {
@@ -83,6 +101,9 @@ export const isAllowedFeature = (
needsOrgConnect: [],
needsProjectConnect: [],
isLoggedOut: [],
isComponentTestingCandidate: [
noOtherSmartBannerShownWithin('2 days'),
],
},
docsCiPrompt: {
base: [
@@ -94,6 +115,7 @@ export const isAllowedFeature = (
needsProjectConnect: [],
isLoggedOut: [],
allTasksCompleted: [],
isComponentTestingCandidate: [],
},
}
@@ -102,7 +124,7 @@ export const isAllowedFeature = (
// 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 statusSpecificRules = (state && rules[featureName][state]) ?? [false]
const rulesToCheck = baseRules.concat(statusSpecificRules)

View File

@@ -4,7 +4,10 @@ const cypressCSS = require('@cypress-design/css')
module.exports = {
presets: [cypressCSS.TailwindConfig()],
content: {
files: ['./src/**/*.{vue,js,ts,jsx,tsx,scss,css}'],
files: [
'../../node_modules/@cypress-design/vue-*/dist/*.js',
'./src/**/*.{vue,js,ts,jsx,tsx,scss,css}',
],
extract: ['vue', 'js', 'tsx'].reduce((acc, ext) => {
acc[ext] = cypressCSS.TailwindIconExtractor

View File

@@ -1624,6 +1624,11 @@ type Mutation {
Parse a JS or TS file to see any exported React components that are defined in the file
"""
getReactComponentsFromFile(filePath: String!): ReactComponentResponse
"""
Scan dependencies to determine what, if any, CT frameworks are installed
"""
initializeCtFrameworks: Boolean
internal_clearAllProjectPreferencesCache: Boolean
internal_clearLatestProjectCache: Boolean
internal_clearProjectPreferencesCache(projectTitle: String!): Boolean
@@ -1705,9 +1710,18 @@ type Mutation {
reconfigureProject: Boolean!
"""
Dispatch an event to Cypress Cloud to be recorded. Events are completely anonymous and are only used to identify aggregate usage patterns across all Cypress users.
Dispatch an event to Cypress Cloud to be recorded. Events are used only to derive aggregate usage patterns across all Cypress instances.
"""
recordEvent(campaign: String!, cohort: String, medium: String!, messageId: String!): Boolean
recordEvent(
campaign: String!
cohort: String
includeMachineId: Boolean
medium: String!
messageId: String!
"""(optional) stringified JSON object with supplemental data"""
payload: String
): Boolean
"""
Signal that we are explicitly refetching remote data and should not use the server cache
@@ -1959,6 +1973,11 @@ type Query {
"""local settings on a device-by-device basis"""
localSettings: LocalSettings!
"""
Unique node machine identifier for this instance - may be nil if unable to resolve
"""
machineId: String
"""Metadata about the migration, null if we aren't showing it"""
migration: Migration
node(id: ID!): Node

View File

@@ -709,12 +709,16 @@ export const mutation = mutationType({
t.field('recordEvent', {
type: 'Boolean',
description: 'Dispatch an event to Cypress Cloud to be recorded. Events are completely anonymous and are only used to identify aggregate usage patterns across all Cypress users.',
description: 'Dispatch an event to Cypress Cloud to be recorded. Events are used only to derive aggregate usage patterns across all Cypress instances.',
args: {
includeMachineId: booleanArg({}),
campaign: nonNull(stringArg({})),
messageId: nonNull(stringArg({})),
medium: nonNull(stringArg({})),
cohort: stringArg({}),
payload: stringArg({
description: '(optional) stringified JSON object with supplemental data',
}),
},
resolve: (source, args, ctx) => {
return ctx.actions.eventCollector.recordEvent({
@@ -722,7 +726,8 @@ export const mutation = mutationType({
messageId: args.messageId,
medium: args.medium,
cohort: args.cohort || undefined,
})
payload: (args.payload && JSON.parse(args.payload)) || undefined,
}, args.includeMachineId ?? false)
},
})
@@ -804,5 +809,16 @@ export const mutation = mutationType({
return true
},
})
t.field('initializeCtFrameworks', {
description: 'Scan dependencies to determine what, if any, CT frameworks are installed',
type: 'Boolean',
resolve: async (source, args, ctx) => {
await ctx.actions.wizard.detectFrameworks()
await ctx.actions.wizard.initializeFramework()
return true
},
})
},
})

View File

@@ -141,6 +141,11 @@ export const Query = objectType({
return ctx.graphql.resolveNode(args.id, ctx, info) as any
},
})
t.string('machineId', {
description: 'Unique node machine identifier for this instance - may be nil if unable to resolve',
resolve: async (source, args, ctx) => await ctx.coreData.machineId,
})
},
sourceType: {
module: '@packages/graphql',

View File

@@ -85,7 +85,7 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { FrameworkBundlerLogos } from '../utils/icons'
import { FrameworkBundlerLogos } from '@packages/frontend-shared/src/utils/icons'
import type { Option, FrameworkOption } from './types'
import Select from '@cy/components/Select.vue'
import type {

View File

@@ -1,25 +0,0 @@
import LogoWebpack from '../images/logos/webpack.svg'
import LogoVite from '../images/logos/vite.svg'
import LogoNext from '../images/logos/nextjs.svg'
import LogoNuxt from '../images/logos/nuxt.svg'
import LogoVue from '../images/logos/vue.svg'
import LogoReact from '../images/logos/react.svg'
import LogoAngular from '../images/logos/angular.svg'
import LogoSvelte from '../images/logos/svelte.svg'
import type { SupportedBundlers } from '../generated/graphql'
export const FrameworkBundlerLogos: Record<string | SupportedBundlers, string> = {
webpack: LogoWebpack,
vite: LogoVite,
vue2: LogoVue,
vue3: LogoVue,
vueclivue2: LogoVue,
vueclivue3: LogoVue,
nextjs: LogoNext,
nuxtjs: LogoNuxt,
react: LogoReact,
reactscripts: LogoReact,
angular: LogoAngular,
svelte: LogoSvelte,
}

View File

@@ -1,8 +1,5 @@
const config = require('@packages/frontend-shared/tailwind.config.cjs')
config.content.files = [
'./src/**/*.{vue,js,ts,jsx,tsx,scss,css}', //
'../frontend-shared/src/**/*.{vue,js,ts,jsx,tsx,scss,css}',
],
config.content.files.push('../frontend-shared/src/**/*.{vue,js,ts,jsx,tsx,scss,css}')
module.exports = config

View File

@@ -47,6 +47,7 @@ export const BannerIds = {
ACI_082022_CREATE_ORG: 'aci_082022_createOrganization',
ACI_082022_CONNECT_PROJECT: 'aci_082022_connectProject',
ACI_082022_RECORD: 'aci_082022_record',
CT_052023_AVAILABLE: 'ct_052023_available',
} as const
type BannerKeys = keyof typeof BannerIds

View File

@@ -2222,10 +2222,20 @@
dependencies:
"@jridgewell/trace-mapping" "0.3.9"
"@cypress-design/css@^0.11.0":
version "0.11.0"
resolved "https://registry.yarnpkg.com/@cypress-design/css/-/css-0.11.0.tgz#61d6c0a58dfbce610df5d992d1fe310e4110ac56"
integrity sha512-kZDjTfSTnzvROBYtCTTqf6/s0VBUroXx9O9P8jM5SCjpE221wZa3pFyPt78iCQ00E/AyIt0yUlb5mz22QRRKCQ==
"@cypress-design/constants-button@*":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@cypress-design/constants-button/-/constants-button-0.1.0.tgz#d32788484eda0a05ce72a53d9529ec7284174e77"
integrity sha512-HE5QQT6T8GDSgBDUBywOdnTvRXErrvGHm8YQuvj2iqzCTv9B0RhNQUVKSoyN9/LKyVcIXoa/ZaJLeTMVTlf+pg==
"@cypress-design/constants-statusicon@*":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@cypress-design/constants-statusicon/-/constants-statusicon-0.1.0.tgz#2b4f56bb0838af8635fb655d4136a0a5980c6f9a"
integrity sha512-xp1iGxJAV5Qj+FWcZ4mpQ00UqD5n1syFbnqOvm8WLuRie2lw0hyz5/lncJMMU7mheMyRtM+KU9+VL0+JB8mkLw==
"@cypress-design/css@*", "@cypress-design/css@^0.13.1":
version "0.13.1"
resolved "https://registry.yarnpkg.com/@cypress-design/css/-/css-0.13.1.tgz#6144c38167ed4d514135257b5d942c52c359e8ab"
integrity sha512-wWlDIzk6NL3/a2xBHi5XaJ5P/Y+YVC9r02ZpSueSwZviAklV02bKcKjHTDFd6OdsEKTRwj8bEzmItwFi3sNvpg==
dependencies:
"@windicss/plugin-interaction-variants" "^1.0.0"
lodash "^4.17.21"
@@ -2235,24 +2245,34 @@
windicss "^3.5.6"
windicss-webpack-plugin "^1.7.8"
"@cypress-design/icon-registry@^0.21.0":
version "0.21.0"
resolved "https://registry.yarnpkg.com/@cypress-design/icon-registry/-/icon-registry-0.21.0.tgz#bddb07da4af6ae6e1883056d86809edf8348343b"
integrity sha512-aPB/BjZNGWewf6YX9htgISZ69hIVn/YppNxC4hp1v8I4ZYxkIaOHvd1Q7HR9hxXdLwo8YL1d9DAJPqKGxfYCPg==
"@cypress-design/vue-icon@*", "@cypress-design/vue-icon@0.20.0":
version "0.20.0"
resolved "https://registry.yarnpkg.com/@cypress-design/vue-icon/-/vue-icon-0.20.0.tgz#eaf029a877459524c7ff259c19e88422606d3058"
integrity sha512-Gc7V73hOWDwAaCWRMTCFGQYQ8V6ILb3kJtRcPK8u1UlYcNbfv9/oZoHZpgwm71J9VArHHUa9RBey6cp1BsT+gA==
"@cypress-design/icon-registry@^0.22.0":
version "0.22.0"
resolved "https://registry.yarnpkg.com/@cypress-design/icon-registry/-/icon-registry-0.22.0.tgz#cdaa45c32ebe2d5cb0df6c3220bac735d8c3ee0f"
integrity sha512-VyH7khgBSOJfJCTyMfDoB0YReyiQUtD/VyCEjmkiZOCjPcVF0LEH3lc3zzz+tAXjM+VJtx2TIJi5uOs3mDtRlQ==
dependencies:
"@cypress-design/icon-registry" "^0.21.0"
"@cypress-design/css" "*"
"@cypress-design/vue-statusicon@0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@cypress-design/vue-statusicon/-/vue-statusicon-0.3.0.tgz#f43c42c0053c49b089693f0a1462441f789ffb3c"
integrity sha512-992Onatd2ZTi+tocV7eEgufz1K5deG9lLO3RRryuiy5LNBNmhb/STrWGKopqL4G/L6F9polf0nWMBmzYEVwoWg==
"@cypress-design/vue-button@0.9.2":
version "0.9.2"
resolved "https://registry.yarnpkg.com/@cypress-design/vue-button/-/vue-button-0.9.2.tgz#8445282e9740124f9ff366ae3ebc4dc731757a12"
integrity sha512-Zcs50D/VwwthXF6aqVDz1f/Z1csbu8q5SzGAqRNxlLhgGR1kQS+0WgA2Q+nj4OHfL/Jh6uGTqFZrdNXVMRLtbA==
dependencies:
"@cypress-design/icon-registry" "^0.21.0"
"@cypress-design/constants-button" "*"
"@cypress-design/vue-icon@*", "@cypress-design/vue-icon@0.22.1":
version "0.22.1"
resolved "https://registry.yarnpkg.com/@cypress-design/vue-icon/-/vue-icon-0.22.1.tgz#f415221fae929b94ad75444860e7ee1d317c9dc4"
integrity sha512-SurEkyRHsQjNeCxhcXujOiAr8+puU+fC0w9fqgVgNxPffelvcXOKgGnalk/FFD6bN9T5QCZSA0yNprOzp9PxSw==
dependencies:
"@cypress-design/icon-registry" "^0.22.0"
"@cypress-design/vue-statusicon@0.4.3":
version "0.4.3"
resolved "https://registry.yarnpkg.com/@cypress-design/vue-statusicon/-/vue-statusicon-0.4.3.tgz#39ade5e5e0f3b11337d4e07a91f5302fe257e3fa"
integrity sha512-cTr7BSmhgZt2Ka0+Qa+QsKYcu4TikQ1B+iBGNVGEZFBE+o417mFHLyDLcqE3r9IC3zx8efI4VuKTxJMaYYyXRA==
dependencies:
"@cypress-design/constants-statusicon" "*"
"@cypress-design/icon-registry" "^0.22.0"
"@cypress-design/vue-icon" "*"
"@cypress/commit-info@2.2.0":