diff --git a/packages/app/cypress/e2e/studio/studio-cloud.cy.ts b/packages/app/cypress/e2e/studio/studio-cloud.cy.ts
index ea3286fb4991..74149c8a1219 100644
--- a/packages/app/cypress/e2e/studio/studio-cloud.cy.ts
+++ b/packages/app/cypress/e2e/studio/studio-cloud.cy.ts
@@ -11,7 +11,7 @@ describe('Studio Cloud', () => {
})
})
- it('loads the studio UI correctly when studio bundle is taking too long to load', () => {
+ it('loads the legacy studio UI correctly when studio bundle is taking too long to load', () => {
loadProjectAndRunSpec({ enableCloudStudio: false })
cy.window().then(() => {
@@ -93,7 +93,8 @@ describe('Studio Cloud', () => {
it('hides selector playground and studio controls when studio beta is available', () => {
launchStudio({ enableCloudStudio: true })
- cy.get('[data-cy="studio-header-studio-button"]').click()
+
+ cy.findByTestId('studio-panel').should('be.visible')
cy.get('[data-cy="playground-activator"]').should('not.exist')
cy.get('[data-cy="studio-toolbar"]').should('not.exist')
@@ -102,6 +103,9 @@ describe('Studio Cloud', () => {
it('closes studio panel when clicking studio button (from the cloud)', () => {
launchStudio({ enableCloudStudio: true })
+ cy.findByTestId('studio-panel').should('be.visible')
+ cy.get('[data-cy="loading-studio-panel"]').should('not.exist')
+
cy.get('[data-cy="studio-header-studio-button"]').click()
assertClosingPanelWithoutChanges()
@@ -128,6 +132,13 @@ describe('Studio Cloud', () => {
body: { enabled: true },
})
+ // this endpoint gets called twice, so we need to mock it twice
+ cy.mockNodeCloudRequest({
+ url: '/studio/testgen/n69px6/enabled',
+ method: 'get',
+ body: { enabled: true },
+ })
+
const aiOutput = 'cy.get(\'button\').should(\'have.text\', \'Increment\')'
cy.mockNodeCloudStreamingRequest({
@@ -182,6 +193,9 @@ describe('Studio Cloud', () => {
cy.findByTestId('studio-panel')
cy.get('[data-cy="hook-name-studio commands"]')
+ // make sure studio is not loading
+ cy.get('[data-cy="loading-studio-panel"]').should('not.exist')
+
// Verify that AI is enabled
cy.get('[data-cy="ai-status-text"]').should('contain.text', 'Enabled')
diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue
index 6059e2fc2ca7..cc5bd3b1de4a 100644
--- a/packages/app/src/runner/SpecRunnerOpenMode.vue
+++ b/packages/app/src/runner/SpecRunnerOpenMode.vue
@@ -103,6 +103,7 @@
:can-access-studio-a-i="studioStore.canAccessStudioAI"
:on-studio-panel-close="handleStudioPanelClose"
:event-manager="eventManager"
+ :studio-status="studioStatus"
/>
@@ -111,7 +112,7 @@
diff --git a/packages/data-context/src/actions/DataEmitterActions.ts b/packages/data-context/src/actions/DataEmitterActions.ts
index 90215793c763..b6f4740ea5d1 100644
--- a/packages/data-context/src/actions/DataEmitterActions.ts
+++ b/packages/data-context/src/actions/DataEmitterActions.ts
@@ -90,6 +90,13 @@ abstract class DataEmitterEvents {
this._emit('specsChange')
}
+ /**
+ * Emitted when the studio manager's status changes
+ */
+ studioStatusChange () {
+ this._emit('studioStatusChange')
+ }
+
/**
* Emitted when then relevant run numbers changed after querying for matching
* runs based on local commit shas
diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql
index 96a22d7b7f05..09092fc5534e 100644
--- a/packages/graphql/schemas/schema.graphql
+++ b/packages/graphql/schemas/schema.graphql
@@ -2023,6 +2023,9 @@ type Query {
specPath: String!
): CloudProjectSpecResult
+ """Whether cloud studio is requested by the environment"""
+ cloudStudioRequested: Boolean
+
"""A user within the Cypress Cloud"""
cloudViewer: CloudUser
@@ -2069,11 +2072,6 @@ type Query {
"""The files that have just been scaffolded"""
scaffoldedFiles: [ScaffoldedFile!]
- """
- Data pertaining to studio and the studio manager that is loaded from the cloud
- """
- studio: Studio
-
"""Previous versions of cypress and their release date"""
versions: VersionData
@@ -2373,15 +2371,15 @@ enum SpecType {
integration
}
-"""The studio manager for the app"""
-type Studio {
- """The current status of the studio"""
- status: StudioStatusType
+type StudioStatusPayload {
+ canAccessStudioAI: Boolean!
+ status: StudioStatusType!
}
enum StudioStatusType {
ENABLED
INITIALIZED
+ INITIALIZING
IN_ERROR
NOT_INITIALIZED
}
@@ -2437,6 +2435,9 @@ type Subscription {
"""Issued when the watched specs for the project changes"""
specsChange: CurrentProject
+
+ """Status of the studio manager and AI access"""
+ studioStatusChange: StudioStatusPayload!
}
enum SupportStatusEnum {
diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts
index df38bd5b409a..7486dbc3a077 100644
--- a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts
+++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts
@@ -11,8 +11,6 @@ import { Wizard } from './gql-Wizard'
import { ErrorWrapper } from './gql-ErrorWrapper'
import { CachedUser } from './gql-CachedUser'
import { Cohort } from './gql-Cohorts'
-import { Studio } from './gql-Studio'
-import type { StudioStatusType } from '@packages/data-context/src/gen/graphcache-config.gen'
export const Query = objectType({
name: 'Query',
@@ -103,20 +101,10 @@ export const Query = objectType({
resolve: (source, args, ctx) => ctx.coreData.authState,
})
- t.field('studio', {
- type: Studio,
- description: 'Data pertaining to studio and the studio manager that is loaded from the cloud',
- resolve: async (source, args, ctx) => {
- const isStudioReady = ctx.coreData.studioLifecycleManager?.isStudioReady()
-
- if (!isStudioReady) {
- return { status: 'INITIALIZED' as StudioStatusType }
- }
-
- const studio = await ctx.coreData.studioLifecycleManager?.getStudio()
-
- return studio ? { status: studio.status } : null
- },
+ t.field('cloudStudioRequested', {
+ type: 'Boolean',
+ description: 'Whether cloud studio is requested by the environment',
+ resolve: (source, args, ctx) => ctx.coreData.studioLifecycleManager?.cloudStudioRequested ?? false,
})
t.nonNull.field('localSettings', {
diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Studio.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Studio.ts
index dd98b5be865d..c3abbe7c2d97 100644
--- a/packages/graphql/src/schemaTypes/objectTypes/gql-Studio.ts
+++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Studio.ts
@@ -1,18 +1,7 @@
import { STUDIO_STATUSES } from '@packages/types'
-import { enumType, objectType } from 'nexus'
+import { enumType } from 'nexus'
export const StudioStatusTypeEnum = enumType({
name: 'StudioStatusType',
members: STUDIO_STATUSES,
})
-
-export const Studio = objectType({
- name: 'Studio',
- description: 'The studio manager for the app',
- definition (t) {
- t.field('status', {
- type: StudioStatusTypeEnum,
- description: 'The current status of the studio',
- })
- },
-})
diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts
index 4992db81cbed..058a0264afa4 100644
--- a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts
+++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts
@@ -1,9 +1,20 @@
import type { PushFragmentData } from '@packages/data-context/src/actions'
import { enumType, idArg, list, nonNull, objectType, subscriptionType } from 'nexus'
-import { CurrentProject, DevState, Query, Wizard } from '.'
+import { CurrentProject, DevState, Query, StudioStatusTypeEnum, Wizard } from '.'
import { Spec } from './gql-Spec'
import { RelevantRun } from './gql-RelevantRun'
+export const StudioStatusPayload = objectType({
+ name: 'StudioStatusPayload',
+ definition (t) {
+ t.nonNull.field('status', {
+ type: StudioStatusTypeEnum,
+ })
+
+ t.nonNull.boolean('canAccessStudioAI')
+ },
+})
+
export const Subscription = subscriptionType({
definition (t) {
t.field('authChange', {
@@ -49,6 +60,38 @@ export const Subscription = subscriptionType({
resolve: (source, args, ctx) => ctx.lifecycleManager,
})
+ t.nonNull.field('studioStatusChange', {
+ type: StudioStatusPayload,
+ description: 'Status of the studio manager and AI access',
+ subscribe: (source, args, ctx) => ctx.emitter.subscribeTo('studioStatusChange'),
+ resolve: async (source, args, ctx) => {
+ const isStudioReady = ctx.coreData.studioLifecycleManager?.isStudioReady()
+
+ if (!isStudioReady) {
+ return {
+ status: 'INITIALIZING' as const,
+ canAccessStudioAI: false,
+ }
+ }
+
+ const studio = await ctx.coreData.studioLifecycleManager?.getStudio()
+
+ if (!studio) {
+ return {
+ status: 'NOT_INITIALIZED' as const,
+ canAccessStudioAI: false,
+ }
+ }
+
+ const canAccessStudioAI = studio.status === 'ENABLED' && ctx.coreData.activeBrowser && (await studio.canAccessStudioAI(ctx.coreData.activeBrowser as Cypress.Browser)) || false
+
+ return {
+ status: studio.status,
+ canAccessStudioAI,
+ }
+ },
+ })
+
t.field('configChange', {
type: CurrentProject,
description: 'Issued when cypress.config.js is re-executed due to a change',
diff --git a/packages/server/lib/StudioLifecycleManager.ts b/packages/server/lib/StudioLifecycleManager.ts
index 97c092140b5f..fbc1157ff55f 100644
--- a/packages/server/lib/StudioLifecycleManager.ts
+++ b/packages/server/lib/StudioLifecycleManager.ts
@@ -12,6 +12,7 @@ import { CloudRequest } from './cloud/api/cloud_request'
import { isRetryableError } from './cloud/network/is_retryable_error'
import { asyncRetry } from './util/async_retry'
import { postStudioSession } from './cloud/api/studio/post_studio_session'
+import type { StudioStatus } from '@packages/types'
const debug = Debug('cypress:server:studio-lifecycle-manager')
const routes = require('./cloud/routes')
@@ -20,6 +21,13 @@ export class StudioLifecycleManager {
private studioManagerPromise?: Promise
private studioManager?: StudioManager
private listeners: ((studioManager: StudioManager) => void)[] = []
+ private ctx?: DataContext
+ private lastStatus?: StudioStatus
+
+ public get cloudStudioRequested () {
+ return !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH)
+ }
+
/**
* Initialize the studio manager and possibly set up protocol.
* Also registers this instance in the data context.
@@ -42,7 +50,14 @@ export class StudioLifecycleManager {
debugData: any
ctx: DataContext
}): void {
- debug('Initializing studio manager')
+ // Register this instance in the data context
+ ctx.update((data) => {
+ data.studioLifecycleManager = this
+ })
+
+ this.ctx = ctx
+
+ this.updateStatus('INITIALIZING')
const studioManagerPromise = this.createStudioManager({
projectId,
@@ -71,6 +86,8 @@ export class StudioLifecycleManager {
studioMethodArgs: [],
})
+ this.updateStatus('IN_ERROR')
+
// Clean up any registered listeners
this.listeners = []
@@ -78,11 +95,6 @@ export class StudioLifecycleManager {
})
this.studioManagerPromise = studioManagerPromise
-
- // Register this instance in the data context
- ctx.update((data) => {
- data.studioLifecycleManager = this
- })
}
isStudioReady (): boolean {
@@ -94,7 +106,13 @@ export class StudioLifecycleManager {
throw new Error('Studio manager has not been initialized')
}
- return await this.studioManagerPromise
+ const studioManager = await this.studioManagerPromise
+
+ if (studioManager) {
+ this.updateStatus(studioManager.status)
+ }
+
+ return studioManager
}
private async createStudioManager ({
@@ -116,6 +134,8 @@ export class StudioLifecycleManager {
studioUrl: studioSession.studioUrl,
projectId,
cloudDataSource,
+ shouldEnableStudio: this.cloudStudioRequested,
+ lifecycleManager: this,
})
if (studioManager.status === 'ENABLED') {
@@ -146,6 +166,7 @@ export class StudioLifecycleManager {
debug('Studio is ready')
this.studioManager = studioManager
this.callRegisteredListeners()
+ this.updateStatus(studioManager.status)
return studioManager
}
@@ -179,4 +200,21 @@ export class StudioLifecycleManager {
this.listeners.push(listener)
}
}
+
+ public updateStatus (status: StudioStatus) {
+ if (status === this.lastStatus) {
+ debug('Studio status unchanged: %s', status)
+
+ return
+ }
+
+ debug('Studio status changed: %s → %s', this.lastStatus, status)
+ this.lastStatus = status
+
+ if (this.ctx) {
+ this.ctx?.emitter.studioStatusChange()
+ } else {
+ debug('No ctx available, cannot emit studioStatusChange')
+ }
+ }
}
diff --git a/packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts b/packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts
index c86bf7e04ea0..8a732f2e0e00 100644
--- a/packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts
+++ b/packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts
@@ -12,6 +12,7 @@ import { isRetryableError } from '../../network/is_retryable_error'
import { PUBLIC_KEY_VERSION } from '../../constants'
import { CloudRequest } from '../cloud_request'
import type { CloudDataSource } from '@packages/data-context/src/sources'
+import type { StudioLifecycleManagerShape } from '@packages/types'
interface Options {
studioUrl: string
@@ -22,11 +23,26 @@ const pkg = require('@packages/root')
const _delay = linearDelay(500)
+// Default timeout of 30 seconds for the download
+const DOWNLOAD_TIMEOUT_MS = 30000
+
export const studioPath = path.join(os.tmpdir(), 'cypress', 'studio')
const bundlePath = path.join(studioPath, 'bundle.tar')
const serverFilePath = path.join(studioPath, 'server', 'index.js')
+async function downloadStudioBundleWithTimeout (args: Options & { downloadTimeoutMs?: number }) {
+ const timeoutPromise = new Promise((_, reject) => {
+ setTimeout(() => {
+ reject(new Error('Cloud studio download timed out'))
+ }, args.downloadTimeoutMs || DOWNLOAD_TIMEOUT_MS)
+ })
+
+ const funcPromise = downloadStudioBundleToTempDirectory(args)
+
+ return Promise.race([funcPromise, timeoutPromise])
+}
+
const downloadStudioBundleToTempDirectory = async ({ studioUrl, projectId }: Options): Promise => {
let responseSignature: string | null = null
@@ -80,7 +96,7 @@ const downloadStudioBundleToTempDirectory = async ({ studioUrl, projectId }: Opt
}
}
-export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId }: Options): Promise<{ studioHash: string | undefined }> => {
+export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId, downloadTimeoutMs }: Options & { downloadTimeoutMs?: number }): Promise<{ studioHash: string | undefined }> => {
// The studio hash is the last part of the studio URL, after the last slash and before the extension
const studioHash = studioUrl.split('/').pop()?.split('.')[0]
@@ -99,7 +115,7 @@ export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId }: O
return { studioHash: undefined }
}
- await downloadStudioBundleToTempDirectory({ studioUrl, projectId })
+ await downloadStudioBundleWithTimeout({ studioUrl, projectId, downloadTimeoutMs })
await tar.extract({
file: bundlePath,
@@ -109,7 +125,7 @@ export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId }: O
return { studioHash }
}
-export const getAndInitializeStudioManager = async ({ studioUrl, projectId, cloudDataSource }: { studioUrl: string, projectId?: string, cloudDataSource: CloudDataSource }): Promise => {
+export const getAndInitializeStudioManager = async ({ studioUrl, projectId, cloudDataSource, shouldEnableStudio, downloadTimeoutMs, lifecycleManager }: { studioUrl: string, projectId?: string, cloudDataSource: CloudDataSource, shouldEnableStudio: boolean, downloadTimeoutMs?: number, lifecycleManager?: StudioLifecycleManagerShape }): Promise => {
let script: string
const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
@@ -119,7 +135,7 @@ export const getAndInitializeStudioManager = async ({ studioUrl, projectId, clou
let studioHash: string | undefined
try {
- ({ studioHash } = await retrieveAndExtractStudioBundle({ studioUrl, projectId }))
+ ({ studioHash } = await retrieveAndExtractStudioBundle({ studioUrl, projectId, downloadTimeoutMs }))
script = await readFile(serverFilePath, 'utf8')
@@ -137,7 +153,7 @@ export const getAndInitializeStudioManager = async ({ studioUrl, projectId, clou
isRetryableError,
asyncRetry,
},
- shouldEnableStudio: !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH),
+ shouldEnableStudio,
})
return studioManager
diff --git a/packages/server/lib/cloud/api/studio/report_studio_error.ts b/packages/server/lib/cloud/api/studio/report_studio_error.ts
index ed6a857f4999..112222b375a1 100644
--- a/packages/server/lib/cloud/api/studio/report_studio_error.ts
+++ b/packages/server/lib/cloud/api/studio/report_studio_error.ts
@@ -1,7 +1,6 @@
import type { StudioCloudApi } from '@packages/types/src/studio/studio-server-types'
import Debug from 'debug'
import { stripPath } from '../../strip_path'
-
const debug = Debug('cypress:server:cloud:api:studio:report_studio_errors')
export interface ReportStudioErrorOptions {
diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts
index e847be38bb6a..163342fb997a 100644
--- a/packages/server/lib/project-base.ts
+++ b/packages/server/lib/project-base.ts
@@ -431,10 +431,8 @@ export class ProjectBase extends EE {
const studio = await this.ctx.coreData.studioLifecycleManager?.getStudio()
- const isCloudStudio = !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH)
-
// only capture studio started event if the user is accessing legacy studio
- if (!isCloudStudio) {
+ if (!this.ctx.coreData.studioLifecycleManager?.cloudStudioRequested) {
try {
studio?.captureStudioEvent({
type: StudioMetricsTypes.STUDIO_STARTED,
diff --git a/packages/server/test/unit/StudioLifecycleManager_spec.ts b/packages/server/test/unit/StudioLifecycleManager_spec.ts
index 3b4de67cc92a..3022ecba01d9 100644
--- a/packages/server/test/unit/StudioLifecycleManager_spec.ts
+++ b/packages/server/test/unit/StudioLifecycleManager_spec.ts
@@ -25,6 +25,7 @@ describe('StudioLifecycleManager', () => {
let getCaptureProtocolScriptStub: sinon.SinonStub
let prepareProtocolStub: sinon.SinonStub
let reportStudioErrorStub: sinon.SinonStub
+ let studioStatusChangeEmitterStub: sinon.SinonStub
beforeEach(() => {
studioLifecycleManager = new StudioLifecycleManager()
@@ -34,6 +35,8 @@ describe('StudioLifecycleManager', () => {
status: 'INITIALIZED',
} as unknown as StudioManager
+ studioStatusChangeEmitterStub = sinon.stub()
+
mockCtx = {
update: sinon.stub(),
coreData: {},
@@ -41,6 +44,9 @@ describe('StudioLifecycleManager', () => {
getCloudUrl: sinon.stub().returns('https://cloud.cypress.io'),
additionalHeaders: sinon.stub().resolves({ 'Authorization': 'Bearer test-token' }),
},
+ emitter: {
+ studioStatusChange: studioStatusChangeEmitterStub,
+ },
} as unknown as DataContext
mockCloudDataSource = {} as CloudDataSource
@@ -74,6 +80,36 @@ describe('StudioLifecycleManager', () => {
sinon.restore()
})
+ describe('cloudStudioRequested', () => {
+ it('is true when CYPRESS_ENABLE_CLOUD_STUDIO is set', async () => {
+ process.env.CYPRESS_ENABLE_CLOUD_STUDIO = '1'
+ delete process.env.CYPRESS_LOCAL_STUDIO_PATH
+
+ expect(studioLifecycleManager.cloudStudioRequested).to.be.true
+ })
+
+ it('is true when CYPRESS_LOCAL_STUDIO_PATH is set', async () => {
+ delete process.env.CYPRESS_ENABLE_CLOUD_STUDIO
+ process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio'
+
+ expect(studioLifecycleManager.cloudStudioRequested).to.be.true
+ })
+
+ it('is false when neither env variable is set', async () => {
+ delete process.env.CYPRESS_ENABLE_CLOUD_STUDIO
+ delete process.env.CYPRESS_LOCAL_STUDIO_PATH
+
+ expect(studioLifecycleManager.cloudStudioRequested).to.be.false
+ })
+
+ it('is true when both env variables are set', async () => {
+ process.env.CYPRESS_ENABLE_CLOUD_STUDIO = '1'
+ process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio'
+
+ expect(studioLifecycleManager.cloudStudioRequested).to.be.true
+ })
+ })
+
describe('initializeStudioManager', () => {
it('initializes the studio manager and registers it in the data context', async () => {
studioLifecycleManager.initializeStudioManager({
@@ -150,8 +186,8 @@ describe('StudioLifecycleManager', () => {
studioLifecycleManager.registerStudioReadyListener(listener1)
studioLifecycleManager.registerStudioReadyListener(listener2)
- // @ts-ignore - accessing private property for testing
- expect(studioLifecycleManager['listeners'].length).to.equal(2)
+ // @ts-expect-error - accessing private property
+ expect(studioLifecycleManager.listeners.length).to.equal(2)
getAndInitializeStudioManagerStub.rejects(error)
@@ -176,7 +212,7 @@ describe('StudioLifecycleManager', () => {
expect(mockCtx.update).to.be.calledOnce
- // @ts-ignore - accessing private property for testing
+ // @ts-expect-error - accessing private property
const studioPromise = studioLifecycleManager.studioManagerPromise
expect(studioPromise).to.not.be.null
@@ -191,8 +227,8 @@ describe('StudioLifecycleManager', () => {
studioMethodArgs: [],
})
- // @ts-ignore - accessing private property for testing
- expect(studioLifecycleManager['listeners'].length).to.equal(0)
+ // @ts-expect-error - accessing private property
+ expect(studioLifecycleManager.listeners.length).to.equal(0)
expect(listener1).not.to.be.called
expect(listener2).not.to.be.called
@@ -211,7 +247,7 @@ describe('StudioLifecycleManager', () => {
})
it('returns true when studio has been initialized', async () => {
- // @ts-ignore - accessing private property for testing
+ // @ts-expect-error - accessing private property
studioLifecycleManager.studioManager = mockStudioManager
expect(studioLifecycleManager.isStudioReady()).to.be.true
@@ -229,7 +265,7 @@ describe('StudioLifecycleManager', () => {
})
it('returns the studio manager when initialized', async () => {
- // @ts-ignore - accessing private property for testing
+ // @ts-expect-error - accessing private property
studioLifecycleManager.studioManagerPromise = Promise.resolve(mockStudioManager)
const result = await studioLifecycleManager.getStudio()
@@ -244,18 +280,18 @@ describe('StudioLifecycleManager', () => {
studioLifecycleManager.registerStudioReadyListener(listener)
- // @ts-ignore - accessing private property for testing
- expect(studioLifecycleManager['listeners']).to.include(listener)
+ // @ts-expect-error - accessing private property
+ expect(studioLifecycleManager.listeners).to.include(listener)
})
it('calls listener immediately if studio is already ready', async () => {
const listener = sinon.stub()
- // @ts-ignore - accessing private property for testing
+ // @ts-expect-error - accessing private property
studioLifecycleManager.studioManager = mockStudioManager
- // @ts-ignore - accessing private property for testing
- studioLifecycleManager['studioReady'] = true
+ // @ts-expect-error - accessing non-existent property
+ studioLifecycleManager.studioReady = true
await Promise.resolve()
@@ -271,11 +307,11 @@ describe('StudioLifecycleManager', () => {
it('does not call listener if studio manager is null', async () => {
const listener = sinon.stub()
- // @ts-ignore - accessing private property for testing
+ // @ts-expect-error - accessing private property
studioLifecycleManager.studioManager = null
- // @ts-ignore - accessing private property for testing
- studioLifecycleManager['studioReady'] = true
+ // @ts-expect-error - accessing non-existent property
+ studioLifecycleManager.studioReady = true
studioLifecycleManager.registerStudioReadyListener(listener)
@@ -294,10 +330,10 @@ describe('StudioLifecycleManager', () => {
studioLifecycleManager.registerStudioReadyListener(listener1)
studioLifecycleManager.registerStudioReadyListener(listener2)
- // @ts-ignore - accessing private property for testing
- expect(studioLifecycleManager['listeners']).to.include(listener1)
- // @ts-ignore - accessing private property for testing
- expect(studioLifecycleManager['listeners']).to.include(listener2)
+ // @ts-expect-error - accessing private property
+ expect(studioLifecycleManager.listeners).to.include(listener1)
+ // @ts-expect-error - accessing private property
+ expect(studioLifecycleManager.listeners).to.include(listener2)
})
it('cleans up listeners after calling them when studio becomes ready', async () => {
@@ -307,8 +343,8 @@ describe('StudioLifecycleManager', () => {
studioLifecycleManager.registerStudioReadyListener(listener1)
studioLifecycleManager.registerStudioReadyListener(listener2)
- // @ts-ignore - accessing private property for testing
- expect(studioLifecycleManager['listeners'].length).to.equal(2)
+ // @ts-expect-error - accessing private property
+ expect(studioLifecycleManager.listeners.length).to.equal(2)
const listenersCalledPromise = Promise.all([
new Promise((resolve) => {
@@ -334,8 +370,96 @@ describe('StudioLifecycleManager', () => {
expect(listener1).to.be.calledWith(mockStudioManager)
expect(listener2).to.be.calledWith(mockStudioManager)
- // @ts-ignore - accessing private property for testing
- expect(studioLifecycleManager['listeners'].length).to.equal(0)
+ // @ts-expect-error - accessing private property
+ expect(studioLifecycleManager.listeners.length).to.equal(0)
+ })
+ })
+
+ describe('status tracking', () => {
+ it('updates status and emits events when status changes', async () => {
+ // Setup the context to test status updates
+ // @ts-expect-error - accessing private property
+ studioLifecycleManager.ctx = mockCtx
+
+ studioLifecycleManager.updateStatus('INITIALIZING')
+
+ // Wait for nextTick to process
+ await nextTick()
+
+ expect(studioStatusChangeEmitterStub).to.be.calledOnce
+
+ // Same status should not trigger another event
+ studioStatusChangeEmitterStub.reset()
+ studioLifecycleManager.updateStatus('INITIALIZING')
+
+ await nextTick()
+ expect(studioStatusChangeEmitterStub).not.to.be.called
+
+ // Different status should trigger another event
+ studioStatusChangeEmitterStub.reset()
+ studioLifecycleManager.updateStatus('ENABLED')
+
+ await nextTick()
+ expect(studioStatusChangeEmitterStub).to.be.calledOnce
+ })
+
+ it('updates status when getStudio is called', async () => {
+ // @ts-expect-error - accessing private property
+ studioLifecycleManager.ctx = mockCtx
+ // @ts-expect-error - accessing private property
+ studioLifecycleManager.studioManagerPromise = Promise.resolve(mockStudioManager)
+
+ const updateStatusSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
+
+ const result = await studioLifecycleManager.getStudio()
+
+ expect(result).to.equal(mockStudioManager)
+ expect(updateStatusSpy).to.be.calledWith('INITIALIZED')
+ })
+
+ it('handles status updates properly during initialization', async () => {
+ const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
+
+ studioLifecycleManager.initializeStudioManager({
+ projectId: 'test-project-id',
+ cloudDataSource: mockCloudDataSource,
+ cfg: mockCfg,
+ debugData: {},
+ ctx: mockCtx,
+ })
+
+ // Should set INITIALIZING status immediately
+ expect(statusChangesSpy).to.be.calledWith('INITIALIZING')
+
+ const studioReadyPromise = new Promise((resolve) => {
+ studioLifecycleManager?.registerStudioReadyListener(() => {
+ resolve(true)
+ })
+ })
+
+ await studioReadyPromise
+
+ expect(statusChangesSpy).to.be.calledWith('INITIALIZED')
+ })
+
+ it('updates status to IN_ERROR when initialization fails', async () => {
+ getAndInitializeStudioManagerStub.rejects(new Error('Test error'))
+
+ const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
+
+ studioLifecycleManager.initializeStudioManager({
+ projectId: 'test-project-id',
+ cloudDataSource: mockCloudDataSource,
+ cfg: mockCfg,
+ debugData: {},
+ ctx: mockCtx,
+ })
+
+ expect(statusChangesSpy).to.be.calledWith('INITIALIZING')
+
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ expect(statusChangesSpy).to.be.calledWith('IN_ERROR')
})
})
})
diff --git a/packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts b/packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts
index aceb6e800348..5866ef9c3d06 100644
--- a/packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts
+++ b/packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts
@@ -79,99 +79,6 @@ describe('getAndInitializeStudioManager', () => {
sinon.restore()
})
- describe('Studio status based on environment variables', () => {
- let mockGetCloudUrl: sinon.SinonStub
- let mockAdditionalHeaders: sinon.SinonStub
- let cloud: CloudDataSource
- let writeStream: Writable
- let readStream: Readable
-
- beforeEach(() => {
- readStream = Readable.from('console.log("studio script")')
-
- writeStream = new Writable({
- write: (chunk, encoding, callback) => {
- callback()
- },
- })
-
- createWriteStreamStub.returns(writeStream)
- createReadStreamStub.returns(Readable.from('tar contents'))
-
- mockGetCloudUrl = sinon.stub()
- mockAdditionalHeaders = sinon.stub()
- cloud = {
- getCloudUrl: mockGetCloudUrl,
- additionalHeaders: mockAdditionalHeaders,
- } as unknown as CloudDataSource
-
- mockGetCloudUrl.returns('http://localhost:1234')
- mockAdditionalHeaders.resolves({
- a: 'b',
- c: 'd',
- })
-
- crossFetchStub.resolves({
- ok: true,
- statusText: 'OK',
- body: readStream,
- headers: {
- get: (header) => {
- if (header === 'x-cypress-signature') {
- return '159'
- }
- },
- },
- })
-
- verifySignatureFromFileStub.resolves(true)
- })
-
- it('sets status to ENABLED when CYPRESS_ENABLE_CLOUD_STUDIO is set', async () => {
- process.env.CYPRESS_ENABLE_CLOUD_STUDIO = '1'
- delete process.env.CYPRESS_LOCAL_STUDIO_PATH
-
- await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId: '12345', cloudDataSource: cloud })
-
- expect(studioManagerSetupStub).to.be.calledWith(sinon.match({
- shouldEnableStudio: true,
- }))
- })
-
- it('sets status to ENABLED when CYPRESS_LOCAL_STUDIO_PATH is set', async () => {
- delete process.env.CYPRESS_ENABLE_CLOUD_STUDIO
- process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio'
-
- await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId: '12345', cloudDataSource: cloud })
-
- expect(studioManagerSetupStub).to.be.calledWith(sinon.match({
- shouldEnableStudio: true,
- }))
- })
-
- it('sets status to INITIALIZED when neither env variable is set', async () => {
- delete process.env.CYPRESS_ENABLE_CLOUD_STUDIO
- delete process.env.CYPRESS_LOCAL_STUDIO_PATH
-
- await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId: '12345', cloudDataSource: cloud })
-
- expect(studioManagerSetupStub).to.be.calledWith(sinon.match({
- shouldEnableStudio: false,
- }))
- })
-
- it('sets status to ENABLED when both env variables are set', async () => {
- process.env.CYPRESS_ENABLE_CLOUD_STUDIO = '1'
- process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio'
-
- await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId: '12345', cloudDataSource: cloud })
-
- expect(studioManagerSetupStub).to.be.calledWith(sinon.match({
- shouldEnableStudio: true,
- }))
- })
- })
-
describe('CYPRESS_LOCAL_STUDIO_PATH is set', () => {
beforeEach(() => {
process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio'
@@ -195,6 +102,7 @@ describe('getAndInitializeStudioManager', () => {
studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz',
projectId: '12345',
cloudDataSource: cloud,
+ shouldEnableStudio: true,
})
expect(rmStub).to.be.calledWith('/tmp/cypress/studio')
@@ -271,7 +179,7 @@ describe('getAndInitializeStudioManager', () => {
const projectId = '12345'
- await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud })
+ await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true })
expect(rmStub).to.be.calledWith('/tmp/cypress/studio')
expect(ensureStub).to.be.calledWith('/tmp/cypress/studio')
@@ -340,7 +248,7 @@ describe('getAndInitializeStudioManager', () => {
const projectId = '12345'
- await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud })
+ await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true })
expect(rmStub).to.be.calledWith('/tmp/cypress/studio')
expect(ensureStub).to.be.calledWith('/tmp/cypress/studio')
@@ -397,7 +305,7 @@ describe('getAndInitializeStudioManager', () => {
const projectId = '12345'
- await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud })
+ await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true })
expect(rmStub).to.be.calledWith('/tmp/cypress/studio')
expect(ensureStub).to.be.calledWith('/tmp/cypress/studio')
@@ -453,7 +361,7 @@ describe('getAndInitializeStudioManager', () => {
const projectId = '12345'
- await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud })
+ await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true })
expect(rmStub).to.be.calledWith('/tmp/cypress/studio')
expect(ensureStub).to.be.calledWith('/tmp/cypress/studio')
@@ -500,7 +408,7 @@ describe('getAndInitializeStudioManager', () => {
const projectId = '12345'
- await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud })
+ await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true })
expect(rmStub).to.be.calledWith('/tmp/cypress/studio')
expect(ensureStub).to.be.calledWith('/tmp/cypress/studio')
@@ -558,7 +466,7 @@ describe('getAndInitializeStudioManager', () => {
const projectId = '12345'
- await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud })
+ await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true })
expect(rmStub).to.be.calledWith('/tmp/cypress/studio')
expect(ensureStub).to.be.calledWith('/tmp/cypress/studio')
@@ -573,5 +481,43 @@ describe('getAndInitializeStudioManager', () => {
studioMethod: 'getAndInitializeStudioManager',
})
})
+
+ it('throws an error if downloading the studio bundle takes too long', async () => {
+ const mockGetCloudUrl = sinon.stub()
+ const mockAdditionalHeaders = sinon.stub()
+ const cloud = {
+ getCloudUrl: mockGetCloudUrl,
+ additionalHeaders: mockAdditionalHeaders,
+ } as unknown as CloudDataSource
+
+ mockGetCloudUrl.returns('http://localhost:1234')
+ mockAdditionalHeaders.resolves({
+ a: 'b',
+ c: 'd',
+ })
+
+ // Create a promise that never resolves to simulate timeout
+ crossFetchStub.returns(new Promise(() => {
+ // This promise deliberately never resolves
+ }))
+
+ const projectId = '12345'
+
+ // pass shorter timeout for testing
+ await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true, downloadTimeoutMs: 3000 })
+
+ expect(rmStub).to.be.calledWith('/tmp/cypress/studio')
+ expect(ensureStub).to.be.calledWith('/tmp/cypress/studio')
+ expect(createInErrorManagerStub).to.be.calledWithMatch({
+ error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Cloud studio download timed out')),
+ cloudApi: {
+ cloudUrl: 'http://localhost:1234',
+ cloudHeaders: { a: 'b', c: 'd' },
+ },
+ studioHash: undefined,
+ projectSlug: '12345',
+ studioMethod: 'getAndInitializeStudioManager',
+ })
+ })
})
})
diff --git a/packages/types/src/studio/index.ts b/packages/types/src/studio/index.ts
index 6bddec85f96e..beffb5f4a4ed 100644
--- a/packages/types/src/studio/index.ts
+++ b/packages/types/src/studio/index.ts
@@ -3,7 +3,7 @@ import type { StudioServerShape, StudioEvent } from './studio-server-types'
export * from './studio-server-types'
-export const STUDIO_STATUSES = ['NOT_INITIALIZED', 'INITIALIZED', 'ENABLED', 'IN_ERROR'] as const
+export const STUDIO_STATUSES = ['NOT_INITIALIZED', 'INITIALIZING', 'INITIALIZED', 'ENABLED', 'IN_ERROR'] as const
export type StudioStatus = typeof STUDIO_STATUSES[number]
@@ -18,6 +18,8 @@ export interface StudioLifecycleManagerShape {
getStudio: () => Promise
isStudioReady: () => boolean
registerStudioReadyListener: (listener: (studioManager: StudioManagerShape) => void) => void
+ cloudStudioRequested: boolean
+ updateStatus: (status: StudioStatus) => void
}
export type StudioErrorReport = {