Merge branch 'develop' into cache-sessions

This commit is contained in:
Emily Rohrbough
2022-09-16 11:07:46 -05:00
49 changed files with 1089 additions and 152 deletions

View File

@@ -106,7 +106,7 @@ generates:
<<: *vueOperations
'./packages/frontend-shared/src/generated/graphql.ts':
documents: './packages/frontend-shared/src/{gql-components,graphql}/**/*.{vue,ts,tsx,js,jsx}'
documents: './packages/frontend-shared/src/{gql-components,graphql,composables}/**/*.{vue,ts,tsx,js,jsx}'
<<: *vueOperations
###
# All GraphQL documents imported into the .spec.tsx files for component testing.

View File

@@ -94,6 +94,11 @@ export type MountResponse<T> = {
component: T
};
// 'zone.js/testing' is not properly aliasing `it.skip` but it does provide `xit`/`xspecify`
// Written up under https://github.com/angular/angular/issues/46297 but is not seeing movement
// so we'll patch here pending a fix in that library
globalThis.it.skip = globalThis.xit
/**
* Bootstraps the TestModuleMetaData passed to the TestBed
*

View File

@@ -611,7 +611,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
cy.get('[data-cy="copy-button"]').click()
cy.contains('Copied!')
cy.withRetryableCtx((ctx) => {
expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('cypress run --component --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('npx cypress run --component --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
})
})
@@ -624,7 +624,7 @@ describe('App: Runs', { viewportWidth: 1200 }, () => {
cy.get('[data-cy="copy-button"]').click()
cy.contains('Copied!')
cy.withRetryableCtx((ctx) => {
expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('cypress run --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
expect(ctx.electronApi.copyTextToClipboard as SinonStub).to.have.been.calledWith('npx cypress run --record --key 2aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
})
})
})

View File

@@ -61,7 +61,7 @@ const firstRecordKey = computed(() => {
const recordCommand = computed(() => {
const componentFlagOrSpace = props.gql.currentTestingType === 'component' ? ' --component ' : ' '
return `cypress run${componentFlagOrSpace}--record --key ${firstRecordKey.value}`
return `npx cypress run${componentFlagOrSpace}--record --key ${firstRecordKey.value}`
})
</script>

View File

@@ -1,7 +1,7 @@
import SpecsListBanners from './SpecsListBanners.vue'
import { ref } from 'vue'
import type { Ref } from 'vue'
import { SpecsListBannersFragment, SpecsListBannersFragmentDoc } from '../generated/graphql-test'
import { SpecsListBannersFragment, SpecsListBannersFragmentDoc, UseCohorts_DetermineCohortDocument } from '../generated/graphql-test'
import interval from 'human-interval'
import { CloudUserStubs, CloudProjectStubs } from '@packages/graphql/test/stubCloudTypes'
import { AllowedState, BannerIds } from '@packages/types'
@@ -92,6 +92,12 @@ describe('<SpecsListBanners />', () => {
})
context('banner conditions are met and when cypress use >= 4 days', () => {
beforeEach(() => {
cy.stubMutationResolver(UseCohorts_DetermineCohortDocument, (defineResult) => {
return defineResult({ determineCohort: { __typename: 'Cohort', name: 'foo', cohort: 'A' } })
})
})
it('should render when not previously-dismissed', () => {
mountWithState(gql, stateWithFirstOpenedDaysAgo(4))
cy.get(`[data-cy="${bannerTestId}"]`).should('be.visible')

View File

@@ -116,19 +116,22 @@
:has-banner-been-shown="hasRecordBannerBeenShown"
/>
<ConnectProjectBanner
v-else-if="showConnectBanner"
v-else-if="showConnectBanner && cohorts.connectProject?.value"
v-model="showConnectBanner"
:has-banner-been-shown="hasConnectBannerBeenShown"
:cohort-option="cohorts.connectProject.value"
/>
<CreateOrganizationBanner
v-else-if="showCreateOrganizationBanner"
v-else-if="showCreateOrganizationBanner && cohorts.organization?.value"
v-model="showCreateOrganizationBanner"
:has-banner-been-shown="hasCreateOrganizationBannerBeenShown"
:cohort-option="cohorts.organization.value"
/>
<LoginBanner
v-else-if="showLoginBanner"
v-else-if="showLoginBanner && cohorts.login?.value"
v-model="showLoginBanner"
:has-banner-been-shown="hasLoginBannerBeenShown"
:cohort-option="cohorts.login.value"
/>
</template>
@@ -143,13 +146,14 @@ 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 { computed, ref, watch } from 'vue'
import { computed, ref, watch, watchEffect } from 'vue'
import RequestAccessButton from './RequestAccessButton.vue'
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'
import { CohortConfig, useCohorts } from '@packages/frontend-shared/src/composables/useCohorts'
const route = useRoute()
const { t } = useI18n()
@@ -228,10 +232,10 @@ const showConnectBanner = ref(false)
const showCreateOrganizationBanner = ref(false)
const showLoginBanner = ref(false)
const hasRecordBannerBeenShown = ref(true)
const hasConnectBannerBeenShown = ref(true)
const hasCreateOrganizationBannerBeenShown = ref(true)
const hasLoginBannerBeenShown = ref(true)
const hasRecordBannerBeenShown = ref(false)
const hasConnectBannerBeenShown = ref(false)
const hasCreateOrganizationBannerBeenShown = ref(false)
const hasLoginBannerBeenShown = ref(false)
watch(
() => ([props.isSpecNotFound, props.isOffline, props.isFetchError, props.isProjectNotFound, props.isProjectUnauthorized]),
@@ -272,6 +276,50 @@ watch(
{ immediate: true },
)
const bannerCohortOptions = {
[BannerIds.ACI_082022_LOGIN]: [
{ cohort: 'A', value: t('specPage.banners.login.contentA') },
{ cohort: 'B', value: t('specPage.banners.login.contentB') },
],
[BannerIds.ACI_082022_CREATE_ORG]: [
{ cohort: 'A', value: t('specPage.banners.createOrganization.titleA') },
{ cohort: 'B', value: t('specPage.banners.createOrganization.titleB') },
],
[BannerIds.ACI_082022_CONNECT_PROJECT]: [
{ cohort: 'A', value: t('specPage.banners.connectProject.contentA') },
{ cohort: 'B', value: t('specPage.banners.connectProject.contentB') },
],
}
const cohortBuilder = useCohorts()
const getCohortForBanner = (bannerId: string) => {
const cohortConfig: CohortConfig = {
name: bannerId,
options: bannerCohortOptions[bannerId],
}
return cohortBuilder.getCohort(cohortConfig)
}
type BannerType = 'login' | 'connectProject' | 'organization'
const cohorts: Partial<Record<BannerType, ReturnType<typeof getCohortForBanner>>> = {}
watchEffect(() => {
if (!cohorts.login && showLoginBanner.value) {
cohorts.login = getCohortForBanner(BannerIds.ACI_082022_LOGIN)
}
if (!cohorts.organization && showCreateOrganizationBanner.value) {
cohorts.organization = getCohortForBanner(BannerIds.ACI_082022_CREATE_ORG)
}
if (!cohorts.connectProject && showConnectBanner.value) {
cohorts.connectProject = getCohortForBanner(BannerIds.ACI_082022_CONNECT_PROJECT)
}
})
function hasBannerBeenDismissed (bannerId: string) {
const bannersState = (props.gql.currentProject?.savedState as AllowedState)?.banners

View File

@@ -3,32 +3,44 @@ import ConnectProjectBanner from './ConnectProjectBanner.vue'
import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql'
describe('<ConnectProjectBanner />', () => {
const cohortOption = { cohort: 'A', value: defaultMessages.specPage.banners.connectProject.contentA }
it('should render expected content', () => {
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} /> })
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
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.contentA).should('be.visible')
cy.contains(defaultMessages.specPage.banners.connectProject.buttonLabel).should('be.visible')
cy.percySnapshot()
})
it('should record expected event on mount', () => {
const recordEvent = cy.stub().as('recordEvent')
context('events', () => {
beforeEach(() => {
const recordEvent = cy.stub().as('recordEvent')
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
return defineResult({ recordEvent: true })
return defineResult({ recordEvent: true })
})
})
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} hasBannerBeenShown={false} /> })
it('should record expected event on mount', () => {
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} hasBannerBeenShown={false} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Create project',
medium: 'Specs Create Project Banner',
messageId: Cypress.sinon.match.string,
cohort: null,
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Create project',
medium: 'Specs Create Project Banner',
messageId: Cypress.sinon.match.string,
cohort: 'A',
})
})
it('should not record event on mount if already shown', () => {
cy.mount({ render: () => <ConnectProjectBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('not.have.been.called')
})
})
})

View File

@@ -1,6 +1,6 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_CONNECT_PROJECT"
:banner-id="bannerId"
:model-value="modelValue"
data-cy="connect-project-banner"
status="info"
@@ -12,12 +12,12 @@
:event-data="{
campaign: 'Create project',
medium: 'Specs Create Project Banner',
cohort: '' // TODO Connect cohort
cohort: cohortOption.cohort
}"
@update:model-value="value => emit('update:modelValue', value)"
>
<p class="mb-24px">
{{ t('specPage.banners.connectProject.content') }}
{{ cohortOption.value }}
</p>
<Button
@@ -45,6 +45,7 @@ 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 type { CohortOption } from '@packages/frontend-shared/src/composables/useCohorts'
import { BannerIds } from '@packages/types'
import { ref } from 'vue'
import { ConnectProjectBannerDocument } from '../../generated/graphql'
@@ -56,19 +57,18 @@ query ConnectProjectBanner {
}
`
withDefaults(defineProps<{
defineProps<{
modelValue: boolean
hasBannerBeenShown: boolean
}>(), {
modelValue: false,
hasBannerBeenShown: true,
})
cohortOption: CohortOption
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const bannerId = BannerIds.ACI_082022_CONNECT_PROJECT
const isProjectConnectOpen = ref(false)
const cloudModalsQuery = useQuery({ query: ConnectProjectBannerDocument, pause: true })

View File

@@ -3,6 +3,8 @@ import CreateOrganizationBanner from './CreateOrganizationBanner.vue'
import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql'
describe('<CreateOrganizationBanner />', () => {
const cohortOption = { cohort: 'A', value: defaultMessages.specPage.banners.createOrganization.titleA }
it('should render expected content', () => {
const linkHref = 'http://dummy.cypress.io/organizations/create'
@@ -12,9 +14,9 @@ describe('<CreateOrganizationBanner />', () => {
cloudOrganizationsUrl: linkHref,
} as any
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} /> })
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
cy.contains(defaultMessages.specPage.banners.createOrganization.title).should('be.visible')
cy.contains(defaultMessages.specPage.banners.createOrganization.titleA).should('be.visible')
cy.contains(defaultMessages.specPage.banners.createOrganization.content).should('be.visible')
cy.contains(defaultMessages.specPage.banners.createOrganization.buttonLabel).should('be.visible')
@@ -25,22 +27,32 @@ describe('<CreateOrganizationBanner />', () => {
cy.percySnapshot()
})
it('should record expected event on mount', () => {
const recordEvent = cy.stub().as('recordEvent')
context('events', () => {
beforeEach(() => {
const recordEvent = cy.stub().as('recordEvent')
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
return defineResult({ recordEvent: true })
return defineResult({ recordEvent: true })
})
})
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} hasBannerBeenShown={false} /> })
it('should record expected event on mount', () => {
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} hasBannerBeenShown={false} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Set up your organization',
medium: 'Specs Create Organization Banner',
messageId: Cypress.sinon.match.string,
cohort: null,
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Set up your organization',
medium: 'Specs Create Organization Banner',
messageId: Cypress.sinon.match.string,
cohort: 'A',
})
})
it('should not record event on mount if already shown', () => {
cy.mount({ render: () => <CreateOrganizationBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('not.have.been.called')
})
})
})

View File

@@ -1,10 +1,10 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_CREATE_ORG"
:banner-id="bannerId"
:model-value="modelValue"
data-cy="create-organization-banner"
status="info"
:title="t('specPage.banners.createOrganization.title')"
:title="cohortOption.value"
class="mb-16px"
:icon="OrganizationIcon"
dismissible
@@ -12,7 +12,7 @@
:event-data="{
campaign: 'Set up your organization',
medium: 'Specs Create Organization Banner',
cohort: '' // TODO Connect cohort
cohort: cohortOption.cohort
}"
@update:model-value="value => emit('update:modelValue', value)"
>
@@ -36,6 +36,7 @@
import OrganizationIcon from '~icons/cy/office-building_x16.svg'
import { useI18n } from '@cy/i18n'
import TrackedBanner from './TrackedBanner.vue'
import type { CohortOption } from '@packages/frontend-shared/src/composables/useCohorts'
import { BannerIds } from '@packages/types'
import { CreateOrganizationBannerDocument } from '../../generated/graphql'
import { gql, useQuery } from '@urql/vue'
@@ -52,19 +53,18 @@ query CreateOrganizationBanner {
}
`
withDefaults(defineProps<{
const props = defineProps<{
modelValue: boolean
hasBannerBeenShown: boolean
}>(), {
modelValue: false,
hasBannerBeenShown: true,
})
cohortOption: CohortOption
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const bannerId = BannerIds.ACI_082022_CREATE_ORG
const query = useQuery({ query: CreateOrganizationBannerDocument })
@@ -80,6 +80,7 @@ const createOrganizationUrl = computed(() => {
params: {
utm_medium: 'Specs Create Organization Banner',
utm_campaign: 'Set up your organization',
utm_content: props.cohortOption.cohort,
},
})
})

View File

@@ -3,32 +3,44 @@ import LoginBanner from './LoginBanner.vue'
import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql'
describe('<LoginBanner />', () => {
const cohortOption = { cohort: 'A', value: defaultMessages.specPage.banners.login.contentA }
it('should render expected content', () => {
cy.mount({ render: () => <LoginBanner modelValue={true} /> })
cy.mount({ render: () => <LoginBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
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.contentA).should('be.visible')
cy.contains(defaultMessages.specPage.banners.login.buttonLabel).should('be.visible')
cy.percySnapshot()
})
it('should record expected event on mount', () => {
const recordEvent = cy.stub().as('recordEvent')
context('events', () => {
beforeEach(() => {
const recordEvent = cy.stub().as('recordEvent')
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
cy.stubMutationResolver(TrackedBanner_RecordBannerSeenDocument, (defineResult, event) => {
recordEvent(event)
return defineResult({ recordEvent: true })
return defineResult({ recordEvent: true })
})
})
cy.mount({ render: () => <LoginBanner modelValue={true} hasBannerBeenShown={false} /> })
it('should record expected event on mount', () => {
cy.mount({ render: () => <LoginBanner modelValue={true} hasBannerBeenShown={false} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Log In',
medium: 'Specs Login Banner',
messageId: Cypress.sinon.match.string,
cohort: null,
cy.get('@recordEvent').should('have.been.calledWith', {
campaign: 'Log In',
medium: 'Specs Login Banner',
messageId: Cypress.sinon.match.string,
cohort: cohortOption.cohort,
})
})
it('should not record event on mount if already shown', () => {
cy.mount({ render: () => <LoginBanner modelValue={true} hasBannerBeenShown={true} cohortOption={cohortOption}/> })
cy.get('@recordEvent').should('not.have.been.called')
})
})
})

View File

@@ -1,6 +1,6 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_LOGIN"
:banner-id="bannerId"
:model-value="modelValue"
data-cy="login-banner"
status="info"
@@ -12,12 +12,12 @@
:event-data="{
campaign: 'Log In',
medium: 'Specs Login Banner',
cohort: '' // TODO Connect cohort
cohort: cohortOption.cohort
}"
@update:model-value="value => emit('update:modelValue', value)"
>
<p class="mb-24px">
{{ t('specPage.banners.login.content') }}
{{ cohortOption.value }}
</p>
<Button
@@ -33,6 +33,7 @@
v-model="isLoginOpen"
:gql="loginModalQuery.data.value"
utm-medium="Specs Login Banner"
:utm-content="cohortOption.cohort"
/>
</TrackedBanner>
</template>
@@ -45,6 +46,7 @@ import { useI18n } from '@cy/i18n'
import Button from '@cy/components/Button.vue'
import { LoginBannerDocument } from '../../generated/graphql'
import TrackedBanner from './TrackedBanner.vue'
import type { CohortOption } from '@packages/frontend-shared/src/composables/useCohorts'
import { BannerIds } from '@packages/types'
import LoginModal from '@cy/gql-components/topnav/LoginModal.vue'
@@ -54,19 +56,18 @@ query LoginBanner {
}
`
withDefaults(defineProps<{
defineProps<{
modelValue: boolean
hasBannerBeenShown: boolean
}>(), {
modelValue: false,
hasBannerBeenShown: true,
})
cohortOption: CohortOption
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const bannerId = BannerIds.ACI_082022_LOGIN
const isLoginOpen = ref(false)
const loginModalQuery = useQuery({ query: LoginBannerDocument, pause: true })

View File

@@ -4,7 +4,7 @@ import { TrackedBanner_RecordBannerSeenDocument } from '../../generated/graphql'
describe('<RecordBanner />', () => {
it('should render expected content', () => {
cy.mount({ render: () => <RecordBanner modelValue={true} /> })
cy.mount({ render: () => <RecordBanner modelValue={true} hasBannerBeenShown={false} /> })
cy.gqlStub.Query.currentProject = {
id: 'test_id',
@@ -24,7 +24,7 @@ describe('<RecordBanner />', () => {
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.findByText('npx cypress run --component --record --key abcd-efg-1234')
cy.percySnapshot()
})
@@ -44,7 +44,7 @@ describe('<RecordBanner />', () => {
campaign: 'Record Runs',
medium: 'Specs Record Runs Banner',
messageId: Cypress.sinon.match.string,
cohort: null,
cohort: 'n/a',
})
})
})

View File

@@ -1,6 +1,6 @@
<template>
<TrackedBanner
:banner-id="BannerIds.ACI_082022_RECORD"
:banner-id="bannerId"
:model-value="modelValue"
data-cy="record-banner"
status="info"
@@ -12,7 +12,7 @@
:event-data="{
campaign: 'Record Runs',
medium: 'Specs Record Runs Banner',
cohort: '' // TODO Connect cohort
cohort: 'n/a'
}"
@update:model-value="value => emit('update:modelValue', value)"
>
@@ -58,29 +58,28 @@ query RecordBanner {
}
`
withDefaults(defineProps<{
defineProps<{
modelValue: boolean
hasBannerBeenShown: boolean
}>(), {
modelValue: false,
hasBannerBeenShown: true,
})
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const { t } = useI18n()
const bannerId = BannerIds.ACI_082022_RECORD
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}`
return `npx cypress run${componentFlagOrSpace}--record --key ${firstRecordKey.value}`
})
</script>

View File

@@ -1,5 +1,5 @@
import TrackedBanner from './TrackedBanner.vue'
import { ref } from 'vue'
import { ref, reactive } from 'vue'
import { TrackedBanner_RecordBannerSeenDocument, TrackedBanner_SetProjectStateDocument } from '../../generated/graphql'
describe('<TrackedBanner />', () => {
@@ -25,7 +25,7 @@ describe('<TrackedBanner />', () => {
// Initially mount as visible
// @ts-ignore
cy.mount({ render: () => <TrackedBanner data-cy="banner" bannerId="test-banner" v-model={shown.value} hasBannerBeenShown={false} /> })
cy.mount({ render: () => <TrackedBanner data-cy="banner" bannerId="test-banner" v-model={shown.value} hasBannerBeenShown={false} eventData={{} as any}/> })
cy.get('[data-cy="banner"]').as('banner')
@@ -48,7 +48,7 @@ describe('<TrackedBanner />', () => {
// Initially mount as visible
// @ts-ignore
cy.mount({ render: () => <TrackedBanner data-cy="banner" bannerId="test-banner" v-model={shown.value} dismissible hasBannerBeenShown={false} /> })
cy.mount({ render: () => <TrackedBanner data-cy="banner" bannerId="test-banner" v-model={shown.value} dismissible hasBannerBeenShown={false} eventData={{} as any} /> })
cy.get('[data-cy="banner"]').as('banner')
@@ -74,32 +74,55 @@ describe('<TrackedBanner />', () => {
})
context('when banner not previously shown', () => {
let eventData
beforeEach(() => {
const setProjectStateStub = cy.stub().as('setProjectState')
const hasBannerBeenShown = ref(false)
// mock setting the project state which would reactively set the hasBannerBeenShown ref
cy.stubMutationResolver(TrackedBanner_SetProjectStateDocument, (defineResult, event) => {
setProjectStateStub(event)
const preference = JSON.parse(event.value)
expect(preference).to.have.nested.property('banners.test-banner.lastShown')
hasBannerBeenShown.value = true
return defineResult({ setPreferences: null }) // do not care about return value here
})
eventData = reactive({ campaign: 'CAM', medium: 'MED', cohort: 'COH' })
cy.mount({
render: () => <TrackedBanner bannerId="test-banner" modelValue={true} hasBannerBeenShown={false} eventData={{ campaign: 'CAM', medium: 'MED', cohort: 'COH' }} />,
render: () => <TrackedBanner bannerId="test-banner" modelValue={true} hasBannerBeenShown={hasBannerBeenShown.value} eventData={eventData} />,
})
})
it('should record event', () => {
cy.get('@recordEvent').should('have.been.calledOnce')
eventData.cohort = 'COH2' //Change reactive variable to confirm the record event is not recorded a second time
cy.get('@recordEvent').should(
'have.been.calledWith',
'have.been.calledOnceWith',
Cypress.sinon.match({ campaign: 'CAM', messageId: Cypress.sinon.match.string, medium: 'MED', cohort: 'COH' }),
)
})
it('should debounce event recording', () => {
eventData.cohort = 'COH'
cy.wait(250)
cy.get('@recordEvent').should('have.been.calledOnce')
})
})
context('when banner has been previously shown', () => {
let eventData
beforeEach(() => {
cy.mount({ render: () => <TrackedBanner bannerId="test-banner" modelValue={true} hasBannerBeenShown={true} eventData={{} as any} /> })
eventData = reactive({ campaign: 'CAM', medium: 'MED', cohort: undefined })
cy.mount({ render: () => <TrackedBanner bannerId="test-banner" modelValue={true} hasBannerBeenShown={true} eventData={eventData} /> })
})
it('should not record event', () => {
eventData.cohort = 'COH'
cy.get('@recordEvent').should('not.have.been.called')
})
})

View File

@@ -10,6 +10,7 @@ import {
BrowserActions,
DevActions,
AuthActions,
CohortsActions,
} from './actions'
import { ErrorActions } from './actions/ErrorActions'
import { EventCollectorActions } from './actions/EventCollectorActions'
@@ -83,4 +84,9 @@ export class DataActions {
get eventCollector () {
return new EventCollectorActions(this.ctx)
}
@cached
get cohorts () {
return new CohortsActions(this.ctx)
}
}

View File

@@ -9,7 +9,7 @@ import _ from 'lodash'
import 'server-destroy'
import { AppApiShape, DataEmitterActions, LocalSettingsApiShape, ProjectApiShape } from './actions'
import { AppApiShape, CohortsApiShape, DataEmitterActions, LocalSettingsApiShape, ProjectApiShape } from './actions'
import type { NexusGenAbstractTypeMembers } from '@packages/graphql/src/gen/nxs.gen'
import type { AuthApiShape } from './actions/AuthActions'
import type { ElectronApiShape } from './actions/ElectronActions'
@@ -70,6 +70,7 @@ export interface DataContextConfig {
projectApi: ProjectApiShape
electronApi: ElectronApiShape
browserApi: BrowserApiShape
cohortsApi: CohortsApiShape
}
export interface GraphQLRequestInfo {
@@ -131,6 +132,10 @@ export class DataContext {
return this._config.localSettingsApi
}
get cohortsApi () {
return this._config.cohortsApi
}
get isGlobalMode () {
return this.appData.isGlobalMode
}
@@ -324,6 +329,7 @@ export class DataContext {
projectApi: this._config.projectApi,
electronApi: this._config.electronApi,
localSettingsApi: this._config.localSettingsApi,
cohortsApi: this._config.cohortsApi,
}
}

View File

@@ -0,0 +1,54 @@
import type { Cohort } from '@packages/types'
import type { DataContext } from '..'
import { WEIGHTED, WEIGHTED_EVEN } from '../util/weightedChoice'
const debug = require('debug')('cypress:data-context:actions:CohortActions')
export interface CohortsApiShape {
getCohorts(): Promise<Record<string, Cohort> | undefined>
getCohort(name: string): Promise<Cohort | undefined>
insertCohort (cohort: Cohort): Promise<void>
}
export class CohortsActions {
constructor (private ctx: DataContext) {}
async getCohorts () {
debug('Getting all cohorts')
return this.ctx._apis.cohortsApi.getCohorts()
}
async getCohort (name: string) {
debug('Getting cohort for %s', name)
return this.ctx._apis.cohortsApi.getCohort(name)
}
async determineCohort (name: string, cohorts: string[], weights?: number[]) {
debug('Determining cohort', name, cohorts)
const cohortFromCache = await this.getCohort(name)
let cohortSelected: Cohort
if (!cohortFromCache || !cohorts.includes(cohortFromCache.cohort)) {
const algorithm = weights ? WEIGHTED(weights) : WEIGHTED_EVEN(cohorts)
const pickedCohort = {
name,
cohort: algorithm.pick(cohorts),
}
debug('Inserting cohort for %o', pickedCohort)
await this.ctx._apis.cohortsApi.insertCohort(pickedCohort)
cohortSelected = pickedCohort
} else {
cohortSelected = cohortFromCache
}
debug('Selecting cohort', cohortSelected)
return cohortSelected
}
}

View File

@@ -4,6 +4,7 @@
export * from './AppActions'
export * from './AuthActions'
export * from './BrowserActions'
export * from './CohortsActions'
export * from './DataEmitterActions'
export * from './DevActions'
export * from './ElectronActions'

View File

@@ -9,3 +9,4 @@ export * from './file'
export * from './hasTypescript'
export * from './pluginHandlers'
export * from './urqlCacheKeys'
export * from './weightedChoice'

View File

@@ -0,0 +1,57 @@
import _ from 'lodash'
export type WeightedAlgorithm = {
pick: (values: string[]) => string
}
/**
* Randomly choose an index from an array based on weights
*
* Based on algorithm found here: https://dev.to/trekhleb/weighted-random-algorithm-in-javascript-1pdc
*
* @param weights array of numbered weights that correspond to the indexed values
* @param values array of values to choose from
*/
const weightedChoice = (weights: number[], values: any[]) => {
if (weights.length === 0 || values.length === 0 || weights.length !== values.length) {
throw new Error('The length of the weights and values must be the same and greater than zero')
}
const cumulativeWeights = weights.reduce<number[]>((acc, curr) => {
if (acc.length === 0) {
return [curr]
}
const last = acc[acc.length - 1]
if (!last) {
return acc
}
return [...acc, last + curr]
}, [])
const randomNumber = Math.random() * (cumulativeWeights[cumulativeWeights.length - 1] ?? 1)
const choice = _.transform(cumulativeWeights, (result, value, index) => {
if (value >= randomNumber) {
result.chosenIndex = index
}
return result.chosenIndex === -1
}, { chosenIndex: -1 })
return values[choice.chosenIndex]
}
export const WEIGHTED = (weights: number[]): WeightedAlgorithm => {
return {
pick: (values: any[]): string => {
return weightedChoice(weights, values)
},
}
}
export const WEIGHTED_EVEN = (values: any[]): WeightedAlgorithm => {
return WEIGHTED(_.fill(Array(values.length), 1))
}

