diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 59fab58cbd..987778181f 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -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). diff --git a/packages/app/package.json b/packages/app/package.json index 36cc45a7f4..75dfd7ed73 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/composables/useRelevantRun.ts b/packages/app/src/composables/useRelevantRun.ts index 25e4027799..549fbf8acd 100644 --- a/packages/app/src/composables/useRelevantRun.ts +++ b/packages/app/src/composables/useRelevantRun.ts @@ -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 diff --git a/packages/app/src/debug/DebugContainer.cy.tsx b/packages/app/src/debug/DebugContainer.cy.tsx index e4002b096b..3b1511300e 100644 --- a/packages/app/src/debug/DebugContainer.cy.tsx +++ b/packages/app/src/debug/DebugContainer.cy.tsx @@ -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('', () => { } 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('', () => { }) 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('', () => { 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('', () => { }) 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', () => { diff --git a/packages/app/src/debug/DebugContainer.vue b/packages/app/src/debug/DebugContainer.vue index e0ef009841..7b9e722e9d 100644 --- a/packages/app/src/debug/DebugContainer.vue +++ b/packages/app/src/debug/DebugContainer.vue @@ -4,16 +4,16 @@ {{ t('launchpadErrors.noInternet.connectProject') }} - + { return props.gql?.currentProject?.cloudProject?.__typename === 'CloudProject' diff --git a/packages/app/src/debug/empty/DebugEmptyStates.cy.tsx b/packages/app/src/debug/empty/DebugEmptyStates.cy.tsx index e21d6c7ee9..6dd4c6aa62 100644 --- a/packages/app/src/debug/empty/DebugEmptyStates.cy.tsx +++ b/packages/app/src/debug/empty/DebugEmptyStates.cy.tsx @@ -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() @@ -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(, { 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() @@ -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(, { 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(, { 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(, { 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(, { cohort: 'A', debugSlideshowComplete: true }) cy.findByTestId('debug-default-empty-state') diff --git a/packages/app/src/navigation/SidebarNavigation.cy.tsx b/packages/app/src/navigation/SidebarNavigation.cy.tsx index 3da07c8c74..37ceba7806 100644 --- a/packages/app/src/navigation/SidebarNavigation.cy.tsx +++ b/packages/app/src/navigation/SidebarNavigation.cy.tsx @@ -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 }) diff --git a/packages/app/src/navigation/SidebarNavigation.vue b/packages/app/src/navigation/SidebarNavigation.vue index 5d65c657b2..3ca27db5b2 100644 --- a/packages/app/src/navigation/SidebarNavigation.vue +++ b/packages/app/src/navigation/SidebarNavigation.vue @@ -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() @@ -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 diff --git a/packages/app/src/runs/CloudConnectButton.cy.tsx b/packages/app/src/runs/CloudConnectButton.cy.tsx index 35e9c5825e..c4361d619a 100644 --- a/packages/app/src/runs/CloudConnectButton.cy.tsx +++ b/packages/app/src/runs/CloudConnectButton.cy.tsx @@ -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('', { viewportHeight: 60, viewportWidth: 400 }, () => { context('not logged in ', () => { @@ -11,12 +11,12 @@ describe('', { 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('', { 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(() => ) cy.contains('button', 'Connect a Cypress Cloud project').click() @@ -35,7 +35,7 @@ describe('', { 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(() => ) cy.contains('button', 'Connect a Cypress Cloud project').click() diff --git a/packages/app/src/runs/CloudConnectButton.vue b/packages/app/src/runs/CloudConnectButton.vue index 255d8d3899..ab5a3b25f6 100644 --- a/packages/app/src/runs/CloudConnectButton.vue +++ b/packages/app/src/runs/CloudConnectButton.vue @@ -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() diff --git a/packages/app/src/runs/RunsContainer.cy.tsx b/packages/app/src/runs/RunsContainer.cy.tsx index 8dc6c46e1f..18e62d7b66 100644 --- a/packages/app/src/runs/RunsContainer.cy.tsx +++ b/packages/app/src/runs/RunsContainer.cy.tsx @@ -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('', { 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', () => { diff --git a/packages/app/src/runs/RunsContainer.vue b/packages/app/src/runs/RunsContainer.vue index f33bc62609..92ca151d80 100644 --- a/packages/app/src/runs/RunsContainer.vue +++ b/packages/app/src/runs/RunsContainer.vue @@ -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 diff --git a/packages/app/src/runs/RunsErrorRenderer.spec.tsx b/packages/app/src/runs/RunsErrorRenderer.spec.tsx index 45a4d88da2..68dc15d9c3 100644 --- a/packages/app/src/runs/RunsErrorRenderer.spec.tsx +++ b/packages/app/src/runs/RunsErrorRenderer.spec.tsx @@ -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('', () => { }, }) - 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') diff --git a/packages/app/src/runs/RunsErrorRenderer.vue b/packages/app/src/runs/RunsErrorRenderer.vue index ba9dfb1496..c4a14962d3 100644 --- a/packages/app/src/runs/RunsErrorRenderer.vue +++ b/packages/app/src/runs/RunsErrorRenderer.vue @@ -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() diff --git a/packages/app/src/specs/SpecHeaderCloudDataTooltip.cy.tsx b/packages/app/src/specs/SpecHeaderCloudDataTooltip.cy.tsx index f0e3151375..ce27ab2b4a 100644 --- a/packages/app/src/specs/SpecHeaderCloudDataTooltip.cy.tsx +++ b/packages/app/src/specs/SpecHeaderCloudDataTooltip.cy.tsx @@ -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('', () => { docs: string }, ) { - const loginConnectStore = useLoginConnectStore() + const userProjectStatusStore = useUserProjectStatusStore() cy.mountFragment(SpecHeaderCloudDataTooltipFragmentDoc, { onResult: (result) => { @@ -26,22 +26,22 @@ describe('', () => { 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('', () => { 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 } }, diff --git a/packages/app/src/specs/SpecHeaderCloudDataTooltip.vue b/packages/app/src/specs/SpecHeaderCloudDataTooltip.vue index 3166d79a33..0e31231318 100644 --- a/packages/app/src/specs/SpecHeaderCloudDataTooltip.vue +++ b/packages/app/src/specs/SpecHeaderCloudDataTooltip.vue @@ -44,7 +44,7 @@ ', { viewportHeight: 260 }, () => { +describe('', { viewportHeight: 260, defaultCommandTimeout: 1000 }, () => { const validateBaseRender = () => { it('should render as expected', () => { cy.findByTestId(AlertSelector).should('be.visible') @@ -68,9 +69,9 @@ describe('', { viewportHeight: 260 }, () => { const validateSmartNotificationBehaviors = (bannerId: BannerId, bannerTestId: string, gql: Partial) => { 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) => , @@ -80,9 +81,9 @@ describe('', { 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('', { 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('', { viewportHeight: 260 }, () => { />), }) - const bannerTrueUserConditions = { - 'login-banner': [], - 'create-organization-banner': ['isLoggedIn', 'isOrganizationLoaded'], - 'connect-project-banner': ['isLoggedIn', 'isMemberOfOrganization'], - 'record-banner': ['isLoggedIn', 'isMemberOfOrganization'], - } as const + type DeepPartial = { + [K in keyof T]?: T[K] extends object ? DeepPartial : 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> = { + '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('', { 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('', { 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) => }) }) @@ -188,9 +209,9 @@ describe('', { 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) => }) }) @@ -208,9 +229,9 @@ describe('', { 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) => }) }) @@ -234,11 +255,11 @@ describe('', { 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) => }) }) @@ -261,9 +282,9 @@ describe('', { 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('', { viewportHeight: 260 }, () => { hasRequestedAccess: false, }, savedState: {}, + currentTestingType: 'e2e', + config: {}, } }, render: (gql) => , @@ -294,9 +317,9 @@ describe('', { 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('', { viewportHeight: 260 }, () => { hasRequestedAccess: true, }, savedState: {}, + currentTestingType: 'e2e', + config: {}, } }, render: (gql) => , @@ -408,4 +433,87 @@ describe('', { viewportHeight: 260 }, () => { validateSmartNotificationBehaviors(BannerIds.ACI_082022_RECORD, 'record-banner', gql) }) + + describe('component testing', () => { + const gql: Partial = { + 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) => , + }) + }) + + 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') + }) + }) + }) }) diff --git a/packages/app/src/specs/SpecsListBanners.vue b/packages/app/src/specs/SpecsListBanners.vue index 53fc55ef04..222eec9465 100644 --- a/packages/app/src/specs/SpecsListBanners.vue +++ b/packages/app/src/specs/SpecsListBanners.vue @@ -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') }} @@ -112,10 +112,13 @@ @@ -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> @@ -280,11 +295,21 @@ const getCohortForBanner = (bannerId: BannerId): Ref = } 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) + diff --git a/packages/app/src/specs/banners/ComponentTestingAvailableBanner.cy.tsx b/packages/app/src/specs/banners/ComponentTestingAvailableBanner.cy.tsx new file mode 100644 index 0000000000..64253ee0a9 --- /dev/null +++ b/packages/app/src/specs/banners/ComponentTestingAvailableBanner.cy.tsx @@ -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('', { viewportWidth: 1200 }, () => { + it('should render expected content', () => { + cy.mount() + }) + + frameworks.map((framework) => { + it(`should render expected content for ${framework.name}`, () => { + cy.mount( + , + ) + + 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( + , + ) + + 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( + , + ) + + cy.get('@recordEvent').should('not.have.been.called') + }) + + it('should record dismissal event when clicking survey link', () => { + cy.mount( + , + ) + + 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') + }) + }) + }) +}) diff --git a/packages/app/src/specs/banners/ComponentTestingAvailableBanner.vue b/packages/app/src/specs/banners/ComponentTestingAvailableBanner.vue new file mode 100644 index 0000000000..b3b75b72d7 --- /dev/null +++ b/packages/app/src/specs/banners/ComponentTestingAvailableBanner.vue @@ -0,0 +1,145 @@ + + + + + {{ t('specPage.banners.ct.content') }} + + + + {{ t('specPage.banners.ct.primaryAction') }} + + + {{ t('specPage.banners.ct.secondaryAction') }} + + + + {{ t('specPage.banners.ct.dismissAction') }} + + + + + + + diff --git a/packages/app/src/specs/banners/ConnectProjectBanner.vue b/packages/app/src/specs/banners/ConnectProjectBanner.vue index ffa6377d6f..ed8fc0861e 100644 --- a/packages/app/src/specs/banners/ConnectProjectBanner.vue +++ b/packages/app/src/specs/banners/ConnectProjectBanner.vue @@ -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 diff --git a/packages/app/src/specs/banners/LoginBanner.vue b/packages/app/src/specs/banners/LoginBanner.vue index acb6faa1d8..5d0e0853a0 100644 --- a/packages/app/src/specs/banners/LoginBanner.vue +++ b/packages/app/src/specs/banners/LoginBanner.vue @@ -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 diff --git a/packages/app/src/specs/banners/TrackedBanner.vue b/packages/app/src/specs/banners/TrackedBanner.vue index c5bee7a956..7a0d2d8db7 100644 --- a/packages/app/src/specs/banners/TrackedBanner.vue +++ b/packages/app/src/specs/banners/TrackedBanner.vue @@ -3,7 +3,10 @@ v-model="isAlertDisplayed" v-bind="$attrs" > - + @@ -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 { + await updateBannerState('dismissed') +} + diff --git a/packages/app/src/specs/banners/index.ts b/packages/app/src/specs/banners/index.ts index c952e10fdd..1414b9c752 100644 --- a/packages/app/src/specs/banners/index.ts +++ b/packages/app/src/specs/banners/index.ts @@ -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' diff --git a/packages/app/tailwind.config.cjs b/packages/app/tailwind.config.cjs index de778a97ae..8e706b94f7 100644 --- a/packages/app/tailwind.config.cjs +++ b/packages/app/tailwind.config.cjs @@ -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 diff --git a/packages/data-context/src/actions/EventCollectorActions.ts b/packages/data-context/src/actions/EventCollectorActions.ts index 5d9e0e01e0..9f1af8f461 100644 --- a/packages/data-context/src/actions/EventCollectorActions.ts +++ b/packages/data-context/src/actions/EventCollectorActions.ts @@ -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 { + async recordEvent (event: CollectibleEvent, includeMachineId: boolean): Promise { 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) { diff --git a/packages/data-context/src/actions/WizardActions.ts b/packages/data-context/src/actions/WizardActions.ts index 7a3c4aafed..9aa87d96f3 100644 --- a/packages/data-context/src/actions/WizardActions.ts +++ b/packages/data-context/src/actions/WizardActions.ts @@ -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) diff --git a/packages/data-context/src/data/coreDataShape.ts b/packages/data-context/src/data/coreDataShape.ts index 1dd5322525..529311c62f 100644 --- a/packages/data-context/src/data/coreDataShape.ts +++ b/packages/data-context/src/data/coreDataShape.ts @@ -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 | null | undefined @@ -123,6 +124,7 @@ export interface CoreDataShape { cliBrowser: string | null cliTestingType: string | null activeBrowser: FoundBrowser | null + machineId: Promise machineBrowsers: Promise | null allBrowsers: Promise | null servers: { @@ -166,6 +168,7 @@ export function makeCoreData (modeOptions: Partial = {}): 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 = {}): CoreDa testsForRunResults: {}, }, } + + async function machineId (): Promise { + try { + return await getMachineId() + } catch (error) { + return null + } + } } diff --git a/packages/data-context/src/sources/VersionsDataSource.ts b/packages/data-context/src/sources/VersionsDataSource.ts index f2bfcf29d1..15b0c301aa 100644 --- a/packages/data-context/src/sources/VersionsDataSource.ts +++ b/packages/data-context/src/sources/VersionsDataSource.ts @@ -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 { - try { - return await nmi.machineId() - } catch (error) { - return undefined - } - } - private isFulfilled (item: PromiseSettledResult): item is PromiseFulfilledResult { return item.status === 'fulfilled' } diff --git a/packages/data-context/test/unit/actions/EventCollectorActions.spec.ts b/packages/data-context/test/unit/actions/EventCollectorActions.spec.ts index e5fe67b195..0e989ebdcb 100644 --- a/packages/data-context/test/unit/actions/EventCollectorActions.spec.ts +++ b/packages/data-context/test/unit/actions/EventCollectorActions.spec.ts @@ -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) }) diff --git a/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts b/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts index 6828555450..31ae8f2283 100644 --- a/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts +++ b/packages/data-context/test/unit/sources/VersionsDataSource.spec.ts @@ -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({ diff --git a/packages/frontend-shared/package.json b/packages/frontend-shared/package.json index d873f7ce25..07cc6dd6af 100644 --- a/packages/frontend-shared/package.json +++ b/packages/frontend-shared/package.json @@ -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", diff --git a/packages/launchpad/src/images/logos/angular.svg b/packages/frontend-shared/src/assets/logos/angular.svg similarity index 100% rename from packages/launchpad/src/images/logos/angular.svg rename to packages/frontend-shared/src/assets/logos/angular.svg diff --git a/packages/launchpad/src/images/logos/nextjs.svg b/packages/frontend-shared/src/assets/logos/nextjs.svg similarity index 100% rename from packages/launchpad/src/images/logos/nextjs.svg rename to packages/frontend-shared/src/assets/logos/nextjs.svg diff --git a/packages/launchpad/src/images/logos/nuxt.svg b/packages/frontend-shared/src/assets/logos/nuxt.svg similarity index 100% rename from packages/launchpad/src/images/logos/nuxt.svg rename to packages/frontend-shared/src/assets/logos/nuxt.svg diff --git a/packages/launchpad/src/images/logos/react.svg b/packages/frontend-shared/src/assets/logos/react.svg similarity index 100% rename from packages/launchpad/src/images/logos/react.svg rename to packages/frontend-shared/src/assets/logos/react.svg diff --git a/packages/launchpad/src/images/logos/svelte.svg b/packages/frontend-shared/src/assets/logos/svelte.svg similarity index 100% rename from packages/launchpad/src/images/logos/svelte.svg rename to packages/frontend-shared/src/assets/logos/svelte.svg diff --git a/packages/launchpad/src/images/logos/vite.svg b/packages/frontend-shared/src/assets/logos/vite.svg similarity index 100% rename from packages/launchpad/src/images/logos/vite.svg rename to packages/frontend-shared/src/assets/logos/vite.svg diff --git a/packages/launchpad/src/images/logos/vue.svg b/packages/frontend-shared/src/assets/logos/vue.svg similarity index 100% rename from packages/launchpad/src/images/logos/vue.svg rename to packages/frontend-shared/src/assets/logos/vue.svg diff --git a/packages/launchpad/src/images/logos/webpack.svg b/packages/frontend-shared/src/assets/logos/webpack.svg similarity index 100% rename from packages/launchpad/src/images/logos/webpack.svg rename to packages/frontend-shared/src/assets/logos/webpack.svg diff --git a/packages/frontend-shared/src/components/Alert.vue b/packages/frontend-shared/src/components/Alert.vue index 9bd1e6fe8a..75f678035c 100644 --- a/packages/frontend-shared/src/components/Alert.vue +++ b/packages/frontend-shared/src/components/Alert.vue @@ -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 @@ diff --git a/packages/frontend-shared/src/gql-components/HeaderBarContent.cy.tsx b/packages/frontend-shared/src/gql-components/HeaderBarContent.cy.tsx index b4e46bea09..5f5d769fc9 100644 --- a/packages/frontend-shared/src/gql-components/HeaderBarContent.cy.tsx +++ b/packages/frontend-shared/src/gql-components/HeaderBarContent.cy.tsx @@ -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('', { 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('', { viewportWidth: 1000, viewportHeight: 750 }, ( fullName: 'Tester Test', } - loginConnectStore.setUserData(cloudViewer) + userProjectStatusStore.setUserData(cloudViewer) cy.mountFragment(HeaderBar_HeaderBarContentFragmentDoc, { onResult: (result) => { diff --git a/packages/frontend-shared/src/gql-components/HeaderBarContent.vue b/packages/frontend-shared/src/gql-components/HeaderBarContent.vue index a9f91d5b43..90532a9fdd 100644 --- a/packages/frontend-shared/src/gql-components/HeaderBarContent.vue +++ b/packages/frontend-shared/src/gql-components/HeaderBarContent.vue @@ -94,18 +94,18 @@ @clear-force-open="isForceOpenAllowed = false" > {{ t('topNav.login.profileMenuLabel') }} - {{ loginConnectStore.userData?.fullName }} + {{ userProjectStatusStore.userData?.fullName }} - {{ loginConnectStore.userData?.email }} + {{ userProjectStatusStore.userData?.email }} - + @@ -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) diff --git a/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.cy.tsx b/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.cy.tsx index d828523e25..99ec8f3f21 100644 --- a/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.cy.tsx +++ b/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.cy.tsx @@ -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('', () => { 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('', () => { 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) diff --git a/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.vue b/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.vue index 3626b8f4df..ed0f470aa3 100644 --- a/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.vue +++ b/packages/frontend-shared/src/gql-components/LoginConnectModalsContent.vue @@ -1,25 +1,25 @@ - + @@ -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 diff --git a/packages/frontend-shared/src/gql-components/composables/usePromptManager.ts b/packages/frontend-shared/src/gql-components/composables/usePromptManager.ts index e641490c1b..e0e1acefa0 100644 --- a/packages/frontend-shared/src/gql-components/composables/usePromptManager.ts +++ b/packages/frontend-shared/src/gql-components/composables/usePromptManager.ts @@ -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[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, } } diff --git a/packages/frontend-shared/src/gql-components/modals/LoginModal.cy.tsx b/packages/frontend-shared/src/gql-components/modals/LoginModal.cy.tsx index 2d6caad6eb..88176712e7 100644 --- a/packages/frontend-shared/src/gql-components/modals/LoginModal.cy.tsx +++ b/packages/frontend-shared/src/gql-components/modals/LoginModal.cy.tsx @@ -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, { diff --git a/packages/frontend-shared/src/locales/en-US.json b/packages/frontend-shared/src/locales/en-US.json index 3c6c3d4283..c03a7d5b86 100644 --- a/packages/frontend-shared/src/locales/en-US.json +++ b/packages/frontend-shared/src/locales/en-US.json @@ -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" diff --git a/packages/frontend-shared/src/store/index.ts b/packages/frontend-shared/src/store/index.ts index aafb566e1c..9ea519d078 100644 --- a/packages/frontend-shared/src/store/index.ts +++ b/packages/frontend-shared/src/store/index.ts @@ -1,4 +1,4 @@ -export * from './login-connect-store' +export * from './user-project-status-store' import { createPinia as _createPinia } from 'pinia' diff --git a/packages/frontend-shared/src/store/login-connect-store.ts b/packages/frontend-shared/src/store/user-project-status-store.ts similarity index 68% rename from packages/frontend-shared/src/store/login-connect-store.ts rename to packages/frontend-shared/src/store/user-project-status-store.ts index 91a1a7f904..28e798470c 100644 --- a/packages/frontend-shared/src/store/login-connect-store.ts +++ b/packages/frontend-shared/src/store/user-project-status-store.ts @@ -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 +export type UserProjectStatusStore = ReturnType diff --git a/packages/frontend-shared/src/utils/icons.ts b/packages/frontend-shared/src/utils/icons.ts new file mode 100644 index 0000000000..8b03e2aeaf --- /dev/null +++ b/packages/frontend-shared/src/utils/icons.ts @@ -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 diff --git a/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts b/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts index 08bbf631ee..d203adaa25 100644 --- a/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts +++ b/packages/frontend-shared/src/utils/isAllowedFeature.cy.ts @@ -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 }) diff --git a/packages/frontend-shared/src/utils/isAllowedFeature.ts b/packages/frontend-shared/src/utils/isAllowedFeature.ts index ba5c316fff..6b47cfa0f3 100644 --- a/packages/frontend-shared/src/utils/isAllowedFeature.ts +++ b/packages/frontend-shared/src/utils/isAllowedFeature.ts @@ -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> +type RulesSet = { base: boolean[] } & Partial> type Rules = Record +type BannerId = typeof BannerIds[keyof typeof BannerIds] -const bannerIds = { +const BANNER_ID_BY_STATE: Partial> = { 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) diff --git a/packages/frontend-shared/tailwind.config.cjs b/packages/frontend-shared/tailwind.config.cjs index 49e2d312ff..f783b82dda 100644 --- a/packages/frontend-shared/tailwind.config.cjs +++ b/packages/frontend-shared/tailwind.config.cjs @@ -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 diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index e7724d8b91..cabc2e6886 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -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 diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts index 058f9e01e1..92a919b3b3 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts @@ -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 + }, + }) }, }) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts index b19ff1ee70..58a3156d3a 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts @@ -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', diff --git a/packages/launchpad/src/setup/SelectFrameworkOrBundler.vue b/packages/launchpad/src/setup/SelectFrameworkOrBundler.vue index d71e1d0120..3a92dea293 100644 --- a/packages/launchpad/src/setup/SelectFrameworkOrBundler.vue +++ b/packages/launchpad/src/setup/SelectFrameworkOrBundler.vue @@ -85,7 +85,7 @@
+ {{ t('specPage.banners.ct.content') }} +