Merge branch 'develop' into add_to_triage_project_workflow

This commit is contained in:
Matt Schile
2022-08-16 16:12:43 -06:00
committed by GitHub
29 changed files with 886 additions and 32 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cypress",
"version": "10.5.0",
"version": "10.6.0",
"description": "Cypress.io end to end testing tool",
"private": true,
"scripts": {
@@ -1,3 +1,5 @@
/// <reference path="../../../../frontend-shared/cypress/support/component.ts" />
import '../../../../frontend-shared/cypress/support/component.ts'
import { registerMountFn } from '@packages/frontend-shared/cypress/support/common'
// ***********************************************************
// This example support/index.ts is processed and
@@ -14,14 +14,7 @@ describe('App: Spec List - Flaky Indicator', () => {
cy.remoteGraphQLIntercept(async (obj) => {
await new Promise((r) => setTimeout(r, 20))
if (obj.result.data && 'cloudProjectBySlug' in obj.result.data) {
obj.result.data.cloudProjectBySlug = {
__typename: 'CloudProject',
retrievedAt: new Date().toISOString(),
id: `id${obj.variables.slug}`,
projectId: 'abc123',
}
} else if (obj.result.data && 'cloudSpecByPath' in obj.result.data) {
if (obj.result.data && 'cloudSpecByPath' in obj.result.data) {
if (obj.variables.specPath.includes('123.spec.js')) {
obj.result.data.cloudSpecByPath = {
__typename: 'CloudProjectSpec',
@@ -314,7 +314,7 @@ describe('App/Cloud Integration - Latest runs and Average duration', { viewportW
cy.get(averageDurationSelector('accounts_new.spec.js')).contains('2:03')
})
it('lazily loads data for off-screen specs', () => {
it('lazily loads data for off-screen specs', { viewportHeight: 500 }, () => {
// make sure the virtualized list didn't load z008.spec.js
cy.get(specRowSelector('z008.spec.js')).should('not.exist')
+2
View File
@@ -655,6 +655,7 @@ describe('Growth Prompts Can Open Automatically', () => {
firstOpened: 1609459200000,
lastOpened: 1609459200000,
promptsShown: {},
banners: { _disabled: true },
})
},
)
@@ -672,6 +673,7 @@ describe('Growth Prompts Can Open Automatically', () => {
firstOpened: 1609459200000,
lastOpened: 1609459200000,
promptsShown: { ci1: 1609459200000 },
banners: { _disabled: true },
})
},
)
+166 -1
View File
@@ -1,11 +1,18 @@
import SpecsListBanners from './SpecsListBanners.vue'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { SpecsListBannersFragmentDoc } from '../generated/graphql-test'
import { SpecsListBannersFragment, SpecsListBannersFragmentDoc } from '../generated/graphql-test'
import interval from 'human-interval'
import { CloudUserStubs, CloudProjectStubs } from '@packages/graphql/test/stubCloudTypes'
import { AllowedState, BannerIds } from '@packages/types'
import { assignIn, set } from 'lodash'
const AlertSelector = 'alert-header'
const AlertCloseBtnSelector = 'alert-suffix-icon'
type BannerKey = keyof typeof BannerIds
type BannerId = typeof BannerIds[BannerKey]
describe('<SpecsListBanners />', () => {
const validateBaseRender = () => {
it('should render as expected', () => {
@@ -48,6 +55,75 @@ describe('<SpecsListBanners />', () => {
})
}
const stateWithFirstOpenedDaysAgo = (days: number) => {
return {
firstOpened: Date.now() - interval(`${days} days`),
}
}
const mountWithState = (query: Partial<SpecsListBannersFragment>, state?: Partial<AllowedState>, props?: object) => {
cy.mountFragment(SpecsListBannersFragmentDoc, {
onResult: (result) => {
assignIn(result, query)
set(result, 'currentProject.savedState', state)
},
render: (gql) => <SpecsListBanners gql={gql} {...props} />,
})
}
const validateSmartNotificationBehaviors = (bannerId: BannerId, bannerTestId: string, gql: Partial<SpecsListBannersFragment>) => {
it('should not render when using cypress < 4 days', () => {
mountWithState(gql, stateWithFirstOpenedDaysAgo(3))
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
})
it('should not render when previously-dismissed', () => {
mountWithState(gql, {
...stateWithFirstOpenedDaysAgo(4),
banners: {
[bannerId]: {
dismissed: Date.now(),
},
},
})
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
})
context('banner conditions are met and when cypress use >= 4 days', () => {
it('should render when not previously-dismissed', () => {
mountWithState(gql, stateWithFirstOpenedDaysAgo(4))
cy.get(`[data-cy="${bannerTestId}"]`).should('be.visible')
})
it('should be preempted by spec not found banner', () => {
mountWithState(gql, stateWithFirstOpenedDaysAgo(4), { isSpecNotFound: true })
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
})
it('should be preempted by offline warning banner', () => {
mountWithState(gql, stateWithFirstOpenedDaysAgo(4), { isOffline: true })
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
})
it('should be preempted by fetch error banner', () => {
mountWithState(gql, stateWithFirstOpenedDaysAgo(4), { isFetchError: true })
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
})
it('should be preempted by project not found banner', () => {
mountWithState(gql, stateWithFirstOpenedDaysAgo(4), { isProjectNotFound: true })
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
})
it('should be preempted by request access banner', () => {
mountWithState(gql, stateWithFirstOpenedDaysAgo(4), { isProjectUnauthorized: true })
cy.get(`[data-cy="${bannerTestId}"]`).should('not.exist')
})
})
}
describe('spec not found', () => {
const visible: any = ref(true)
@@ -141,6 +217,7 @@ describe('<SpecsListBanners />', () => {
message: 'test',
hasRequestedAccess: false,
},
savedState: {},
}
},
render: (gql) => <SpecsListBanners gql={gql} isProjectUnauthorized={visible} />,
@@ -170,6 +247,7 @@ describe('<SpecsListBanners />', () => {
message: 'test',
hasRequestedAccess: true,
},
savedState: {},
}
},
render: (gql) => <SpecsListBanners gql={gql} isProjectUnauthorized={visible} hasRequestedAccess />,
@@ -181,4 +259,91 @@ describe('<SpecsListBanners />', () => {
validateCloseOnPropChange(visible)
validateReopenOnPropChange(visible)
})
describe('login', () => {
const gql: Partial<SpecsListBannersFragment> = {
cloudViewer: null,
cachedUser: null,
currentProject: {
__typename: 'CurrentProject',
id: 'abc123',
} as any,
}
validateSmartNotificationBehaviors(BannerIds.ACI_082022_LOGIN, 'login-banner', gql)
})
describe('create organization', () => {
const gql: Partial<SpecsListBannersFragment> = {
cloudViewer: {
...CloudUserStubs.me,
firstOrganization: {
__typename: 'CloudOrganizationConnection',
nodes: [],
},
},
currentProject: {
__typename: 'CurrentProject',
id: 'abc123',
} as any,
}
beforeEach(() => {
cy.gqlStub.Query.cloudViewer = gql.cloudViewer as any
})
validateSmartNotificationBehaviors(BannerIds.ACI_082022_CREATE_ORG, 'create-organization-banner', gql)
})
describe('connect project', () => {
const gql: Partial<SpecsListBannersFragment> = {
cloudViewer: {
...CloudUserStubs.me,
firstOrganization: {
__typename: 'CloudOrganizationConnection',
nodes: [{ __typename: 'CloudOrganization', id: '987' }],
},
},
currentProject: {
__typename: 'CurrentProject',
id: 'abc123',
projectId: null,
} as any,
}
validateSmartNotificationBehaviors(BannerIds.ACI_082022_CONNECT_PROJECT, 'connect-project-banner', gql)
})
describe('record', () => {
const gql: Partial<SpecsListBannersFragment> = {
cloudViewer: {
...CloudUserStubs.me,
firstOrganization: {
__typename: 'CloudOrganizationConnection',
nodes: [{ __typename: 'CloudOrganization', id: '987' }],
},
},
currentProject: {
__typename: 'CurrentProject',
id: 'abc123',
title: 'my-test-project',
currentTestingType: 'component',
projectId: 'abcd',
cloudProject: {
...CloudProjectStubs.componentProject,
runs: {
__typename: 'CloudRunConnection',
nodes: [],
},
},
} as any,
}
beforeEach(() => {
cy.gqlStub.Query.currentProject = gql.currentProject as any
cy.gqlStub.Query.cloudViewer = gql.cloudViewer as any
})
validateSmartNotificationBehaviors(BannerIds.ACI_082022_RECORD, 'record-banner', gql)
})
})
+96 -7
View File
@@ -16,7 +16,7 @@
<p>{{ t('specPage.noSpecError.explainer') }}</p>
</Alert>
<Alert
v-if="showOffline"
v-else-if="showOffline"
v-model="showOffline"
data-cy="offline-alert"
status="warning"
@@ -30,7 +30,7 @@
</p>
</Alert>
<Alert
v-if="showFetchError"
v-else-if="showFetchError"
v-model="showFetchError"
status="warning"
:title="t('specPage.fetchFailedWarning.title')"
@@ -64,7 +64,7 @@
</Button>
</Alert>
<Alert
v-if="showProjectNotFound"
v-else-if="showProjectNotFound"
v-model="showProjectNotFound"
data-cy="project-not-found-alert"
status="warning"
@@ -96,7 +96,7 @@
</Button>
</Alert>
<Alert
v-if="showProjectRequestAccess"
v-else-if="showProjectRequestAccess"
v-model="showProjectRequestAccess"
data-cy="project-request-access-alert"
status="warning"
@@ -110,6 +110,22 @@
</p>
<RequestAccessButton :gql="props.gql" />
</Alert>
<RecordBanner
v-else-if="showRecordBanner"
v-model="showRecordBanner"
/>
<ConnectProjectBanner
v-else-if="showConnectBanner"
v-model="showConnectBanner"
/>
<CreateOrganizationBanner
v-else-if="showCreateOrganizationBanner"
v-model="showCreateOrganizationBanner"
/>
<LoginBanner
v-else-if="showLoginBanner"
v-model="showLoginBanner"
/>
</template>
<script setup lang="ts">
@@ -123,10 +139,13 @@ import ConnectIcon from '~icons/cy/chain-link_x16.svg'
import WarningIcon from '~icons/cy/warning_x16.svg'
import RefreshIcon from '~icons/cy/action-restart_x16'
import { useRoute } from 'vue-router'
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import RequestAccessButton from './RequestAccessButton.vue'
import { gql } from '@urql/vue'
import type { SpecsListBannersFragment } from '../generated/graphql'
import { gql, useSubscription } from '@urql/vue'
import { SpecsListBannersFragment, SpecsListBanners_CheckCloudOrgMembershipDocument } from '../generated/graphql'
import interval from 'human-interval'
import { AllowedState, BannerIds } from '@packages/types'
import { LoginBanner, CreateOrganizationBanner, ConnectProjectBanner, RecordBanner } from './banners'
const route = useRoute()
const { t } = useI18n()
@@ -134,6 +153,41 @@ const { t } = useI18n()
gql`
fragment SpecsListBanners on Query {
...RequestAccessButton
cloudViewer {
id
firstOrganization: organizations(first: 1) {
nodes {
id
}
}
}
cachedUser {
id
}
currentProject {
id
projectId
savedState
cloudProject {
__typename
... on CloudProject {
id
runs(first: 1) {
nodes {
id
}
}
}
}
}
}
`
gql`
subscription SpecsListBanners_CheckCloudOrgMembership {
cloudViewerChange {
...SpecsListBanners
}
}
`
@@ -158,11 +212,17 @@ const emit = defineEmits<{
(e: 'reconnectProject'): void
}>()
useSubscription({ query: SpecsListBanners_CheckCloudOrgMembershipDocument })
const showSpecNotFound = ref(props.isSpecNotFound)
const showOffline = ref(props.isOffline)
const showFetchError = ref(props.isFetchError)
const showProjectNotFound = ref(props.isProjectNotFound)
const showProjectRequestAccess = ref(props.isProjectUnauthorized)
const showRecordBanner = ref(false)
const showConnectBanner = ref(false)
const showCreateOrganizationBanner = ref(false)
const showLoginBanner = ref(false)
watch(
() => ([props.isSpecNotFound, props.isOffline, props.isFetchError, props.isProjectNotFound, props.isProjectUnauthorized]),
@@ -175,4 +235,33 @@ watch(
},
)
const cloudData = computed(() => ([props.gql.cloudViewer, props.gql.cachedUser, props.gql.currentProject] as const))
watch(
cloudData,
([cloudViewer, cachedUser, currentProject]) => {
// Cached user covers state where we're authenticated but data isn't loaded yet
const isLoggedIn = !!cachedUser?.id || !!cloudViewer?.id
// Need to be able to tell whether the lack of `firstOrganization` means they don't have an org or whether it just hasn't loaded yet
// Not having this check can cause a brief flicker of the 'Create Org' banner while org data is loading
const isOrganizationLoaded = !!cloudViewer?.firstOrganization
const isMemberOfOrganization = (cloudViewer?.firstOrganization?.nodes?.length ?? 0) > 0
const isProjectConnected = !!currentProject?.projectId && currentProject.cloudProject?.__typename === 'CloudProject'
const hasNoRecordedRuns = currentProject?.cloudProject?.__typename === 'CloudProject' && (currentProject.cloudProject?.runs?.nodes?.length ?? 0) === 0
const hasFourDaysOfCypressUse = (Date.now() - currentProject?.savedState?.firstOpened) > interval('4 days')
showRecordBanner.value = !hasBannerBeenDismissed(BannerIds.ACI_082022_RECORD) && isLoggedIn && isProjectConnected && isMemberOfOrganization && isProjectConnected && hasNoRecordedRuns && hasFourDaysOfCypressUse
showConnectBanner.value = !hasBannerBeenDismissed(BannerIds.ACI_082022_CONNECT_PROJECT) && isLoggedIn && isMemberOfOrganization && !isProjectConnected && hasFourDaysOfCypressUse
showCreateOrganizationBanner.value = !hasBannerBeenDismissed(BannerIds.ACI_082022_CREATE_ORG) && isLoggedIn && isOrganizationLoaded && !isMemberOfOrganization && hasFourDaysOfCypressUse
showLoginBanner.value = !hasBannerBeenDismissed(BannerIds.ACI_082022_LOGIN) && !isLoggedIn && hasFourDaysOfCypressUse
},
{ immediate: true },
)
function hasBannerBeenDismissed (bannerId: string) {
const bannersState = (props.gql.currentProject?.savedState as AllowedState)?.banners
return !!bannersState?._disabled || !!bannersState?.[bannerId]?.dismissed
}
</script>
@@ -0,0 +1,14 @@
import { defaultMessages } from '@cy/i18n'
import ConnectProjectBanner from './ConnectProjectBanner.vue'
describe('<ConnectProjectBanner />', () => {
it('should render expected content', () => {
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} /> })
cy.contains(defaultMessages.specPage.banners.connectProject.title).should('be.visible')
cy.contains(defaultMessages.specPage.banners.connectProject.content).should('be.visible')
cy.contains(defaultMessages.specPage.banners.connectProject.buttonLabel).should('be.visible')
cy.percySnapshot()
})
})
@@ -0,0 +1,78 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_CONNECT_PROJECT"
:model-value="modelValue"
data-cy="connect-project-banner"
status="info"
:title="t('specPage.banners.connectProject.title')"
class="mb-16px"
:icon="ConnectIcon"
dismissible
@update:model-value="value => emit('update:modelValue', value)"
>
<p class="mb-24px">
{{ t('specPage.banners.connectProject.content') }}
</p>
<Button
:prefix-icon="ConnectIcon"
class="mt-24px"
data-cy="connect-project-button"
@click="handleButtonClick"
>
{{ t('specPage.banners.connectProject.buttonLabel') }}
</Button>
<CloudConnectModals
v-if="isProjectConnectOpen && cloudModalsQuery.data.value"
:gql="cloudModalsQuery.data.value"
@cancel="handleModalClose"
@success="handleModalClose"
/>
</TrackedBanner>
</template>
<script setup lang="ts">
import { gql, useQuery } from '@urql/vue'
import ConnectIcon from '~icons/cy/chain-link_x16.svg'
import { useI18n } from '@cy/i18n'
import Button from '@cy/components/Button.vue'
import TrackedBanner from './TrackedBanner.vue'
import { BannerIds } from '@packages/types'
import { ref } from 'vue'
import { ConnectProjectBannerDocument } from '../../generated/graphql'
import CloudConnectModals from '../../runs/modals/CloudConnectModals.vue'
gql`
query ConnectProjectBanner {
...CloudConnectModals
}
`
withDefaults(defineProps<{
modelValue: boolean
}>(), {
modelValue: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const isProjectConnectOpen = ref(false)
const cloudModalsQuery = useQuery({ query: ConnectProjectBannerDocument, pause: true })
async function handleButtonClick () {
await cloudModalsQuery.executeQuery()
isProjectConnectOpen.value = true
}
function handleModalClose () {
isProjectConnectOpen.value = false
emit('update:modelValue', false)
}
</script>
@@ -0,0 +1,26 @@
import { defaultMessages } from '@cy/i18n'
import CreateOrganizationBanner from './CreateOrganizationBanner.vue'
describe('<CreateOrganizationBanner />', () => {
it('should render expected content', () => {
const linkHref = 'http://dummy.cypress.io/organizations/create'
cy.gqlStub.Query.cloudViewer = {
__typename: 'CloudUser',
id: 'test123',
cloudOrganizationsUrl: linkHref,
} as any
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} /> })
cy.contains(defaultMessages.specPage.banners.createOrganization.title).should('be.visible')
cy.contains(defaultMessages.specPage.banners.createOrganization.content).should('be.visible')
cy.contains(defaultMessages.specPage.banners.createOrganization.buttonLabel).should('be.visible')
cy.get('a')
.should('have.attr', 'href')
.and('contain', linkHref)
cy.percySnapshot()
})
})
@@ -0,0 +1,79 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_CREATE_ORG"
:model-value="modelValue"
data-cy="create-organization-banner"
status="info"
:title="t('specPage.banners.createOrganization.title')"
class="mb-16px"
:icon="OrganizationIcon"
dismissible
@update:model-value="value => emit('update:modelValue', value)"
>
<p class="mb-24px">
{{ t('specPage.banners.createOrganization.content') }}
</p>
<Button
:href="createOrganizationUrl"
:include-graphql-port="true"
data-cy="create-organization-button"
:prefix-icon="OrganizationIcon"
prefix-icon-class="icon-dark-white icon-light-indigo-500"
>
{{ t('specPage.banners.createOrganization.buttonLabel') }}
</Button>
</TrackedBanner>
</template>
<script setup lang="ts">
import OrganizationIcon from '~icons/cy/office-building_x16.svg'
import { useI18n } from '@cy/i18n'
import TrackedBanner from './TrackedBanner.vue'
import { BannerIds } from '@packages/types'
import { CreateOrganizationBannerDocument } from '../../generated/graphql'
import { gql, useQuery } from '@urql/vue'
import { getUrlWithParams } from '@packages/frontend-shared/src/utils/getUrlWithParams'
import { computed } from 'vue'
import Button from '@packages/frontend-shared/src/components/Button.vue'
gql`
query CreateOrganizationBanner {
cloudViewer {
id
createCloudOrganizationUrl
}
}
`
withDefaults(defineProps<{
modelValue: boolean
}>(), {
modelValue: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const query = useQuery({ query: CreateOrganizationBannerDocument })
const createOrganizationUrl = computed(() => {
const baseUrl = query.data?.value?.cloudViewer?.createCloudOrganizationUrl
if (!baseUrl) {
return ''
}
return getUrlWithParams({
url: baseUrl,
params: {
utm_medium: 'Specs Create Organization Banner',
utm_campaign: 'Set up your organization',
},
})
})
</script>
@@ -0,0 +1,14 @@
import { defaultMessages } from '@cy/i18n'
import LoginBanner from './LoginBanner.vue'
describe('<LoginBanner />', () => {
it('should render expected content', () => {
cy.mount({ render: () => <LoginBanner modelValue={true} /> })
cy.contains(defaultMessages.specPage.banners.login.title).should('be.visible')
cy.contains(defaultMessages.specPage.banners.login.content).should('be.visible')
cy.contains(defaultMessages.specPage.banners.login.buttonLabel).should('be.visible')
cy.percySnapshot()
})
})
@@ -0,0 +1,70 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_LOGIN"
:model-value="modelValue"
data-cy="login-banner"
status="info"
:title="t('specPage.banners.login.title')"
class="mb-16px"
:icon="ConnectIcon"
dismissible
@update:model-value="value => emit('update:modelValue', value)"
>
<p class="mb-24px">
{{ t('specPage.banners.login.content') }}
</p>
<Button
:prefix-icon="ConnectIcon"
class="mt-24px"
data-cy="login-button"
@click="handleButtonClick"
>
{{ t('specPage.banners.login.buttonLabel') }}
</Button>
<LoginModal
v-if="loginModalQuery.data.value"
v-model="isLoginOpen"
:gql="loginModalQuery.data.value"
utm-medium="Specs Login Banner"
/>
</TrackedBanner>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { gql, useQuery } from '@urql/vue'
import ConnectIcon from '~icons/cy/chain-link_x16.svg'
import { useI18n } from '@cy/i18n'
import Button from '@cy/components/Button.vue'
import { LoginBannerDocument } from '../../generated/graphql'
import TrackedBanner from './TrackedBanner.vue'
import { BannerIds } from '@packages/types'
import LoginModal from '@cy/gql-components/topnav/LoginModal.vue'
gql`
query LoginBanner {
...LoginModal
}
`
withDefaults(defineProps<{
modelValue: boolean
}>(), {
modelValue: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const isLoginOpen = ref(false)
const loginModalQuery = useQuery({ query: LoginBannerDocument, pause: true })
async function handleButtonClick () {
await loginModalQuery.executeQuery()
isLoginOpen.value = true
}
</script>
@@ -0,0 +1,30 @@
import { defaultMessages } from '@cy/i18n'
import RecordBanner from './RecordBanner.vue'
describe('<RecordBanner />', () => {
it('should render expected content', () => {
cy.mount({ render: () => <RecordBanner modelValue={true} /> })
cy.gqlStub.Query.currentProject = {
id: 'test_id',
title: 'project_title',
currentTestingType: 'component',
cloudProject: {
__typename: 'CloudProject',
id: 'cloud_id',
recordKeys: [{
__typename: 'CloudRecordKey',
id: 'recordKey1',
key: 'abcd-efg-1234',
}],
} as any,
} as any
cy.contains(defaultMessages.specPage.banners.record.title).should('be.visible')
cy.contains(defaultMessages.specPage.banners.record.content).should('be.visible')
cy.findByText('cypress run --component --record --key abcd-efg-1234')
cy.percySnapshot()
})
})
@@ -0,0 +1,78 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_RECORD"
:model-value="modelValue"
data-cy="record-banner"
status="info"
:title="t('specPage.banners.record.title')"
class="mb-16px"
:icon="RecordIcon"
dismissible
@update:model-value="value => emit('update:modelValue', value)"
>
<p class="mb-24px">
{{ t('specPage.banners.record.content') }}
</p>
<TerminalPrompt
:command="recordCommand"
:project-folder-name="query.data?.value?.currentProject?.title"
class="bg-white max-w-900px"
/>
</TrackedBanner>
</template>
<script setup lang="ts">
import { gql, useQuery } from '@urql/vue'
import RecordIcon from '~icons/cy/action-record_x16.svg'
import { useI18n } from '@cy/i18n'
import TerminalPrompt from '@cy/components/TerminalPrompt.vue'
import TrackedBanner from './TrackedBanner.vue'
import { BannerIds } from '@packages/types'
import { RecordBannerDocument } from '../../generated/graphql'
import { computed } from 'vue'
gql`
query RecordBanner {
currentProject {
id
title
currentTestingType
cloudProject {
__typename
... on CloudProject {
id
recordKeys {
id
key
}
}
}
}
}
`
withDefaults(defineProps<{
modelValue: boolean
}>(), {
modelValue: false,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const query = useQuery({ query: RecordBannerDocument })
const firstRecordKey = computed(() => {
return (query.data?.value?.currentProject?.cloudProject?.__typename === 'CloudProject' && query.data.value.currentProject.cloudProject.recordKeys?.[0]?.key) ?? '<record-key>'
})
const recordCommand = computed(() => {
const componentFlagOrSpace = query.data?.value?.currentProject?.currentTestingType === 'component' ? ' --component ' : ' '
return `cypress run${componentFlagOrSpace}--record --key ${firstRecordKey.value}`
})
</script>
@@ -0,0 +1,64 @@
import TrackedBanner from './TrackedBanner.vue'
import { ref } from 'vue'
import { TrackedBanner_SetProjectStateDocument } from '../../generated/graphql'
describe('<TrackedBanner />', () => {
it('should pass through props and child content', () => {
cy.mount({ render: () => <TrackedBanner bannerId="test-banner" dismissible modelValue={true}>Test Content</TrackedBanner> })
cy.findByText('Test Content').should('be.visible')
cy.findByTestId('alert-suffix-icon').should('be.visible')
cy.percySnapshot()
})
it('should record when banner is made visible', () => {
cy.clock(1234)
const recordStub = cy.stub()
const shown = ref(true)
cy.stubMutationResolver(TrackedBanner_SetProjectStateDocument, (defineResult, { value }) => {
recordStub(value)
return defineResult({ setPreferences: {} as any })
})
// Initially mount as visible
// @ts-ignore
cy.mount({ render: () => <TrackedBanner data-cy="banner" bannerId="test-banner" v-model={shown.value} /> })
cy.get('[data-cy="banner"]').as('banner')
cy.get('@banner').should('be.visible')
.then(() => {
expect(recordStub).to.have.been.calledWith('{"banners":{"test-banner":{"lastShown":1234}}}')
})
})
it('should record when banner is dismissed', () => {
cy.clock(1234)
const recordStub = cy.stub()
const shown = ref(true)
cy.stubMutationResolver(TrackedBanner_SetProjectStateDocument, (defineResult, { value }) => {
recordStub(value)
return defineResult({ setPreferences: {} as any })
})
// Initially mount as visible
// @ts-ignore
cy.mount({ render: () => <TrackedBanner data-cy="banner" bannerId="test-banner" v-model={shown.value} dismissible /> })
cy.get('[data-cy="banner"]').as('banner')
cy.get('@banner').should('be.visible')
cy.get('@banner').findByTestId('alert-suffix-icon').click()
cy.get('@banner').should('not.exist')
.then(() => {
expect(recordStub).to.have.been.calledWith('{"banners":{"test-banner":{"dismissed":1234}}}')
})
})
})
@@ -0,0 +1,75 @@
<template>
<Alert
v-bind="$attrs"
:model-value="modelValue"
@update:model-value="handleBannerDismissed"
>
<slot />
</Alert>
</template>
<script setup lang="ts">
import Alert from '@packages/frontend-shared/src/components/Alert.vue'
import { watchEffect } from 'vue'
import { gql, useMutation, useQuery } from '@urql/vue'
import { TrackedBanner_ProjectStateDocument, TrackedBanner_SetProjectStateDocument } from '../../generated/graphql'
import { set } from 'lodash'
type AlertComponentProps = InstanceType<typeof Alert>['$props']
type AlertComponentEmits = InstanceType<typeof Alert>['$emit']
interface TrackedBannerComponentProps extends AlertComponentProps {
bannerId: string
modelValue: boolean
}
interface TrackedBannerComponentEmits extends AlertComponentEmits {
(e: 'update:modelValue'): void
}
gql`
query TrackedBanner_ProjectState {
currentProject {
id
savedState
}
}
`
gql`
mutation TrackedBanner_SetProjectState($value: String!) {
setPreferences(type: project, value: $value) {
...TestingPreferences
...SpecRunner_Preferences
}
}
`
const props = withDefaults(defineProps<TrackedBannerComponentProps>(), {})
const emit = defineEmits<TrackedBannerComponentEmits>()
const stateQuery = useQuery({ query: TrackedBanner_ProjectStateDocument })
const setStateMutation = useMutation(TrackedBanner_SetProjectStateDocument)
watchEffect(() => {
if (props.modelValue) {
updateBannerState('lastShown')
}
})
function handleBannerDismissed (visible: boolean) {
if (!visible) {
updateBannerState('dismissed')
}
emit('update:modelValue', visible)
}
function updateBannerState (field: 'lastShown' | 'dismissed') {
const savedState = stateQuery.data.value?.currentProject?.savedState ?? {}
set(savedState, ['banners', props.bannerId, field], Date.now())
setStateMutation.executeMutation({ value: JSON.stringify(savedState) })
}
</script>
+7
View File
@@ -0,0 +1,7 @@
export { default as LoginBanner } from './LoginBanner.vue'
export { default as CreateOrganizationBanner } from './CreateOrganizationBanner.vue'
export { default as ConnectProjectBanner } from './ConnectProjectBanner.vue'
export { default as RecordBanner } from './RecordBanner.vue'
@@ -35,7 +35,7 @@ export interface ProjectApiShape {
getConfig(): ReceivedCypressOptions | undefined
getRemoteStates(): { reset(): void, getPrimary(): Cypress.RemoteState } | undefined
getCurrentBrowser: () => Cypress.Browser | undefined
getCurrentProjectSavedState(): {} | undefined
getCurrentProjectSavedState(): AllowedState | undefined
setPromptShown(slug: string): void
setProjectPreferences(stated: AllowedState): void
makeProjectSavedState(projectRoot: string): void
@@ -1,4 +1,4 @@
import { FoundBrowser, Editor, AllowedState, AllModeOptions, TestingType, BrowserStatus, PACKAGE_MANAGERS, AuthStateName, MIGRATION_STEPS, MigrationStep } from '@packages/types'
import { FoundBrowser, Editor, AllowedState, AllModeOptions, TestingType, BrowserStatus, PACKAGE_MANAGERS, AuthStateName, MIGRATION_STEPS, MigrationStep, BannerState } from '@packages/types'
import type { WizardFrontendFramework, WizardBundler } from '@packages/scaffold-config'
import type { NexusGenObjects } from '@packages/graphql/src/gen/nxs.gen'
import type { App, BrowserWindow } from 'electron'
@@ -35,6 +35,7 @@ export interface SavedStateShape {
firstOpened?: number | null
lastOpened?: number | null
promptsShown?: object | null
banners?: BannerState | null
lastProjectId?: string | null
specFilter?: string | null
}
@@ -304,6 +304,7 @@ function startAppServer (mode: 'component' | 'e2e' = 'e2e', options: { skipMocki
firstOpened: 1609459200000,
lastOpened: 1609459200000,
promptsShown: { ci1: 1609459200000 },
banners: { _disabled: true },
})
}
@@ -20,7 +20,6 @@ declare global {
}
}
}
cy.i18n = defaultMessages
cy.gqlStub = GQLStubRegistry
@@ -1,6 +1,5 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 9C8.10457 9 9 8.10457 9 7C9 5.89543 8.10457 5 7 5C5.89543 5 5 5.89543 5 7C5 8.10457 5.89543 9 7 9Z" fill="#fff" class="icon-dark"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 7C9 8.10457 8.10457 9 7 9C5.89543 9 5 8.10457 5 7C5 5.89543 5.89543 5 7 5C8.10457 5 9 5.89543 9 7Z" fill="#fff" class="icon-dark"/>
<path d="M7 9C8.10457 9 9 8.10457 9 7C9 5.89543 8.10457 5 7 5C5.89543 5 5 5.89543 5 7C5 8.10457 5.89543 9 7 9Z" stroke="#1B1E2E" stroke-width="2" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 7C9 8.10457 8.10457 9 7 9C5.89543 9 5 8.10457 5 7C5 5.89543 5.89543 5 7 5C8.10457 5 9 5.89543 9 7Z" stroke="#1B1E2E" stroke-width="2"/>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 10C9.10457 10 10 9.10457 10 8C10 6.89543 9.10457 6 8 6C6.89543 6 6 6.89543 6 8C6 9.10457 6.89543 10 8 10Z" fill="#1B1E2E" class="icon-dark" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M10 8C10 9.10457 9.10457 10 8 10C6.89543 10 6 9.10457 6 8C6 6.89543 6.89543 6 8 6C9.10457 6 10 6.89543 10 8Z" fill="#1B1E2E" class="icon-dark" />
<path d="M14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2C11.3137 2 14 4.68629 14 8ZM10 8C10 9.10457 9.10457 10 8 10C6.89543 10 6 9.10457 6 8C6 6.89543 6.89543 6 8 6C9.10457 6 10 6.89543 10 8Z" stroke="#1B1E2E" stroke-width="2" class="icon-dark"/>
</svg>

Before

Width:  |  Height:  |  Size: 768 B

After

Width:  |  Height:  |  Size: 734 B

@@ -357,7 +357,7 @@ describe('<HeaderBarContent />', { viewportWidth: 1000, viewportHeight: 750 }, (
})
function mountWithSavedState (options?: {state?: object, projectId?: string }) {
return cy.mountFragment(HeaderBar_HeaderBarContentFragmentDoc, {
const mountResult = cy.mountFragment(HeaderBar_HeaderBarContentFragmentDoc, {
onResult: (result) => {
if (!result.currentProject) {
return
@@ -378,6 +378,12 @@ describe('<HeaderBarContent />', { viewportWidth: 1000, viewportHeight: 750 }, (
},
render: (gqlVal) => <div class="border-current border-1 h-700px resize overflow-auto"><HeaderBarContent gql={gqlVal} show-browsers={true} allowAutomaticPromptOpen={true} /></div>,
})
// Auto-opening prompts wait 2000ms after mount before opening
// Advance to that point so that prompts will have had a chance to open
cy.tick(2000)
return mountResult
}
it('opens when after 4 days from first open, no projectId, and not already shown', () => {
@@ -166,7 +166,7 @@
<script setup lang="ts">
import { gql, useMutation, useSubscription } from '@urql/vue'
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import type { HeaderBar_HeaderBarContentFragment } from '../generated/graphql'
import {
GlobalPageHeader_ClearCurrentProjectDocument,
@@ -181,6 +181,7 @@ import ExternalLink from './ExternalLink.vue'
import interval from 'human-interval'
import { sortBy } from 'lodash'
import Tooltip from '../components/Tooltip.vue'
import type { AllowedState } from '@packages/types'
gql`
fragment HeaderBarContent_Auth on Query {
@@ -247,7 +248,7 @@ const userData = computed(() => {
})
const savedState = computed(() => {
return props.gql?.currentProject?.savedState
return props.gql?.currentProject?.savedState as AllowedState
})
const currentProject = computed(() => props.gql.currentProject)
@@ -299,7 +300,20 @@ const prompts = sortBy([
},
], 'interval')
const isForceOpenAllowed = ref(true)
const isOpenDelayElapsed = ref(false)
onMounted(() => {
setTimeout(() => isOpenDelayElapsed.value = true, 2000)
})
const isShowablePromptInSavedState = computed(() => {
// We do not want to show a prompt if a banner is going to be shown, but some banners rely on cloud data
// getting loaded before deciding whether to display. Add a delay here of a few seconds to give banners
// a chance to display before deciding whether to show a prompt.
if (!isOpenDelayElapsed.value) {
return false
}
if (savedState.value) {
for (const prompt of prompts) {
if (shouldShowPrompt(prompt)) {
@@ -318,8 +332,9 @@ function shouldShowPrompt (prompt: { slug: string, noProjectId: boolean, interva
}
const now = Date.now()
const timeSinceOpened = now - savedState.value?.firstOpened
const timeSinceOpened = now - (savedState.value?.firstOpened ?? now)
const allPromptShownTimes: number[] = Object.values(savedState.value?.promptsShown ?? {})
const bannersLastShown = Object.values(savedState.value?.banners ?? {}).map((banner) => typeof banner === 'object' && banner?.lastShown).filter((val): val is number => !!val)
// prompt has been shown
if (savedState.value?.promptsShown?.[prompt.slug]) {
@@ -331,6 +346,11 @@ function shouldShowPrompt (prompt: { slug: string, noProjectId: boolean, interva
return false
}
// If any tracked banners have been shown in the last 24 hours
if (bannersLastShown.some((bannerLastShown) => (now - bannerLastShown) < interval('24 hours'))) {
return false
}
// enough time has passed
// no interval indicates *never* being shown automatically, so don't show if there's no interval
if (!prompt.interval || timeSinceOpened < prompt.interval) {
@@ -184,6 +184,27 @@
"explainer1": "The request timed out or failed when trying to retrieve the recorded run metrics from the Cypress Dashboard. The information that you're seeing in the table below may be incomplete as a result.",
"explainer2": "Please refresh the page to try again and visit our {0} if this behavior continues.",
"refreshButton": "Try again"
},
"banners": {
"login": {
"title": "Optimize and record your CI test runs with Cypress Dashboard",
"content": "Parallelize your tests in CI and visualize every error by watching full video recordings of each test you run.",
"buttonLabel": "Get started with Cypress Dashboard"
},
"createOrganization": {
"title": "Finish setting up Cypress Dashboard",
"content": "Join or create an organization in Cypress Dashboard to access your projects and recorded test runs.",
"buttonLabel": "Set up your organization"
},
"connectProject": {
"title": "Connect your project to Cypress Dashboard",
"content": "View recorded test runs directly in the Cypress app to monitor, run, and fix tests locally.",
"buttonLabel": "Connect your project"
},
"record": {
"title": "Record your first run",
"content": "Record a run to see your test results in Cypress Dashboard. You can then optimize your test suite, debug failing and flaky tests, and integrate with your favorite tools."
}
}
},
"noResults": {
+2 -1
View File
@@ -20,7 +20,7 @@ import { SocketE2E } from './socket-e2e'
import { ensureProp } from './util/class-helpers'
import system from './util/system'
import type { FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ReceivedCypressOptions, TestingType } from '@packages/types'
import type { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ReceivedCypressOptions, TestingType } from '@packages/types'
import { DataContext, getCtx } from '@packages/data-context'
import { createHmac } from 'crypto'
@@ -34,6 +34,7 @@ export interface Cfg extends ReceivedCypressOptions {
firstOpened?: number | null
lastOpened?: number | null
promptsShown?: object | null
banners?: BannersState | null
}
e2e: Partial<Cfg>
component: Partial<Cfg>
+18
View File
@@ -36,3 +36,21 @@ export interface SettingsOptions {
testingType?: 'component' |'e2e'
args?: AllModeOptions
}
export type BannerState = {
lastShown?: number
dismissed?: number
}
export const BannerIds = {
ACI_082022_LOGIN: 'aci_082022_login',
ACI_082022_CREATE_ORG: 'aci_082022_createOrganization',
ACI_082022_CONNECT_PROJECT: 'aci_082022_connectProject',
ACI_082022_RECORD: 'aci_082022_record',
} as const
type BannerKeys = keyof typeof BannerIds
type BannerId = typeof BannerIds[BannerKeys]
export type BannersState = {
[bannerId in BannerId]?: BannerState
} & { _disabled?: boolean }
+3 -1
View File
@@ -1,4 +1,4 @@
import type { Editor } from '.'
import type { BannersState, Editor } from '.'
export const defaultPreferences: AllowedState = {
autoScrollingEnabled: true,
@@ -12,6 +12,7 @@ export const allowedKeys: Readonly<Array<keyof AllowedState>> = [
'appX',
'appY',
'autoScrollingEnabled',
'banners',
'browserWidth',
'browserHeight',
'browserX',
@@ -47,6 +48,7 @@ export type AllowedState = Partial<{
appY: Maybe<number>
isSpecsListOpen: Maybe<boolean>
autoScrollingEnabled: Maybe<boolean>
banners: Maybe<BannersState>
browserWidth: Maybe<number>
browserHeight: Maybe<number>
browserX: Maybe<number>