View File

@@ -0,0 +1,57 @@
import type { DataContext } from '../../../src'
import { CohortsActions } from '../../../src/actions/CohortsActions'
import { createTestDataContext } from '../helper'
import { expect } from 'chai'
import sinon, { SinonStub, match } from 'sinon'
describe('CohortsActions', () => {
let ctx: DataContext
let actions: CohortsActions
beforeEach(() => {
sinon.restore()
ctx = createTestDataContext('open')
actions = new CohortsActions(ctx)
})
context('getCohort', () => {
it('should return null if name not found', async () => {
const name = '123'
const cohort = await actions.getCohort(name)
expect(cohort).to.be.undefined
expect(ctx.cohortsApi.getCohort).to.have.been.calledWith(name)
})
it('should return cohort if in cache', async () => {
const cohort = {
name: 'loginBanner',
cohort: 'A',
}
;(ctx._apis.cohortsApi.getCohort as SinonStub).resolves(cohort)
const cohortReturned = await actions.getCohort(cohort.name)
expect(cohortReturned).to.eq(cohort)
expect(ctx.cohortsApi.getCohort).to.have.been.calledWith(cohort.name)
})
})
context('determineCohort', () => {
it('should determine cohort', async () => {
const cohortConfig = {
name: 'loginBanner',
cohorts: ['A', 'B'],
}
const pickedCohort = await actions.determineCohort(cohortConfig.name, cohortConfig.cohorts)
expect(ctx.cohortsApi.insertCohort).to.have.been.calledOnceWith({ name: cohortConfig.name, cohort: match.string })
expect(cohortConfig.cohorts.includes(pickedCohort.cohort)).to.be.true
})
})
})

