Merge branch 'develop' into sessions-instrument-panel

This commit is contained in:
Emily Rohrbough
2022-09-16 11:04:56 -05:00
committed by GitHub
74 changed files with 1474 additions and 293 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

@@ -1,10 +1,10 @@
import Bluebird from 'bluebird'
import { EventEmitter } from 'events'
import type { MobxRunnerStore } from '@packages/app/src/store/mobx-runner-store'
import type { RunState } from '@packages/types/src/driver'
import type MobX from 'mobx'
import type { LocalBusEmitsMap, LocalBusEventMap, DriverToLocalBus, SocketToDriverMap } from './event-manager-types'
import type { AutomationElementId, FileDetails } from '@packages/types'
import type { RunState, CachedTestState, AutomationElementId, FileDetails, ReporterStartInfo, ReporterRunState } from '@packages/types'
import { logger } from './logger'
import type { Socket } from '@packages/socket/lib/browser'
@@ -87,8 +87,7 @@ export class EventManager {
const rerun = () => {
if (!this) {
// if the tests have been reloaded
// then there is nothing to rerun
// if the tests have been reloaded then there is nothing to rerun
return
}
@@ -252,7 +251,7 @@ export class EventManager {
this.reporterBus.on('clear:all:sessions', () => {
if (!Cypress) return
Cypress.backend('clear:session')
Cypress.backend('clear:sessions', true)
.then(rerun)
})
@@ -336,8 +335,8 @@ export class EventManager {
// @ts-ignore
const $window = this.$CypressDriver.$(window)
// This is a test-only even. It's used to
// trigger a re-reun for the drive rerun.cy.js spec.
// This is a test-only event. It's used to
// trigger a rerun for the driver rerun.cy.js spec.
$window.on('test:trigger:rerun', rerun)
// when we actually unload then
@@ -395,9 +394,9 @@ export class EventManager {
return Cypress.initialize({
$autIframe,
onSpecReady: () => {
// get the current runnable in case we reran mid-test due to a visit
// to a new domain
this.ws.emit('get:existing:run:state', (state: RunState = {}) => {
// get the current runnable states and cached test state
// in case we reran mid-test due to a visit to a new domain
this.ws.emit('get:cached:test:state', (runState: RunState = {}, testState: CachedTestState) => {
if (!Cypress.runner) {
// the tests have been reloaded
return
@@ -405,40 +404,40 @@ export class EventManager {
const hideCommandLog = window.__CYPRESS_CONFIG__.hideCommandLog
this.studioStore.initialize(config, state)
this.studioStore.initialize(config, runState)
const runnables = Cypress.runner.normalizeAll(state.tests, hideCommandLog)
const runnables = Cypress.runner.normalizeAll(runState.tests, hideCommandLog)
const run = () => {
performance.mark('initialize-end')
performance.measure('initialize', 'initialize-start', 'initialize-end')
this._runDriver(state)
this._runDriver(runState, testState)
}
if (!hideCommandLog) {
this.reporterBus.emit('runnables:ready', runnables)
}
if (state?.numLogs) {
Cypress.runner.setNumLogs(state.numLogs)
if (runState?.numLogs) {
Cypress.runner.setNumLogs(runState.numLogs)
}
if (state.startTime) {
Cypress.runner.setStartTime(state.startTime)
if (runState.startTime) {
Cypress.runner.setStartTime(runState.startTime)
}
if (config.isTextTerminal && !state.currentId) {
if (config.isTextTerminal && !runState.currentId) {
// we are in run mode and it's the first load
// store runnables in backend and maybe send to dashboard
return this.ws.emit('set:runnables:and:maybe:record:tests', runnables, run)
}
if (state.currentId) {
if (runState.currentId) {
// if we have a currentId it means
// we need to tell the Cypress to skip
// ahead to that test
Cypress.runner.resumeAtTest(state.currentId, state.emissions)
Cypress.runner.resumeAtTest(runState.currentId, runState.emissions)
}
return run()
@@ -464,7 +463,7 @@ export class EventManager {
}
return new Bluebird((resolve) => {
this.reporterBus.emit('reporter:collect:run:state', (reporterState) => {
this.reporterBus.emit('reporter:collect:run:state', (reporterState: ReporterRunState) => {
resolve({
...reporterState,
studio: {
@@ -749,9 +748,9 @@ export class EventManager {
window.top.addEventListener('message', crossOriginOnMessageRef, false)
}
_runDriver (state) {
_runDriver (runState: RunState, testState: CachedTestState) {
performance.mark('run-s')
Cypress.run(() => {
Cypress.run(testState, () => {
performance.mark('run-e')
performance.measure('run', 'run-s', 'run-e')
})
@@ -760,14 +759,14 @@ export class EventManager {
this.reporterBus.emit('reporter:start', {
startTime: Cypress.runner.getStartTime(),
numPassed: state.passed,
numFailed: state.failed,
numPending: state.pending,
autoScrollingEnabled: state.autoScrollingEnabled,
isSpecsListOpen: state.isSpecsListOpen,
scrollTop: state.scrollTop,
numPassed: runState.passed,
numFailed: runState.failed,
numPending: runState.pending,
autoScrollingEnabled: runState.autoScrollingEnabled,
isSpecsListOpen: runState.isSpecsListOpen,
scrollTop: runState.scrollTop,
studioActive: hasRunnableId,
})
} as ReporterStartInfo)
}
stop () {
@@ -783,8 +782,8 @@ export class EventManager {
state.setIsLoading(true)
if (!isRerun) {
// only clear session state when a new spec is selected
Cypress.backend('reset:session:state')
// only clear test state when a new spec is selected
Cypress.backend('reset:cached:test:state')
}
// when we are re-running we first need to stop cypress always

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

@@ -527,6 +527,7 @@ export class ProjectConfigManager {
}
async getFullInitialConfig (options: Partial<AllModeOptions> = this.options.ctx.modeOptions, withBrowsers = true): Promise<FullConfig> {
// return cached configuration for new spec and/or new navigating load when Cypress is running tests
if (this._cachedFullConfig) {
return this._cachedFullConfig
}

View File

@@ -120,10 +120,7 @@ export class HtmlDataSource {
window.__CYPRESS_CONFIG__ = ${JSON.stringify(serveConfig)};
window.__CYPRESS_TESTING_TYPE__ = '${this.ctx.coreData.currentTestingType}'
window.__CYPRESS_BROWSER__ = ${JSON.stringify(this.ctx.coreData.activeBrowser)}
${process.env.CYPRESS_INTERNAL_GQL_NO_SOCKET
? `window.__CYPRESS_GQL_NO_SOCKET__ = 'true';`
: ''
}
${process.env.CYPRESS_INTERNAL_GQL_NO_SOCKET ? `window.__CYPRESS_GQL_NO_SOCKET__ = 'true';` : ''}
</script>
`)
}

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

@@ -237,6 +237,7 @@ describe('src/cy/commands/sessions/manager.ts', () => {
validate: undefined,
cookies: null,
localStorage: null,
sessionStorage: null,
hydrated: false,
})
@@ -245,7 +246,7 @@ describe('src/cy/commands/sessions/manager.ts', () => {
})
it('sessions.clearAllSavedSessions()', async () => {
const cypressSpy = cy.stub(CypressInstance, 'backend').callThrough().withArgs('clear:session').resolves(null)
const cypressSpy = cy.stub(CypressInstance, 'backend').callThrough().withArgs('clear:sessions', true).resolves(null)
const sessionsManager = new SessionsManager(CypressInstance, () => {})
const sessionsSpy = cy.stub(sessionsManager, 'clearActiveSessions')
@@ -253,7 +254,7 @@ describe('src/cy/commands/sessions/manager.ts', () => {
await sessionsManager.sessions.clearAllSavedSessions()
expect(sessionsSpy).to.be.calledOnce
expect(cypressSpy).to.be.calledOnceWith('clear:session', null)
expect(cypressSpy).to.be.calledOnceWith('clear:sessions', true)
})
describe('.clearCurrentSessionData()', () => {

View File

@@ -10,6 +10,8 @@ import { bothUrlsMatchAndOneHasHash } from '../navigation'
import { $Location, LocationObject } from '../../cypress/location'
import { isRunnerAbleToCommunicateWithAut } from '../../util/commandAUTCommunication'
import type { RunState } from '@packages/types'
import debugFn from 'debug'
const debug = debugFn('cypress:driver:navigation')
@@ -1116,26 +1118,26 @@ export default (Commands, Cypress, cy, state, config) => {
// tell our backend we're changing origins
// TODO: add in other things we want to preserve
// state for like scrollTop
let s: Record<string, any> = {
let runState: RunState = {
currentId: id,
tests: Cypress.runner.getTestsState(),
startTime: Cypress.runner.getStartTime(),
emissions: Cypress.runner.getEmissions(),
}
s.passed = Cypress.runner.countByTestState(s.tests, 'passed')
s.failed = Cypress.runner.countByTestState(s.tests, 'failed')
s.pending = Cypress.runner.countByTestState(s.tests, 'pending')
s.numLogs = LogUtils.countLogsByTests(s.tests)
runState.passed = Cypress.runner.countByTestState(runState.tests, 'passed')
runState.failed = Cypress.runner.countByTestState(runState.tests, 'failed')
runState.pending = Cypress.runner.countByTestState(runState.tests, 'pending')
runState.numLogs = LogUtils.countLogsByTests(runState.tests)
return Cypress.action('cy:collect:run:state')
.then((a = []) => {
.then((otherRunStates = []) => {
// merge all the states together holla'
s = _.reduce(a, (memo, obj) => {
runState = _.reduce(otherRunStates, (memo, obj) => {
return _.extend(memo, obj)
}, s)
}, runState)
return Cypress.backend('preserve:run:state', s)
return Cypress.backend('preserve:run:state', runState)
})
.then(() => {
// and now we must change the url to be the new

View File

@@ -1,6 +1,6 @@
import _ from 'lodash'
import { $Location } from '../../../cypress/location'
import type { ServerSessionData } from '@packages/types'
import {
getCurrentOriginStorage,
setPostMessageLocalStorage,
@@ -120,6 +120,7 @@ export default class SessionsManager {
id: options.id,
cookies: null,
localStorage: null,
sessionStorage: null,
setup: options.setup,
hydrated: false,
validate: options.validate,
@@ -132,8 +133,9 @@ export default class SessionsManager {
clearAllSavedSessions: async () => {
this.clearActiveSessions()
const clearAllSessions = true
return this.Cypress.backend('clear:session', null)
return this.Cypress.backend('clear:sessions', clearAllSessions)
},
clearCurrentSessionData: async () => {
@@ -205,7 +207,7 @@ export default class SessionsManager {
}
},
getSession: (id: string) => {
getSession: (id: string): Promise<ServerSessionData> => {
return this.Cypress.backend('get:session', id)
},

View File

@@ -1,7 +1,7 @@
import _ from 'lodash'
import $ from 'jquery'
import { $Location } from '../../../cypress/location'
import Bluebird from 'bluebird'
import { $Location } from '../../../cypress/location'
type SessionData = Cypress.Commands.Session.SessionData

View File

@@ -42,6 +42,8 @@ import * as resolvers from './cypress/resolvers'
import { PrimaryOriginCommunicator, SpecBridgeCommunicator } from './cross-origin/communicator'
import { setupAutEventHandlers } from './cypress/aut_event_handlers'
import type { CachedTestState } from '@packages/types'
const debug = debugFn('cypress:driver:cypress')
declare global {
@@ -280,11 +282,13 @@ class $Cypress {
}
}
run (fn) {
run (cachedTestState: CachedTestState, fn) {
if (!this.runner) {
$errUtils.throwErrByPath('miscellaneous.no_runner')
}
this.state(cachedTestState)
return this.runner.run(fn)
}

View File

@@ -6,7 +6,7 @@ declare namespace Cypress {
type SessionSetup = (log: Cypress.Log) => Chainable<S>
type SessionValidation = (log: Cypress.Log) => Chainable<S>
interface LocalStorage {
interface Storage {
origin: string
value: Record<string, any>
}
@@ -14,7 +14,8 @@ declare namespace Cypress {
interface SessionData {
id: string
cookies?: Array<Cypress.Cookie> | null
localStorage?: Array<LocalStorage> | null
localStorage?: Array<Storage> | null
sessionStorage?: Array<Storage> | null
setup: () => void
hydrated: boolean
validate?: Cypress.SessionOptions['validate']

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

@@ -36,8 +36,8 @@ const debug = null
// https://github.com/cypress-io/cypress/issues/1756
const zlibOptions = {
flush: zlib.Z_SYNC_FLUSH,
finishFlush: zlib.Z_SYNC_FLUSH,
flush: zlib.constants.Z_SYNC_FLUSH,
finishFlush: zlib.constants.Z_SYNC_FLUSH,
}
// https://github.com/cypress-io/cypress/issues/1543

View File

@@ -3,12 +3,7 @@ import { action, computed, observable } from 'mobx'
import { TestState } from '../test/test-model'
import { IntervalID } from '../lib/types'
export interface StatsStoreStartInfo {
startTime: string
numPassed?: number
numFailed?: number
numPending?: number
}
import type { StatsStoreStartInfo } from '@packages/types'
const defaults = {
numPassed: 0,

View File

@@ -2,10 +2,12 @@ import { EventEmitter } from 'events'
import { action } from 'mobx'
import appState, { AppState } from './app-state'
import runnablesStore, { RunnablesStore, RootRunnable, LogProps } from '../runnables/runnables-store'
import statsStore, { StatsStore, StatsStoreStartInfo } from '../header/stats-store'
import statsStore, { StatsStore } from '../header/stats-store'
import scroller, { Scroller } from './scroller'
import TestModel, { UpdatableTestProps, UpdateTestCallback, TestProps } from '../test/test-model'
import type { ReporterStartInfo, ReporterRunState } from '@packages/types'
const localBus = new EventEmitter()
interface InitEvent {
@@ -32,16 +34,7 @@ export interface Events {
__off: (() => void)
}
interface StartInfo extends StatsStoreStartInfo {
autoScrollingEnabled: boolean
scrollTop: number
studioActive: boolean
}
type CollectRunStateCallback = (arg: {
autoScrollingEnabled: boolean
scrollTop: number
}) => void
type CollectRunStateCallback = (arg: ReporterRunState) => void
const events: Events = {
appState,
@@ -88,7 +81,7 @@ const events: Events = {
}
}))
runner.on('reporter:start', action('start', (startInfo: StartInfo) => {
runner.on('reporter:start', action('start', (startInfo: ReporterStartInfo) => {
appState.temporarilySetAutoScrolling(startInfo.autoScrollingEnabled)
runnablesStore.setInitialScrollTop(startInfo.scrollTop)
appState.setStudioActive(startInfo.studioActive)

View File

@@ -1,5 +0,0 @@
// Studio tests have been removed with v10 update.
// You can find the tests in the PR below.
// @see https://github.com/cypress-io/cypress/pull/9542
export * from './studio-recorder'

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

@@ -2,10 +2,6 @@ import _ from 'lodash'
import type { ResolvedFromConfig } from '@packages/types'
import * as configUtils from '@packages/config'
export const setupFullConfigWithDefaults = configUtils.setupFullConfigWithDefaults
export const updateWithPluginValues = configUtils.updateWithPluginValues
export const setUrls = configUtils.setUrls
export function getResolvedRuntimeConfig (config, runtimeConfig) {

View File

@@ -140,7 +140,7 @@ export const getExperiments = (project: CypressProject, names = experimental.nam
}
/**
* Whilelist 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

@@ -1,33 +1,47 @@
import type { CyCookie } from './browsers/cdp_automation'
import type { ServerSessionData, StoredSessions } from '@packages/types'
interface SessionData {
cookies: CyCookie[]
id: string
localStorage: object
sessionStorage: object
}
const state = {
sessions: {},
type State = {
globalSessions: StoredSessions
specSessions: StoredSessions
}
export function saveSession (data: SessionData) {
const state: State = {
globalSessions: {},
specSessions: {},
}
export function saveSession (data: ServerSessionData): void {
if (!data.id) throw new Error('session data had no id')
state.sessions[data.id] = data
if (data.cacheAcrossSpecs) {
state.globalSessions[data.id] = data
return
}
state.specSessions[data.id] = data
}
export function getSession (id: string): SessionData {
const session = state.sessions[id]
export function getActiveSessions (): StoredSessions {
return state.globalSessions
}
export function getSession (id: string): ServerSessionData {
const session = state.globalSessions[id] || state.specSessions[id]
if (!session) throw new Error(`session with id "${id}" not found`)
return session
}
export function getState () {
export function getState (): State {
return state
}
export function clearSessions () {
state.sessions = {}
export function clearSessions (clearAllSessions: boolean = false): void {
state.specSessions = {}
if (clearAllSessions) {
state.globalSessions = {}
}
}

View File

@@ -26,22 +26,13 @@ import runEvents from './plugins/run_events'
// eslint-disable-next-line no-duplicate-imports
import type { Socket } from '@packages/socket'
import type { RunState, CachedTestState } from '@packages/types'
type StartListeningCallbacks = {
onSocketConnection: (socket: any) => void
}
type RunnerEvent =
'reporter:restart:test:run'
| 'runnables:ready'
| 'run:start'
| 'test:before:run:async'
| 'reporter:log:add'
| 'reporter:log:state:changed'
| 'paused'
| 'test:after:hooks'
| 'run:end'
const runnerEvents: RunnerEvent[] = [
const runnerEvents = [
'reporter:restart:test:run',
'runnables:ready',
'run:start',
@@ -51,18 +42,9 @@ const runnerEvents: RunnerEvent[] = [
'paused',
'test:after:hooks',
'run:end',
]
] as const
type ReporterEvent =
'runner:restart'
| 'runner:abort'
| 'runner:console:log'
| 'runner:console:error'
| 'runner:show:snapshot'
| 'runner:hide:snapshot'
| 'reporter:restarted'
const reporterEvents: ReporterEvent[] = [
const reporterEvents = [
// "go:to:file"
'runner:restart',
'runner:abort',
@@ -71,7 +53,7 @@ const reporterEvents: ReporterEvent[] = [
'runner:show:snapshot',
'runner:hide:snapshot',
'reporter:restarted',
]
] as const
const debug = Debug('cypress:server:socket-base')
@@ -156,7 +138,7 @@ export class SocketBase {
options,
callbacks: StartListeningCallbacks,
) {
let existingState = null
let runState: RunState | undefined = undefined
_.defaults(options, {
socketId: null,
@@ -350,7 +332,6 @@ export class SocketBase {
})
// TODO: what to do about runner disconnections?
socket.on('spec:changed', (spec) => {
return options.onSpecChanged(spec)
})
@@ -402,8 +383,7 @@ export class SocketBase {
})
}
// retry for up to data.timeout
// or 1 second
// retry for up to data.timeout or 1 second
return Bluebird
.try(tryConnected)
.timeout(data.timeout != null ? data.timeout : 1000)
@@ -428,7 +408,7 @@ export class SocketBase {
switch (eventName) {
case 'preserve:run:state':
existingState = args[0]
runState = args[0]
return null
case 'resolve:url': {
@@ -467,21 +447,20 @@ export class SocketBase {
return task.run(cfgFile ?? null, args[0])
case 'save:session':
return session.saveSession(args[0])
case 'clear:session':
return session.clearSessions()
case 'clear:sessions':
return session.clearSessions(args[0])
case 'get:session':
return session.getSession(args[0])
case 'reset:session:state':
case 'reset:cached:test:state':
runState = undefined
cookieJar.removeAllCookies()
session.clearSessions()
resetRenderedHTMLOrigins()
return
return resetRenderedHTMLOrigins()
case 'get:rendered:html:origins':
return options.getRenderedHTMLOrigins()
case 'reset:rendered:html:origins': {
case 'reset:rendered:html:origins':
return resetRenderedHTMLOrigins()
}
case 'cross:origin:automation:cookies:received':
return this.localBus.emit('cross:origin:automation:cookies:received')
default:
@@ -497,16 +476,18 @@ export class SocketBase {
})
})
socket.on('get:existing:run:state', (cb) => {
const s = existingState
socket.on('get:cached:test:state', (cb: (runState: RunState | null, testState: CachedTestState) => void) => {
const s = runState
if (s) {
existingState = null
return cb(s)
const cachedTestState: CachedTestState = {
activeSessions: session.getActiveSessions(),
}
return cb()
if (s) {
runState = undefined
}
return cb(s || {}, cachedTestState)
})
socket.on('save:app:state', (state, cb) => {
@@ -547,7 +528,7 @@ export class SocketBase {
// todo(lachlan): post 10.0 we should not pass the
// editor (in the `fileDetails.where` key) from the
// front-end, but rather rely on the server context
// to grab the prefered editor, like I'm doing here,
// to grab the preferred editor, like I'm doing here,
// so we do not need to
// maintain two sources of truth for the preferred editor
// adding this conditional to maintain backwards compat with

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

@@ -17,6 +17,7 @@ const { SocketE2E } = require(`../../lib/socket-e2e`)
const httpsServer = require(`@packages/https-proxy/test/helpers/https_server`)
const SseStream = require('ssestream')
const EventSource = require('eventsource')
const { setupFullConfigWithDefaults } = require('@packages/config')
const config = require(`../../lib/config`)
const { ServerE2E } = require(`../../lib/server-e2e`)
const pluginsModule = require(`../../lib/plugins`)
@@ -107,7 +108,7 @@ describe('Routes', () => {
// get all the config defaults
// and allow us to override them
// for each test
return config.setupFullConfigWithDefaults(obj, getCtx().file.getFilesByGlob)
return setupFullConfigWithDefaults(obj, getCtx().file.getFilesByGlob)
.then((cfg) => {
// use a jar for each test
// but reset it automatically

View File

@@ -5,6 +5,7 @@ const http = require('http')
const rp = require('@cypress/request-promise')
const Promise = require('bluebird')
const evilDns = require('evil-dns')
const { setupFullConfigWithDefaults } = require('@packages/config')
const httpsServer = require(`@packages/https-proxy/test/helpers/https_server`)
const config = require(`../../lib/config`)
const { ServerE2E } = require(`../../lib/server-e2e`)
@@ -47,7 +48,7 @@ describe('Server', () => {
// get all the config defaults
// and allow us to override them
// for each test
return config.setupFullConfigWithDefaults(obj, getCtx().file.getFilesByGlob)
return setupFullConfigWithDefaults(obj, getCtx().file.getFilesByGlob)
.then((cfg) => {
// use a jar for each test
// but reset it automatically

View File

@@ -21,7 +21,7 @@ const { createRoutes } = require(`../../lib/routes`)
process.env.CYPRESS_INTERNAL_ENV = 'development'
const CA = require('@packages/https-proxy').CA
const Config = require('../../lib/config')
const { setupFullConfigWithDefaults } = require('@packages/config')
const { ServerE2E } = require('../../lib/server-e2e')
const { SocketE2E } = require('../../lib/socket-e2e')
const { _getArgs } = require('../../lib/browsers/chrome')
@@ -350,7 +350,7 @@ describe('Proxy Performance', function () {
https: { cert, key },
}).start(HTTPS_PROXY_PORT),
Config.setupFullConfigWithDefaults({
setupFullConfigWithDefaults({
projectRoot: '/tmp/a',
config: {
supportFile: false,

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

@@ -3,6 +3,7 @@ require('../spec_helper')
const path = require('path')
const chokidar = require('chokidar')
const pkg = require('@packages/root')
const { setupFullConfigWithDefaults } = require('@packages/config')
const Fixtures = require('@tooling/system-tests')
const { sinon } = require('../spec_helper')
const user = require(`../../lib/user`)
@@ -44,7 +45,7 @@ describe.skip('lib/project-base', () => {
.then((obj = {}) => {
({ projectId: this.projectId } = obj)
return config.setupFullConfigWithDefaults({ projectName: 'project', projectRoot: '/foo/bar' }, getCtx().file.getFilesByGlob)
return setupFullConfigWithDefaults({ projectName: 'project', projectRoot: '/foo/bar' }, getCtx().file.getFilesByGlob)
.then((config1) => {
this.config = config1
this.project = new ProjectBase({ projectRoot: this.todosPath, testingType: 'e2e' })

View File

@@ -5,7 +5,7 @@ const os = require('os')
const express = require('express')
const Promise = require('bluebird')
const { connect } = require('@packages/network')
const config = require(`../../lib/config`)
const { setupFullConfigWithDefaults } = require('@packages/config')
const { ServerE2E } = require(`../../lib/server-e2e`)
const { SocketE2E } = require(`../../lib/socket-e2e`)
const fileServer = require(`../../lib/file_server`)
@@ -22,7 +22,7 @@ describe('lib/server', () => {
beforeEach(function () {
this.server = new ServerE2E()
return config.setupFullConfigWithDefaults({ projectRoot: '/foo/bar/', config: { supportFile: false } }, getCtx().file.getFilesByGlob)
return setupFullConfigWithDefaults({ projectRoot: '/foo/bar/', config: { supportFile: false } }, getCtx().file.getFilesByGlob)
.then((cfg) => {
this.config = cfg
})
@@ -38,7 +38,7 @@ describe('lib/server', () => {
})
// TODO: Figure out correct configuration to run these tests and/or which ones we need to keep.
// The introducion of server-base/socket-base and the `ensureProp` function made unit testing
// The introduction of server-base/socket-base and the `ensureProp` function made unit testing
// the server difficult.
describe.skip('lib/server', () => {
beforeEach(function () {
@@ -51,7 +51,7 @@ describe.skip('lib/server', () => {
sinon.stub(fileServer, 'create').returns(this.fileServer)
return config.setupFullConfigWithDefaults({ projectRoot: '/foo/bar/' }, getCtx().file.getFilesByGlob)
return setupFullConfigWithDefaults({ projectRoot: '/foo/bar/' }, getCtx().file.getFilesByGlob)
.then((cfg) => {
this.config = cfg
this.server = new ServerE2E()

View File

@@ -3,21 +3,22 @@ require('../spec_helper')
const _ = require('lodash')
const path = require('path')
const Promise = require('bluebird')
const socketIo = require('@packages/socket/lib/browser')
const httpsAgent = require('https-proxy-agent')
const errors = require(`../../lib/errors`)
const { SocketE2E } = require(`../../lib/socket-e2e`)
const { ServerE2E } = require(`../../lib/server-e2e`)
const { Automation } = require(`../../lib/automation`)
const exec = require(`../../lib/exec`)
const preprocessor = require(`../../lib/plugins/preprocessor`)
const { fs } = require(`../../lib/util/fs`)
const socketIo = require('@packages/socket/lib/browser')
const Fixtures = require('@tooling/system-tests')
const firefoxUtil = require(`../../lib/browsers/firefox-util`).default
const { createRoutes } = require(`../../lib/routes`)
const { getCtx } = require(`../../lib/makeDataContext`)
const errors = require('../../lib/errors')
const { SocketE2E } = require('../../lib/socket-e2e')
const { ServerE2E } = require('../../lib/server-e2e')
const { Automation } = require('../../lib/automation')
const exec = require('../../lib/exec')
const preprocessor = require('../../lib/plugins/preprocessor')
const { fs } = require('../../lib/util/fs')
const session = require('../../lib/session')
const firefoxUtil = require('../../lib/browsers/firefox-util').default
const { createRoutes } = require('../../lib/routes')
const { getCtx } = require('../../lib/makeDataContext')
const { sinon } = require('../spec_helper')
let ctx
@@ -33,6 +34,7 @@ describe('lib/socket', () => {
sinon.stub(ctx.actions.project, 'initializeActiveProject')
Fixtures.scaffold()
session.clearSessions(true)
this.todosPath = Fixtures.projectPath('todos')
@@ -453,7 +455,7 @@ describe('lib/socket', () => {
})
})
context('on(get:fixture)', () => {
context('on(backend:request, get:fixture)', () => {
it('returns the fixture object', function (done) {
const cb = function (resp) {
expect(resp.response).to.deep.eq([
@@ -488,7 +490,7 @@ describe('lib/socket', () => {
})
})
context('on(http:request)', () => {
context('on(backend:request, http:request)', () => {
it('calls socket#onRequest', function (done) {
sinon.stub(this.options, 'onRequest').resolves({ foo: 'bar' })
@@ -512,7 +514,7 @@ describe('lib/socket', () => {
})
})
context('on(exec)', () => {
context('on(backend:request, exec)', () => {
it('calls exec#run with project root and options', function (done) {
const run = sinon.stub(exec, 'run').returns(Promise.resolve('Desktop Music Pictures'))
@@ -539,7 +541,7 @@ describe('lib/socket', () => {
})
})
context('on(firefox:force:gc)', () => {
context('on(backend:request, firefox:force:gc)', () => {
it('calls firefoxUtil#collectGarbage', function (done) {
sinon.stub(firefoxUtil, 'collectGarbage').resolves()
@@ -598,6 +600,180 @@ describe('lib/socket', () => {
})
})
})
context('on(backend:request, save:session)', () => {
it('saves spec sessions', function (done) {
const sessionData = {
id: 'spec',
cacheAcrossSpecs: false,
}
this.client.emit('backend:request', 'save:session', sessionData, () => {
const state = session.getState()
expect(state).to.deep.eq({
globalSessions: {},
specSessions: {
'spec': sessionData,
},
})
done()
})
})
it('saves global sessions', function (done) {
const sessionData = {
id: 'global',
cacheAcrossSpecs: true,
}
this.client.emit('backend:request', 'save:session', sessionData, () => {
const state = session.getState()
expect(state).to.deep.eq({
globalSessions: {
'global': sessionData,
},
specSessions: {},
})
done()
})
})
it('returns error if session data has no id', function (done) {
const sessionData = {}
this.client.emit('backend:request', 'save:session', sessionData, ({ error }) => {
expect(error.message).to.eq('session data had no id')
done()
})
})
})
context('on(backend:request, clear:sessions)', () => {
it('clears spec sessions', function (done) {
let state = session.getState()
state.globalSessions = {
global: { id: 'global' },
}
state.specSessions = {
spec: { id: 'spec' },
}
this.client.emit('backend:request', 'clear:sessions', false, () => {
expect(state).to.deep.eq({
globalSessions: {
'global': { id: 'global' },
},
specSessions: {},
})
done()
})
})
it('clears all sessions', function (done) {
let state = session.getState()
state.globalSessions = {
global: { id: 'global' },
}
state.specSessions = {
spec: { id: 'spec' },
}
this.client.emit('backend:request', 'clear:sessions', true, () => {
expect(state).to.deep.eq({
globalSessions: {},
specSessions: {},
})
done()
})
})
})
context('on(backend:request, get:session)', () => {
it('returns global session', function (done) {
const state = session.getState()
state.globalSessions = {
global: { id: 'global' },
}
this.client.emit('backend:request', 'get:session', 'global', ({ response, error }) => {
expect(error).to.be.undefined
expect(response).deep.eq({
id: 'global',
})
done()
})
})
it('returns spec session', function (done) {
const state = session.getState()
state.globalSessions = {}
state.specSessions = {
'spec': { id: 'spec' },
}
this.client.emit('backend:request', 'get:session', 'spec', ({ response, error }) => {
expect(error).to.be.undefined
expect(response).deep.eq({
id: 'spec',
})
done()
})
})
it('returns error when session does not exist', function (done) {
const state = session.getState()
state.globalSessions = {}
state.specSessions = {}
this.client.emit('backend:request', 'get:session', 1, ({ response, error }) => {
expect(response).to.be.undefined
expect(error.message).to.eq('session with id "1" not found')
done()
})
})
})
context('on(backend:request, reset:cached:test:state)', () => {
it('clears spec sessions', function (done) {
const state = session.getState()
state.globalSessions = {
global: { id: 'global' },
}
state.specSessions = {
local: { id: 'local' },
}
this.client.emit('backend:request', 'reset:cached:test:state', ({ error }) => {
expect(error).to.be.undefined
expect(state).to.deep.eq({
globalSessions: {
'global': { id: 'global' },
},
specSessions: {},
})
done()
})
})
})
})
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

@@ -1,19 +1,46 @@
export interface RunState {
import type { ReporterRunState, StudioRecorderState } from './reporter'
interface MochaRunnerState {
startTime?: number
currentId?: number
currentId?: number | null
emissions?: Emissions
tests?: unknown
tests?: Record<string, Cypress.ObjectLike>
passed?: number
failed?: number
pending?: number
numLogs?: number
}
export type RunState = MochaRunnerState & ReporterRunState & {
studio?: StudioRecorderState
isSpecsListOpen?: boolean
}
export interface Emissions {
started: Record<string, boolean>
ended: Record<string, boolean>
}
interface HtmlWebStorage {
origin: string
value: Record<string, any>
}
export interface ServerSessionData {
id: string
cacheAcrossSpecs: boolean
cookies: Cypress.Cookie[] | null
localStorage: Array<HtmlWebStorage> | null
sessionStorage: Array<HtmlWebStorage> | null
setup: string
}
export type StoredSessions = Record<string, ServerSessionData>
export interface CachedTestState {
activeSessions: StoredSessions
}
export type Instrument = 'agent' | 'command' | 'route'
export type TestState = 'active' | 'failed' | 'pending' | 'passed' | 'processing'

View File

@@ -28,6 +28,8 @@ export {
RESOLVED_FROM,
} from './config'
export * from './reporter'
export * from './server'
export * from './util'

View File

@@ -25,10 +25,7 @@ export const allowedKeys: Readonly<Array<keyof AllowedState>> = [
'firstOpenedCypress',
'showedStudioModal',
'preferredOpener',
'ctReporterWidth',
'ctIsSpecsListOpen',
'isSpecsListOpen',
'ctSpecListWidth',
'firstOpened',
'lastOpened',
'lastProjectId',
@@ -61,9 +58,6 @@ export type AllowedState = Partial<{
firstOpenedCypress: Maybe<number>
showedStudioModal: Maybe<boolean>
preferredOpener: Editor | undefined
ctReporterWidth: Maybe<number>
ctIsSpecsListOpen: Maybe<boolean>
ctSpecListWidth: Maybe<number>
lastProjectId: Maybe<string>
firstOpened: Maybe<number>
lastOpened: Maybe<number>

View File

@@ -0,0 +1,24 @@
export interface StudioRecorderState {
suiteId?: string
testId?: string
url?: string
}
export interface ReporterRunState {
autoScrollingEnabled?: boolean
scrollTop?: number
}
export interface StatsStoreStartInfo {
startTime: string
numPassed?: number
numFailed?: number
numPending?: number
}
export interface ReporterStartInfo extends StatsStoreStartInfo {
isSpecsListOpen: boolean
autoScrollingEnabled: boolean
scrollTop: number
studioActive: boolean
}

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/**/*',
})
})