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 = {