View File

@@ -8,7 +8,7 @@ import { DataContext, DataContextConfig } from '../../src'
import { graphqlSchema } from '@packages/graphql/src/schema'
import { remoteSchemaWrapped as schemaCloud } from '@packages/graphql/src/stitching/remoteSchemaWrapped'
import type { BrowserApiShape } from '../../src/sources/BrowserDataSource'
import type { AppApiShape, AuthApiShape, ElectronApiShape, LocalSettingsApiShape, ProjectApiShape } from '../../src/actions'
import type { AppApiShape, AuthApiShape, ElectronApiShape, LocalSettingsApiShape, ProjectApiShape, CohortsApiShape } from '../../src/actions'
import sinon from 'sinon'
import { execute, parse } from 'graphql'
import { getOperationName } from '@urql/core'
@@ -63,6 +63,12 @@ export function createTestDataContext (mode: DataContextConfig['mode'] = 'run',
focusActiveBrowserWindow: sinon.stub(),
getBrowsers: sinon.stub().resolves([]),
} as unknown as BrowserApiShape,
cohortsApi: {
getCohorts: sinon.stub().resolves(),
getCohort: sinon.stub().resolves(),
insertCohort: sinon.stub(),
determineCohort: sinon.stub().resolves(),
} as unknown as CohortsApiShape,
})
const origFetch = ctx.util.fetch

View File

@@ -0,0 +1,75 @@
import { expect } from 'chai'
import { WEIGHTED, WEIGHTED_EVEN } from '../../../src/util/weightedChoice'
describe('weightedChoice', () => {
context('WeightedAlgorithm', () => {
it('should error if invalid arguments', () => {
const weights = [25, 75, 45]
const options = ['A', 'B']
const func = () => {
WEIGHTED(weights).pick(options)
}
expect(func).to.throw()
})
it('should error if weights is empty', () => {
const weights = []
const options = ['A', 'B']
const func = () => {
WEIGHTED(weights).pick(options)
}
expect(func).to.throw()
})
it('should error if options is empty', () => {
const weights = [25, 75, 45]
const options = []
const func = () => {
WEIGHTED(weights).pick(options)
}
expect(func).to.throw()
})
it('should return an option', () => {
const weights = [25, 75]
const options = ['A', 'B']
const selected = WEIGHTED(weights).pick(options)
expect(options.includes(selected)).to.be.true
})
})
context('WEIGHTED_EVEN', () => {
it('should return an option', () => {
const options = ['A', 'B']
const selected = WEIGHTED_EVEN(options).pick(options)
expect(options.includes(selected)).to.be.true
})
})
context('randomness', () => {
it('should return values close to supplied weights', () => {
const results = {}
const options = ['A', 'B']
const algorithm = WEIGHTED_EVEN(options)
for (let i = 0; i < 1000; i++) {
const selected = algorithm.pick(options)
results[selected] ? results[selected]++ : results[selected] = 1
}
Object.keys(results).forEach((key) => {
expect(Math.round(results[key] / 100)).to.equal(5)
})
})
})
})

View File

@@ -0,0 +1,31 @@
import UseCohortsExample, { CopyOption } from './UseCohortsExample.vue'
import { UseCohorts_DetermineCohortDocument } from '../../generated/graphql'
describe('useCohorts example', () => {
const copyOptions: CopyOption[] = [
{ cohort: 'A', value: 'Notification Title A' },
{ cohort: 'B', value: 'Notification Title B' },
]
beforeEach(() => {
cy.stubMutationResolver(UseCohorts_DetermineCohortDocument, (defineResult) => {
return defineResult({ determineCohort: { __typename: 'Cohort', name: 'foo', cohort: 'A' } })
})
})
it('should show value for one cohort with default algorithm', () => {
cy.mount(() => <UseCohortsExample copyOptions={copyOptions}/>)
cy.findByTestId('result').then((elem) => {
expect(copyOptions.map((option) => option.value)).to.include(elem.text())
})
})
it('should show value for one cohort with supplied algorithm', () => {
const weighted25_75 = [25, 75]
cy.mount(() => <UseCohortsExample copyOptions={copyOptions} weights={weighted25_75}/>)
cy.findByTestId('result').then((elem) => {
expect(copyOptions.map((option) => option.value)).to.include(elem.text())
})
})
})

View File

@@ -0,0 +1,30 @@
<template>
<div data-cy="result">
{{ cohortChoice?.value }}
</div>
</template>
<script lang="ts">
export type CopyOption = {
cohort: string
value: string
}
</script>
<script setup lang="ts">
import { CohortConfig, useCohorts } from '../useCohorts'
const props = defineProps<{
weights?: number[]
copyOptions: CopyOption[]
}>()
const cohortConfig: CohortConfig = {
name: 'login',
options: props.copyOptions,
weights: props.weights,
}
const cohortChoice = useCohorts().getCohort(cohortConfig)
</script>

View File

@@ -0,0 +1,83 @@
import { useMutation, gql } from '@urql/vue'
import { UseCohorts_DetermineCohortDocument } from '../generated/graphql'
import { ref } from 'vue'
gql`
mutation UseCohorts_DetermineCohort ($name: String!, $cohorts: [String!]!) {
determineCohort(cohortConfig: { name: $name, cohorts: $cohorts } ) {
__typename
name
cohort
}
}`
/**
* An option to use for a given cohort selection.
*/
export type CohortOption = {
/** The individual cohort identifier. Example: 'A' or 'B' */
cohort: string
/** The value to be used by the calling code for the given cohort. The algorithm for selecting the cohort does not care about this value, but it will return the entire CohortOption that is selected so that this value can be used by the calling code. */
value: any
}
/**
* The configuration of the cohort to be used to select a cohort.
*/
export type CohortConfig = {
/** The name of the feature the cohort will be calculated for. This will be used as a key in the cache file for storing the selected option. */
name: string
/** Array of options to pick from when selecting the cohort */
options: CohortOption[]
/** Optional array of weights to use for selecting the cohort. If not supplied, an even weighting algorithm will be used. */
weights?: number[]
}
/**
* Composable that encapsulates the logic for choosing a cohort from a list of configured options.
*
* @remarks
* The logic for this composable will first check the cache file to determine if a cohort has already been saved for the given cohort `name`. If found, that cohort will be returned. If not found or the option found does not match an existing option, a weighted algorithm will be used to pick from the list of CohortOptions. The picked value will be stored in the cache and returned.
*
* @returns object with getCohort function for returning the cohort
*/
export const useCohorts = () => {
const determineCohortMutation = useMutation(UseCohorts_DetermineCohortDocument)
const determineCohort = async (name: string, cohorts: string[]) => {
return await determineCohortMutation.executeMutation({
name,
cohorts,
})
}
/**
* Return the cohort from the list of configured options
*
* @param config - cohort configuration that contains the options to choose from and optionally the algorithm to use. Defaults to using the WEIGHTED_EVEN algorithm
*
* @returns a reactive reference to the cohort option that is selected
*/
const getCohort = (config: CohortConfig) => {
const cohortOptionSelected = ref<CohortOption>()
const cohortIds = config.options.map((option) => option.cohort)
const fetchCohort = async () => {
const cohortSelected = await determineCohort(config.name, cohortIds)
cohortOptionSelected.value = config.options.find((option) => option.cohort === cohortSelected.data?.determineCohort?.cohort)
}
fetchCohort()
return cohortOptionSelected
}
return {
getCohort,
}
}

View File

@@ -197,17 +197,20 @@
"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.",
"contentA": "Parallelize your tests in CI and visualize every error by watching full video recordings of each test you run.",
"contentB": "When you configure Cypress to record tests to the Cypress Dashboard, you'll see data from your latest recorded runs in the Cypress app. This increased visibility into your test history allows you to debug your tests faster and more effectively, all within your local workflow.",
"buttonLabel": "Get started with Cypress Dashboard"
},
"createOrganization": {
"title": "Finish setting up Cypress Dashboard",
"titleA": "Finish setting up Cypress Dashboard",
"titleB": "Create or join an organization",
"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.",
"contentA": "View recorded test runs directly in the Cypress app to monitor, run, and fix tests locally.",
"contentB": "Bring your recorded test results into your local development workflow to monitor, run, and fix tests all in the Cypress app.",
"buttonLabel": "Connect your project"
},
"record": {

View File

@@ -555,6 +555,28 @@ enum CodeLanguageEnum {
ts
}
"""used to distinguish one group of users from another"""
type Cohort {
"""value used to indicate the cohort (e.g. "A" or "B")"""
cohort: String!
"""name used to identify the cohort topic (e.g. "LoginBanner" ) """
name: String!
}
input CohortInput {
"""Array of cohort options to choose from. Ex: A or B """
cohorts: [String!]!
"""Name of the cohort"""
name: String!
"""
Optional array of integer weights to use for determining cohort. Defaults to even weighting
"""
weights: [Int!]
}
"""
The currently opened Cypress project, represented by a cypress.config.{js,ts,mjs,cjs} file
"""
@@ -1198,6 +1220,11 @@ type Mutation {
"""add the passed text to the local clipboard"""
copyTextToClipboard(text: String!): Boolean
"""
Determine the cohort based on the given configuration. This will either return the cached cohort for a given name or choose a new one and store it.
"""
determineCohort(cohortConfig: CohortInput!): Cohort
"""
Development only: Triggers or dismisses a prompted refresh by touching the file watched by our development scripts
"""
@@ -1494,6 +1521,12 @@ type Query {
"""A user within the Cypress Cloud"""
cloudViewer: CloudUser
"""Return the cohort for the given name"""
cohort(
"""the name of the cohort to find"""
name: String!
): Cohort
"""The currently opened project"""
currentProject: CurrentProject

View File

@@ -0,0 +1,19 @@
import { inputObjectType, objectType } from 'nexus'
export const Cohort = objectType({
name: 'Cohort',
description: 'used to distinguish one group of users from another',
definition (t) {
t.nonNull.string('name', { description: 'name used to identify the cohort topic (e.g. "LoginBanner" ) ' })
t.nonNull.string('cohort', { description: 'value used to indicate the cohort (e.g. "A" or "B")' })
},
})
export const CohortInput = inputObjectType({
name: 'CohortInput',
definition (t) {
t.nonNull.string('name', { description: 'Name of the cohort' })
t.nonNull.list.nonNull.string('cohorts', { description: 'Array of cohort options to choose from. Ex: A or B ' })
t.list.nonNull.int('weights', { description: 'Optional array of integer weights to use for determining cohort. Defaults to even weighting' })
},
})

View File

@@ -7,6 +7,7 @@ import { FileDetailsInput } from '../inputTypes/gql-FileDetailsInput'
import { WizardUpdateInput } from '../inputTypes/gql-WizardUpdateInput'
import { CurrentProject } from './gql-CurrentProject'
import { GenerateSpecResponse } from './gql-GenerateSpecResponse'
import { Cohort, CohortInput } from './gql-Cohorts'
import { Query } from './gql-Query'
import { ScaffoldedFile } from './gql-ScaffoldedFile'
import { WIZARD_BUNDLERS, WIZARD_FRAMEWORKS } from '@packages/scaffold-config'
@@ -684,6 +685,17 @@ export const mutation = mutationType({
},
})
t.field('determineCohort', {
type: Cohort,
description: 'Determine the cohort based on the given configuration. This will either return the cached cohort for a given name or choose a new one and store it.',
args: {
cohortConfig: nonNull(CohortInput),
},
resolve: async (source, args, ctx) => {
return ctx.actions.cohorts.determineCohort(args.cohortConfig.name, args.cohortConfig.cohorts, args.cohortConfig.weights || undefined)
},
})
t.field('recordEvent', {
type: 'Boolean',
description: 'Dispatch an event to the dashboard to be recorded. Events are completely anonymous and are only used to identify aggregate usage patterns across all Cypress users.',

View File

@@ -1,4 +1,4 @@
import { idArg, nonNull, objectType } from 'nexus'
import { idArg, stringArg, nonNull, objectType } from 'nexus'
import { ProjectLike, ScaffoldedFile } from '..'
import { CurrentProject } from './gql-CurrentProject'
import { DevState } from './gql-DevState'
@@ -9,6 +9,7 @@ import { VersionData } from './gql-VersionData'
import { Wizard } from './gql-Wizard'
import { ErrorWrapper } from './gql-ErrorWrapper'
import { CachedUser } from './gql-CachedUser'
import { Cohort } from './gql-Cohorts'
export const Query = objectType({
name: 'Query',
@@ -107,6 +108,17 @@ export const Query = objectType({
resolve: (source, args, ctx) => Boolean(ctx.modeOptions.invokedFromCli),
})
t.field('cohort', {
description: 'Return the cohort for the given name',
type: Cohort,
args: {
name: nonNull(stringArg({ description: 'the name of the cohort to find' })),
},
resolve: async (source, args, ctx) => {
return await ctx.cohortsApi.getCohort(args.name) ?? null
},
})
t.field('node', {
type: 'Node',
args: {

View File

@@ -6,6 +6,7 @@ export * from './gql-Browser'
export * from './gql-CachedUser'
export * from './gql-CodeFrame'
export * from './gql-CodeGenGlobs'
export * from './gql-Cohorts'
export * from './gql-CurrentProject'
export * from './gql-DevState'
export * from './gql-Editor'

View File

@@ -45,6 +45,7 @@ module.exports = {
PROJECTS: [],
PROJECT_PREFERENCES: {},
PROJECTS_CONFIG: {},
COHORTS: {},
}
},
@@ -184,6 +185,21 @@ module.exports = {
return fileUtil.set({ PROJECT_PREFERENCES: updatedPreferences })
},
getCohorts () {
return fileUtil.get('COHORTS', {})
},
insertCohort (cohort) {
return fileUtil.transaction((tx) => {
return tx.get('COHORTS', {}).then((cohorts) => {
return tx.set('COHORTS', {
...cohorts,
[cohort.name]: cohort,
})
})
})
},
remove () {
return fileUtil.remove()
},

View File

@@ -0,0 +1,25 @@
const cache = require('./cache')
import type { Cohort } from '@packages/types'
const debug = require('debug')('cypress:server:cohorts')
export = {
get: (): Promise<Record<string, Cohort>> => {
debug('Get cohorts')
return cache.getCohorts()
},
getByName: (name: string): Promise<Cohort> => {
debug('Get cohort name:', name)
return cache.getCohorts().then((cohorts) => {
debug('Get cohort returning:', cohorts[name])
return cohorts[name]
})
},
set: (cohort: Cohort) => {
debug('Set cohort', cohort)
return cache.insertCohort(cohort)
},
}

View File

@@ -140,7 +140,7 @@ export const getExperiments = (project: CypressProject, names = experimental.nam
}
/**
* Whitelist known experiments here to avoid accidentally showing
* Allow known experiments here to avoid accidentally showing
* any config key that starts with "experimental" prefix
*/
// @ts-ignore

View File

@@ -17,6 +17,7 @@ import type {
import browserUtils from './browsers/utils'
import auth from './gui/auth'
import user from './user'
import cohorts from './cohorts'
import { openProject } from './open_project'
import cache from './cache'
import { graphqlSchema } from '@packages/graphql/src/schema'
@@ -195,6 +196,17 @@ export function makeDataContext (options: MakeDataContextOptions): DataContext {
return availableEditors
},
},
cohortsApi: {
async getCohorts () {
return cohorts.get()
},
async getCohort (name: string) {
return cohorts.getByName(name)
},
async insertCohort (cohort) {
cohorts.set(cohort)
},
},
})
return ctx

View File

@@ -718,7 +718,7 @@ async function runSpecs (options: { config: Cfg, browser: Browser, sys: any, hea
async function runEachSpec (spec: SpecWithRelativeRoot, index: number, length: number, estimated: number) {
if (!options.quiet) {
printResults.displaySpecHeader(spec.baseName, index + 1, length, estimated)
printResults.displaySpecHeader(spec.relativeToCommonRoot, index + 1, length, estimated)
}
const { results } = await runSpec(config, spec, options, estimated, isFirstSpec, index === length - 1)

View File

@@ -113,7 +113,7 @@ function macOSRemovePrivate (str: string) {
function collectTestResults (obj: { video?: boolean, screenshots?: Screenshot[], spec?: any, stats?: any }, estimated: number) {
return {
name: _.get(obj, 'spec.name'),
baseName: _.get(obj, 'spec.baseName'),
relativeToCommonRoot: _.get(obj, 'spec.relativeToCommonRoot'),
tests: _.get(obj, 'stats.tests'),
passes: _.get(obj, 'stats.passes'),
pending: _.get(obj, 'stats.pending'),
@@ -203,7 +203,7 @@ export function displayRunStarting (options: { browser: Browser, config: Cfg, gr
const formatSpecs = (specs) => {
// 25 found: (foo.spec.js, bar.spec.js, baz.spec.js)
const names = _.map(specs, 'baseName')
const names = _.map(specs, 'relativeToCommonRoot')
const specsTruncated = _.truncate(names.join(', '), { length: 250 })
const stringifiedSpecs = [
@@ -325,7 +325,7 @@ export function renderSummaryTable (runUrl: string | undefined, results: any) {
const ms = duration.format(stats.wallClockDuration || 0)
const formattedSpec = formatPath(spec.baseName, getWidth(table2, 1))
const formattedSpec = formatPath(spec.relativeToCommonRoot, getWidth(table2, 1))
if (run.skippedSpec) {
return table2.push([
@@ -398,7 +398,7 @@ export function displayResults (obj: { screenshots?: Screenshot[] }, estimated:
['Video:', results.video],
['Duration:', results.duration],
estimated ? ['Estimated:', results.estimated] : undefined,
['Spec Ran:', formatPath(results.baseName, getWidth(table, 1), c)],
['Spec Ran:', formatPath(results.relativeToCommonRoot, getWidth(table, 1), c)],
])
.compact()
.map((arr) => {

View File

@@ -256,6 +256,28 @@ describe('lib/cache', () => {
PROJECTS: ['foo'],
PROJECT_PREFERENCES: {},
PROJECTS_CONFIG: {},
COHORTS: {},
})
})
})
})
context('cohorts', () => {
it('should get no cohorts when empty', () => {
return cache.getCohorts().then((cohorts) => {
expect(cohorts).to.deep.eq({})
})
})
it('should insert a cohort', () => {
const cohort = {
name: 'cohort_id',
cohort: 'A',
}
return cache.insertCohort(cohort).then(() => {
return cache.getCohorts().then((cohorts) => {
expect(cohorts).to.deep.eq({ [cohort.name]: cohort })
})
})
})

View File

@@ -0,0 +1,63 @@
require('../spec_helper')
import { Cohort } from '@packages/types'
import cache from '../../lib/cache'
import cohorts from '../../lib/cohorts'
describe('lib/cohort', () => {
context('.get', () => {
it('calls cache.get', async () => {
const cohortTest: Cohort = {
name: 'testName',
cohort: 'A',
}
const cohortTest2: Cohort = {
name: 'testName2',
cohort: 'B',
}
const allCohorts = {
[cohortTest.name]: cohortTest,
[cohortTest2.name]: cohortTest2,
}
sinon.stub(cache, 'getCohorts').resolves(allCohorts)
return cohorts.get().then((cohorts) => {
expect(cohorts).to.eq(allCohorts)
})
})
})
context('.getByName', () => {
it('calls cache.getByName', async () => {
const cohortTest: Cohort = {
name: 'testName',
cohort: 'A',
}
sinon.stub(cache, 'getCohorts').resolves({
[cohortTest.name]: cohortTest,
})
return cohorts.getByName(cohortTest.name).then((cohort) => {
expect(cohort).to.eq(cohortTest)
})
})
})
context('.set', () => {
it('calls cache.set', async () => {
const cohortTest: Cohort = {
name: 'testName',
cohort: 'A',
}
return cohorts.set(cohortTest).then(() => {
return cohorts.getByName(cohortTest.name).then((cohort) => {
expect(cohort).to.eq(cohortTest)
})
})
})
})
})

View File

@@ -774,43 +774,6 @@ describe('lib/socket', () => {
})
})
})
context('on(backend:request, cross:origin:bridge:ready)', () => {
it('emits cross:origin:bridge:ready on local bus', function (done) {
this.server.socket.localBus.once('cross:origin:bridge:ready', ({ originPolicy }) => {
expect(originPolicy).to.equal('http://foobar.com')
done()
})
this.client.emit('backend:request', 'cross:origin:bridge:ready', { originPolicy: 'http://foobar.com' }, () => {})
})
})
context('on(backend:request, cross:origin:release:html)', () => {
it('emits cross:origin:release:html on local bus', function (done) {
this.server.socket.localBus.once('cross:origin:release:html', () => {
done()
})
this.client.emit('backend:request', 'cross:origin:release:html', () => {})
})
})
context('on(backend:request, cross:origin:finished)', () => {
it('emits cross:origin:finished on local bus', function (done) {
this.server.socket.localBus.once('cross:origin:finished', (originPolicy) => {
expect(originPolicy).to.equal('http://foobar.com')
done()
})
// add the origin before calling cross:origin:finished (otherwise we'll fail trying to remove the origin)
this.client.emit('backend:request', 'cross:origin:bridge:ready', { originPolicy: 'http://foobar.com' }, () => {})
this.client.emit('backend:request', 'cross:origin:finished', 'http://foobar.com', () => {})
})
})
})
context('unit', () => {

View File

@@ -2,6 +2,7 @@ export interface Cache {
PROJECTS: string[]
PROJECT_PREFERENCES: Record<string, Preferences>
USER: CachedUser
COHORTS: Record<string, Cohort>
}
import type { AllowedState } from './preferences'
@@ -13,3 +14,8 @@ export interface CachedUser {
name: string
email: string
}
export interface Cohort {
name: string
cohort: string
}

View File

@@ -275,15 +275,15 @@ exports['e2e stdout displays fullname of nested specfile 1'] = `
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 1.2.3 │
│ Browser: FooBrowser 88 │
│ Specs: 3 found (spec.cy.js, stdout_specfile.cy.js, stdout_specfile_display_spec_with_a_re │
│ ally_long_name_that_never_has_a_line_break_or_new_line.cy.js)
│ Searched: cypress/e2e/nested-1/nested-2/nested-3/*
│ Specs: 4 found (spec.cy.js, stdout_specfile.cy.js, stdout_specfile_display_spec_with_a_re │
│ ally_long_name_that_never_has_a_line_break_or_new_line.cy.js, nested-4/spec.cy.js)
│ Searched: cypress/e2e/nested-1/nested-2/nested-3/**/*
└────────────────────────────────────────────────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: spec.cy.js (1 of 3)
Running: spec.cy.js (1 of 4)
stdout_specfile_display_spec
@@ -316,7 +316,7 @@ exports['e2e stdout displays fullname of nested specfile 1'] = `
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: stdout_specfile.cy.js (2 of 3)
Running: stdout_specfile.cy.js (2 of 4)
stdout_specfile_display_spec
@@ -349,7 +349,7 @@ exports['e2e stdout displays fullname of nested specfile 1'] = `
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: stdout_specfile_display_spec_with_a_really_long_name_that_never_has_ (3 of 3)
Running: stdout_specfile_display_spec_with_a_really_long_name_that_never_has_ (3 of 4)
a_line_break_or_new_line.cy.js
@@ -391,6 +391,39 @@ exports['e2e stdout displays fullname of nested specfile 1'] = `
ne.cy.js.mp4
────────────────────────────────────────────────────────────────────────────────────────────────────
Running: nested-4/spec.cy.js (4 of 4)
stdout_specfile_display_spec
✓ passes
1 passing
(Results)
┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Tests: 1 │
│ Passing: 1 │
│ Failing: 0 │
│ Pending: 0 │
│ Skipped: 0 │
│ Screenshots: 0 │
│ Video: true │
│ Duration: X seconds │
│ Spec Ran: nested-4/spec.cy.js │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
(Video)
- Started processing: Compressing to 32 CRF
- Finished processing: /XXX/XXX/XXX/cypress/videos/nested-4/spec.cy.js.mp4 (X second)
====================================================================================================
(Run Finished)
@@ -405,8 +438,10 @@ exports['e2e stdout displays fullname of nested specfile 1'] = `
│ ✔ stdout_specfile_display_spec_with_a XX:XX 1 1 - - - │
│ _really_long_name_that_never_has_a_ │
│ line_break_or_new_line.cy.js │
├────────────────────────────────────────────────────────────────────────────────────────────────┤
│ ✔ nested-4/spec.cy.js XX:XX 1 1 - - - │
└────────────────────────────────────────────────────────────────────────────────────────────────┘
✔ All specs passed! XX:XX 3 3 - - -
✔ All specs passed! XX:XX 4 4 - - -
`

View File

@@ -0,0 +1,96 @@
import { AppComponent } from './app.component'
const ExcludedTestTitle = 'should not exist'
// Validating Mocha syntax and behavior of *.only is still valid after being patched by `zone.js/testing`
// Github Issue: https://github.com/cypress-io/cypress/issues/23409
describe('only', () => {
context.only('01 - executions', () => {
describe('suite', () => {
suite.only('should exist on "suite"', () => {
it('succeeds', () => {})
})
it(ExcludedTestTitle, () => {})
})
describe('describe', () => {
describe.only('should exist on "describe"', () => {
it('succeeds', () => {})
})
it(ExcludedTestTitle, () => {})
})
describe('context', () => {
context.only('should exist on "context"', () => {
it('succeeds', () => {})
})
it(ExcludedTestTitle, () => {})
})
describe('specify', () => {
specify.only('should exist on "specify"', () => {})
it(ExcludedTestTitle, () => {})
})
describe('test', () => {
test.only('should exist on "test"', () => {})
it(ExcludedTestTitle, () => {})
})
describe('it', () => {
it.only('should exist on "it"', () => {})
it(ExcludedTestTitle, () => {})
})
})
context.only('02 - validations', () => {
const verifyNotPresent = (title: string) => {
cy.wrap(Cypress.$(window.top!.document.body)).within(() =>
cy
.contains(title)
.should('not.exist')
)
}
describe('suite', () => {
it('should not include other test', () => {
verifyNotPresent(ExcludedTestTitle)
})
})
describe('describe', () => {
it('should not include other test', () => {
verifyNotPresent(ExcludedTestTitle)
})
})
describe('context', () => {
it('should not include other test', () => {
verifyNotPresent(ExcludedTestTitle)
})
})
describe('specify', () => {
it('should not include other test', () => {
verifyNotPresent(ExcludedTestTitle)
})
})
describe('test', () => {
it('should not include other test', () => {
verifyNotPresent(ExcludedTestTitle)
})
})
describe('it', () => {
it('should not include other test', () => {
verifyNotPresent(ExcludedTestTitle)
})
})
})
})
it('empty passing test', () => {})

View File

@@ -0,0 +1,86 @@
import { AppComponent } from './app.component'
// Validating Mocha syntax and behavior of *.skip is still valid after being patched by `zone.js/testing`
// Github Issue: https://github.com/cypress-io/cypress/issues/23409
describe('skip', () => {
context('01 - executions', () => {
describe('suite', () => {
suite.skip('should exist on "suite"', () => {
it('skipped', () => {})
})
})
describe('describe', () => {
describe.skip('should exist on "describe"', () => {
it('skipped', () => {})
})
})
describe('context', () => {
context.skip('should exist on "context"', () => {
it('skipped', () => {})
})
})
describe('specify', () => {
specify.skip('should exist on "specify"', () => {})
})
describe('test', () => {
test.skip('should exist on "test"', () => {})
})
describe('it', () => {
it.skip('should exist on "it"', () => {})
})
})
context('02 - validations', () => {
const verifyWasSkipped = (title: string) => {
cy.wrap(Cypress.$(window.top!.document.body)).within(() =>
cy
.contains(title)
.parents('[data-model-state="pending"]') // Find parent row with class indicating test was skipped
.should('be.visible')
)
}
describe('suite', () => {
it('should have been skipped', () => {
verifyWasSkipped('should exist on "suite"')
})
})
describe('describe', () => {
it('should have been skipped', () => {
verifyWasSkipped('should exist on "describe"')
})
})
describe('context', () => {
it('should have been skipped', () => {
verifyWasSkipped('should exist on "context"')
})
})
describe('specify', () => {
it('should have been skipped', () => {
verifyWasSkipped('should exist on "specify"')
})
})
describe('test', () => {
it('should have been skipped', () => {
verifyWasSkipped('should exist on "test"')
})
})
describe('it', () => {
it('should have been skipped', () => {
verifyWasSkipped('should exist on "it"')
})
})
})
})
it('empty passing test', () => {})

View File

@@ -0,0 +1,3 @@
describe('stdout_specfile_display_spec', () => {
it('passes', () => {})
})

View File

@@ -113,7 +113,11 @@ describe(`Angular CLI major versions`, () => {
systemTests.setup()
for (const majorVersion of ANGULAR_MAJOR_VERSIONS) {
const spec = `${majorVersion === '14' ? 'src/app/components/standalone.component.cy.ts,src/app/mount.cy.ts' : 'src/app/mount.cy.ts'}`
let spec = 'src/**/*.cy.ts'
if (majorVersion === '13') {
spec = `${spec},!src/app/components/standalone.component.cy.ts`
}
systemTests.it(`v${majorVersion} with mount tests`, {
project: `angular-${majorVersion}`,

View File

@@ -66,7 +66,7 @@ describe('e2e stdout', () => {
return systemTests.exec(this, {
port: 2020,
snapshot: true,
spec: 'nested-1/nested-2/nested-3/*',
spec: 'nested-1/nested-2/nested-3/**/*',
})
})