From 4cf7e8a221bfd92477a7e8ad8018c495ffdf6ffa Mon Sep 17 00:00:00 2001 From: astone123 Date: Fri, 2 May 2025 17:23:03 -0400 Subject: [PATCH 01/19] internal: show loading and error statuses in studio panel, add download timeout --- .../app/src/runner/SpecRunnerOpenMode.vue | 37 +++++-- packages/app/src/studio/StudioPanel.vue | 66 +++++++++--- .../src/actions/DataEmitterActions.ts | 7 ++ packages/graphql/schemas/schema.graphql | 12 ++- .../src/schemaTypes/objectTypes/gql-Query.ts | 21 +--- .../objectTypes/gql-Subscription.ts | 17 +++ packages/server/lib/StudioLifecycleManager.ts | 50 ++++++++- .../get_and_initialize_studio_manager.ts | 100 ++++++++++++------ packages/types/src/studio/index.ts | 2 +- 9 files changed, 234 insertions(+), 78 deletions(-) diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue index 8b81ab6b4025..1a17f2304879 100644 --- a/packages/app/src/runner/SpecRunnerOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerOpenMode.vue @@ -102,6 +102,7 @@ :can-access-studio-a-i="studioStore.canAccessStudioAI" :on-studio-panel-close="handleStudioPanelClose" :event-manager="eventManager" + :studio-status="studioStatus" /> @@ -110,7 +111,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..129db10f1e06 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 enabled""" + cloudStudioEnabled: 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 @@ -2382,6 +2380,7 @@ type Studio { enum StudioStatusType { ENABLED INITIALIZED + INITIALIZING IN_ERROR NOT_INITIALIZED } @@ -2437,6 +2436,9 @@ type Subscription { """Issued when the watched specs for the project changes""" specsChange: CurrentProject + + """Status of the studio manager""" + studioStatusChange: Studio } enum SupportStatusEnum { diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts index df38bd5b409a..0b3b5dda2b76 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts @@ -11,8 +11,7 @@ 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' +import { StudioLifecycleManager } from '@packages/server/lib/StudioLifecycleManager' export const Query = objectType({ name: 'Query', @@ -103,20 +102,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('cloudStudioEnabled', { + type: 'Boolean', + description: 'Whether cloud studio is enabled', + resolve: (source, args, ctx) => StudioLifecycleManager.cloudStudioEnabled, }) t.nonNull.field('localSettings', { diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts index 4992db81cbed..68dd4c0a7df3 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts @@ -49,6 +49,23 @@ export const Subscription = subscriptionType({ resolve: (source, args, ctx) => ctx.lifecycleManager, }) + t.field('studioStatusChange', { + type: 'Studio', + description: 'Status of the studio manager', + 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 } + } + + const studio = await ctx.coreData.studioLifecycleManager?.getStudio() + + return studio ? { status: studio.status } : null + }, + }) + 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 db6117e7877b..fbfd0047131f 100644 --- a/packages/server/lib/StudioLifecycleManager.ts +++ b/packages/server/lib/StudioLifecycleManager.ts @@ -12,14 +12,19 @@ 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') export class StudioLifecycleManager { + public static cloudStudioEnabled = !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH) private studioManagerPromise?: Promise private studioManager?: StudioManager private listeners: ((studioManager: StudioManager) => void)[] = [] + private ctx?: DataContext + private lastStatus?: StudioStatus + /** * Initialize the studio manager and possibly set up protocol. * Also registers this instance in the data context. @@ -43,6 +48,9 @@ export class StudioLifecycleManager { ctx: DataContext }): void { debug('Initializing studio manager') + this.ctx = ctx + + this.updateStatus('INITIALIZING') const studioManagerPromise = this.createStudioManager({ projectId, @@ -71,6 +79,8 @@ export class StudioLifecycleManager { studioMethodArgs: [], }) + this.updateStatus('IN_ERROR') + // Clean up any registered listeners this.listeners = [] @@ -94,7 +104,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 ({ @@ -118,6 +134,10 @@ export class StudioLifecycleManager { cloudDataSource, }) + this.updateStatus(studioManager.status) + + this.setupStatusProxy(studioManager) + if (studioManager.status === 'ENABLED') { debug('Cloud studio is enabled - setting up protocol') const protocolManager = new ProtocolManager() @@ -179,4 +199,32 @@ export class StudioLifecycleManager { this.listeners.push(listener) } } + + private updateStatus (status: StudioStatus) { + if (status === this.lastStatus) return + + debug('Studio status changed: %s → %s', this.lastStatus, status) + this.lastStatus = status + + if (this.ctx) { + process.nextTick(() => this.ctx?.emitter.studioStatusChange()) + } + } + + // Monitor status changes on the studioManager + private setupStatusProxy (studioManager: StudioManager) { + let currentStatus = studioManager.status + + Object.defineProperty(studioManager, 'status', { + get: () => currentStatus, + set: (newStatus: StudioStatus) => { + if (newStatus !== currentStatus) { + currentStatus = newStatus + this.updateStatus(newStatus) + } + }, + enumerable: true, + configurable: true, + }) + } } 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 fa8846412434..f216f0aed6e0 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 @@ -13,6 +13,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 { StudioLifecycleManager } from '../../../StudioLifecycleManager' interface Options { studioUrl: string @@ -23,6 +24,9 @@ const pkg = require('@packages/root') const _delay = linearDelay(500) +// Default timeout of 15 seconds for the download +const DOWNLOAD_TIMEOUT_MS = 15000 + export const studioPath = path.join(os.tmpdir(), 'cypress', 'studio') const bundlePath = path.join(studioPath, 'bundle.tar') @@ -31,40 +35,68 @@ const serverFilePath = path.join(studioPath, 'server', 'index.js') const downloadStudioBundleToTempDirectory = async ({ studioUrl, projectId }: Options): Promise => { let responseSignature: string | null = null - await (asyncRetry(async () => { - const response = await fetch(studioUrl, { - // @ts-expect-error - this is supported - agent, - method: 'GET', - headers: { - 'x-route-version': '1', - 'x-cypress-signature': PUBLIC_KEY_VERSION, - ...(projectId ? { 'x-cypress-project-slug': projectId } : {}), - 'x-cypress-studio-mount-version': '1', - 'x-os-name': os.platform(), - 'x-cypress-version': pkg.version, - }, - encrypt: 'signed', - }) - - responseSignature = response.headers.get('x-cypress-signature') - - await new Promise((resolve, reject) => { - const writeStream = fs.createWriteStream(bundlePath) - - writeStream.on('error', reject) - writeStream.on('finish', () => { - resolve() - }) + try { + await (asyncRetry(async () => { + const controller = new AbortController() + const timeoutId = setTimeout(() => { + controller.abort() + }, DOWNLOAD_TIMEOUT_MS) + + try { + const response = await fetch(studioUrl, { + // @ts-expect-error - this is supported + agent, + method: 'GET', + signal: controller.signal, + headers: { + 'x-route-version': '1', + 'x-cypress-signature': PUBLIC_KEY_VERSION, + ...(projectId ? { 'x-cypress-project-slug': projectId } : {}), + 'x-cypress-studio-mount-version': '1', + 'x-os-name': os.platform(), + 'x-cypress-version': pkg.version, + }, + encrypt: 'signed', + }) + + responseSignature = response.headers.get('x-cypress-signature') + + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(bundlePath) + + const downloadTimeout = setTimeout(() => { + writeStream.destroy() + reject(new Error(`Studio bundle download timed out after ${DOWNLOAD_TIMEOUT_MS}ms`)) + }, DOWNLOAD_TIMEOUT_MS) + + writeStream.on('error', (err) => { + clearTimeout(downloadTimeout) + reject(err) + }) + + writeStream.on('finish', () => { + clearTimeout(downloadTimeout) + resolve() + }) + + // @ts-expect-error - this is supported + response.body?.pipe(writeStream) + }) + } finally { + clearTimeout(timeoutId) + } + }, { + maxAttempts: 3, + retryDelay: _delay, + shouldRetry: isRetryableError, + }))() + } catch (error) { + if (error instanceof Error && error.message.includes('timed out')) { + throw new Error(`Studio initialization failed: Download timed out after ${DOWNLOAD_TIMEOUT_MS}ms. Please check your network connection and try again.`) + } - // @ts-expect-error - this is supported - response.body?.pipe(writeStream) - }) - }, { - maxAttempts: 3, - retryDelay: _delay, - shouldRetry: isRetryableError, - }))() + throw error + } if (!responseSignature) { throw new Error('Unable to get studio signature') @@ -150,7 +182,7 @@ export const getAndInitializeStudioManager = async ({ studioUrl, projectId, clou isRetryableError, asyncRetry, }, - shouldEnableStudio: !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH), + shouldEnableStudio: StudioLifecycleManager.cloudStudioEnabled, }) return studioManager diff --git a/packages/types/src/studio/index.ts b/packages/types/src/studio/index.ts index 6bddec85f96e..44adc7873659 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] From 9db8b0778e5d737cd83b0e59c2230668a32761f1 Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 5 May 2025 12:05:01 -0400 Subject: [PATCH 02/19] update lifecycle manager tests --- packages/server/lib/StudioLifecycleManager.ts | 6 +- .../test/unit/StudioLifecycleManager_spec.ts | 205 ++++++++++++++++-- 2 files changed, 187 insertions(+), 24 deletions(-) diff --git a/packages/server/lib/StudioLifecycleManager.ts b/packages/server/lib/StudioLifecycleManager.ts index 0acc4764f8e6..37cbd09ccc37 100644 --- a/packages/server/lib/StudioLifecycleManager.ts +++ b/packages/server/lib/StudioLifecycleManager.ts @@ -18,13 +18,16 @@ const debug = Debug('cypress:server:studio-lifecycle-manager') const routes = require('./cloud/routes') export class StudioLifecycleManager { - public static cloudStudioEnabled = !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH) private studioManagerPromise?: Promise private studioManager?: StudioManager private listeners: ((studioManager: StudioManager) => void)[] = [] private ctx?: DataContext private lastStatus?: StudioStatus + public static get cloudStudioEnabled () { + 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. @@ -219,6 +222,7 @@ export class StudioLifecycleManager { get: () => currentStatus, set: (newStatus: StudioStatus) => { if (newStatus !== currentStatus) { + debug('Studio status change detected: %s → %s', currentStatus, newStatus) currentStatus = newStatus this.updateStatus(newStatus) } diff --git a/packages/server/test/unit/StudioLifecycleManager_spec.ts b/packages/server/test/unit/StudioLifecycleManager_spec.ts index 3b4de67cc92a..647108a147f2 100644 --- a/packages/server/test/unit/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/StudioLifecycleManager_spec.ts @@ -10,6 +10,7 @@ import * as reportStudioErrorPath from '../../lib/cloud/api/studio/report_studio import ProtocolManager from '../../lib/cloud/protocol' const api = require('../../lib/cloud/api').default import * as postStudioSessionModule from '../../lib/cloud/api/studio/post_studio_session' +import type { StudioStatus } from '@packages/types' // Helper to wait for next tick in event loop const nextTick = () => new Promise((resolve) => process.nextTick(resolve)) @@ -25,6 +26,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 +36,8 @@ describe('StudioLifecycleManager', () => { status: 'INITIALIZED', } as unknown as StudioManager + studioStatusChangeEmitterStub = sinon.stub() + mockCtx = { update: sinon.stub(), coreData: {}, @@ -41,6 +45,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 @@ -150,8 +157,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 +183,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 +198,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 +218,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 +236,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 +251,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 +278,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 +301,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 +314,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 +341,160 @@ 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 + + // @ts-expect-error - calling private method + studioLifecycleManager.updateStatus('INITIALIZING') + + // Wait for nextTick to process + await nextTick() + + expect(studioStatusChangeEmitterStub).to.be.calledOnce + + // Same status should not trigger another event + studioStatusChangeEmitterStub.reset() + // @ts-expect-error - calling private method + studioLifecycleManager.updateStatus('INITIALIZING') + + await nextTick() + expect(studioStatusChangeEmitterStub).not.to.be.called + + // Different status should trigger another event + studioStatusChangeEmitterStub.reset() + // @ts-expect-error - calling private method + studioLifecycleManager.updateStatus('ENABLED') + + await nextTick() + expect(studioStatusChangeEmitterStub).to.be.calledOnce + }) + + it('emits events via nextTick to ensure asynchronous delivery', async () => { + // Setup the context to test status updates + // @ts-expect-error - accessing private property + studioLifecycleManager.ctx = mockCtx + + const statusUpdateOrder: string[] = [] + + // Capture the order of operations + studioStatusChangeEmitterStub.callsFake(() => { + statusUpdateOrder.push('event emitted') + }) + + // @ts-expect-error - calling private method + studioLifecycleManager.updateStatus('ENABLED') + statusUpdateOrder.push('updateStatus completed') + + // Before nextTick, the event should not have been emitted yet + expect(studioStatusChangeEmitterStub).not.to.be.called + expect(statusUpdateOrder).to.deep.equal(['updateStatus completed']) + + // After nextTick, the event should be emitted + await nextTick() + expect(statusUpdateOrder).to.deep.equal(['updateStatus completed', 'event emitted']) + expect(studioStatusChangeEmitterStub).to.be.calledOnce + }) + + it('proxies the status property to track changes', async () => { + // @ts-expect-error - accessing private property + studioLifecycleManager.ctx = mockCtx + + const studioManager = { + status: 'INITIALIZED' as StudioStatus, + } as StudioManager + + // @ts-expect-error - calling private method + studioLifecycleManager.setupStatusProxy(studioManager) + + expect(studioManager.status).to.equal('INITIALIZED') + // Same status should not trigger another event + expect(studioStatusChangeEmitterStub).not.to.be.called + + studioManager.status = 'ENABLED' + expect(studioManager.status).to.equal('ENABLED') + + await nextTick() + expect(studioStatusChangeEmitterStub).to.be.calledOnce + + studioStatusChangeEmitterStub.reset() + studioManager.status = 'ENABLED' + + await nextTick() + // Same status should not trigger another event + expect(studioStatusChangeEmitterStub).not.to.be.called + + studioStatusChangeEmitterStub.reset() + studioManager.status = 'IN_ERROR' + + 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') }) }) }) From 1859ba2395b02e0ad602609854b497c44e2388b8 Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 5 May 2025 13:30:33 -0400 Subject: [PATCH 03/19] fix build --- scripts/gulp/tasks/gulpCloudDeliveredTypes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts index f00c1b1a46ae..1394be4205e2 100644 --- a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts +++ b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts @@ -2,10 +2,10 @@ process.env.CYPRESS_INTERNAL_ENV = process.env.CYPRESS_INTERNAL_ENV ?? 'producti import path from 'path' import fs from 'fs-extra' -import { retrieveAndExtractStudioBundle, studioPath } from '@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager' import { postStudioSession } from '@packages/server/lib/cloud/api/studio/post_studio_session' export const downloadStudioTypes = async (): Promise => { + const { retrieveAndExtractStudioBundle, studioPath } = require('@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager') const studioSession = await postStudioSession({ projectId: 'ypt4pf' }) await retrieveAndExtractStudioBundle({ studioUrl: studioSession.studioUrl, projectId: 'ypt4pf' }) From 090cf087e8eb5ba7786474a9c2b503ddb2d2f70e Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 5 May 2025 16:25:10 -0400 Subject: [PATCH 04/19] fix build for real --- packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts | 3 +-- packages/server/lib/StudioLifecycleManager.ts | 3 ++- .../cloud/api/studio/get_and_initialize_studio_manager.ts | 5 ++--- packages/types/src/studio/index.ts | 1 + scripts/gulp/tasks/gulpCloudDeliveredTypes.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts index 0b3b5dda2b76..05f5e4f5cb2c 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts @@ -11,7 +11,6 @@ import { Wizard } from './gql-Wizard' import { ErrorWrapper } from './gql-ErrorWrapper' import { CachedUser } from './gql-CachedUser' import { Cohort } from './gql-Cohorts' -import { StudioLifecycleManager } from '@packages/server/lib/StudioLifecycleManager' export const Query = objectType({ name: 'Query', @@ -105,7 +104,7 @@ export const Query = objectType({ t.field('cloudStudioEnabled', { type: 'Boolean', description: 'Whether cloud studio is enabled', - resolve: (source, args, ctx) => StudioLifecycleManager.cloudStudioEnabled, + resolve: (source, args, ctx) => ctx.coreData.studioLifecycleManager?.cloudStudioEnabled ?? false, }) t.nonNull.field('localSettings', { diff --git a/packages/server/lib/StudioLifecycleManager.ts b/packages/server/lib/StudioLifecycleManager.ts index 37cbd09ccc37..3093e4a0c1f1 100644 --- a/packages/server/lib/StudioLifecycleManager.ts +++ b/packages/server/lib/StudioLifecycleManager.ts @@ -24,7 +24,7 @@ export class StudioLifecycleManager { private ctx?: DataContext private lastStatus?: StudioStatus - public static get cloudStudioEnabled () { + public get cloudStudioEnabled () { return !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH) } @@ -135,6 +135,7 @@ export class StudioLifecycleManager { studioUrl: studioSession.studioUrl, projectId, cloudDataSource, + shouldEnableStudio: this.cloudStudioEnabled, }) this.updateStatus(studioManager.status) 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 b400ac3da4bc..15fb3bce486d 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 @@ -13,7 +13,6 @@ 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 { StudioLifecycleManager } from '../../../StudioLifecycleManager' interface Options { studioUrl: string @@ -154,7 +153,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 }: { studioUrl: string, projectId?: string, cloudDataSource: CloudDataSource, shouldEnableStudio: boolean }): Promise => { let script: string const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production' @@ -182,7 +181,7 @@ export const getAndInitializeStudioManager = async ({ studioUrl, projectId, clou isRetryableError, asyncRetry, }, - shouldEnableStudio: StudioLifecycleManager.cloudStudioEnabled, + shouldEnableStudio, }) return studioManager diff --git a/packages/types/src/studio/index.ts b/packages/types/src/studio/index.ts index 44adc7873659..a6d6b9ab668a 100644 --- a/packages/types/src/studio/index.ts +++ b/packages/types/src/studio/index.ts @@ -18,6 +18,7 @@ export interface StudioLifecycleManagerShape { getStudio: () => Promise isStudioReady: () => boolean registerStudioReadyListener: (listener: (studioManager: StudioManagerShape) => void) => void + cloudStudioEnabled: boolean } export type StudioErrorReport = { diff --git a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts index 1394be4205e2..f00c1b1a46ae 100644 --- a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts +++ b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts @@ -2,10 +2,10 @@ process.env.CYPRESS_INTERNAL_ENV = process.env.CYPRESS_INTERNAL_ENV ?? 'producti import path from 'path' import fs from 'fs-extra' +import { retrieveAndExtractStudioBundle, studioPath } from '@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager' import { postStudioSession } from '@packages/server/lib/cloud/api/studio/post_studio_session' export const downloadStudioTypes = async (): Promise => { - const { retrieveAndExtractStudioBundle, studioPath } = require('@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager') const studioSession = await postStudioSession({ projectId: 'ypt4pf' }) await retrieveAndExtractStudioBundle({ studioUrl: studioSession.studioUrl, projectId: 'ypt4pf' }) From efd5dfb662c4a4d2155f5004b10504a750d4eea1 Mon Sep 17 00:00:00 2001 From: astone123 Date: Tue, 6 May 2025 09:26:49 -0400 Subject: [PATCH 05/19] fix logic --- packages/server/lib/StudioLifecycleManager.ts | 28 ++--- .../get_and_initialize_studio_manager.ts | 111 ++++++++---------- 2 files changed, 61 insertions(+), 78 deletions(-) diff --git a/packages/server/lib/StudioLifecycleManager.ts b/packages/server/lib/StudioLifecycleManager.ts index 3093e4a0c1f1..c1d76d1d5224 100644 --- a/packages/server/lib/StudioLifecycleManager.ts +++ b/packages/server/lib/StudioLifecycleManager.ts @@ -50,7 +50,11 @@ 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') @@ -91,11 +95,6 @@ export class StudioLifecycleManager { }) this.studioManagerPromise = studioManagerPromise - - // Register this instance in the data context - ctx.update((data) => { - data.studioLifecycleManager = this - }) } isStudioReady (): boolean { @@ -138,8 +137,6 @@ export class StudioLifecycleManager { shouldEnableStudio: this.cloudStudioEnabled, }) - this.updateStatus(studioManager.status) - this.setupStatusProxy(studioManager) if (studioManager.status === 'ENABLED') { @@ -170,6 +167,7 @@ export class StudioLifecycleManager { debug('Studio is ready') this.studioManager = studioManager this.callRegisteredListeners() + this.updateStatus(studioManager.status) return studioManager } @@ -211,7 +209,11 @@ export class StudioLifecycleManager { this.lastStatus = status if (this.ctx) { - process.nextTick(() => this.ctx?.emitter.studioStatusChange()) + process.nextTick(() => { + this.ctx?.emitter.studioStatusChange() + }) + } else { + debug('No ctx available, cannot emit studioStatusChange') } } @@ -222,11 +224,9 @@ export class StudioLifecycleManager { Object.defineProperty(studioManager, 'status', { get: () => currentStatus, set: (newStatus: StudioStatus) => { - if (newStatus !== currentStatus) { - debug('Studio status change detected: %s → %s', currentStatus, newStatus) - currentStatus = newStatus - this.updateStatus(newStatus) - } + debug('Studio status change detected: %s → %s', currentStatus, newStatus) + currentStatus = newStatus + this.updateStatus(newStatus) }, enumerable: true, configurable: true, 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 15fb3bce486d..f68867b28e8c 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 @@ -23,79 +23,62 @@ const pkg = require('@packages/root') const _delay = linearDelay(500) -// Default timeout of 15 seconds for the download -const DOWNLOAD_TIMEOUT_MS = 15000 +// 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) { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('Cloud studio download timed out')) + }, DOWNLOAD_TIMEOUT_MS) + }) + + const funcPromise = downloadStudioBundleToTempDirectory(args) + + return Promise.race([funcPromise, timeoutPromise]) +} + const downloadStudioBundleToTempDirectory = async ({ studioUrl, projectId }: Options): Promise => { let responseSignature: string | null = null - try { - await (asyncRetry(async () => { - const controller = new AbortController() - const timeoutId = setTimeout(() => { - controller.abort() - }, DOWNLOAD_TIMEOUT_MS) - - try { - const response = await fetch(studioUrl, { - // @ts-expect-error - this is supported - agent, - method: 'GET', - signal: controller.signal, - headers: { - 'x-route-version': '1', - 'x-cypress-signature': PUBLIC_KEY_VERSION, - ...(projectId ? { 'x-cypress-project-slug': projectId } : {}), - 'x-cypress-studio-mount-version': '1', - 'x-os-name': os.platform(), - 'x-cypress-version': pkg.version, - }, - encrypt: 'signed', - }) - - responseSignature = response.headers.get('x-cypress-signature') - - await new Promise((resolve, reject) => { - const writeStream = fs.createWriteStream(bundlePath) - - const downloadTimeout = setTimeout(() => { - writeStream.destroy() - reject(new Error(`Studio bundle download timed out after ${DOWNLOAD_TIMEOUT_MS}ms`)) - }, DOWNLOAD_TIMEOUT_MS) - - writeStream.on('error', (err) => { - clearTimeout(downloadTimeout) - reject(err) - }) - - writeStream.on('finish', () => { - clearTimeout(downloadTimeout) - resolve() - }) - - // @ts-expect-error - this is supported - response.body?.pipe(writeStream) - }) - } finally { - clearTimeout(timeoutId) - } - }, { - maxAttempts: 3, - retryDelay: _delay, - shouldRetry: isRetryableError, - }))() - } catch (error) { - if (error instanceof Error && error.message.includes('timed out')) { - throw new Error(`Studio initialization failed: Download timed out after ${DOWNLOAD_TIMEOUT_MS}ms. Please check your network connection and try again.`) - } + await (asyncRetry(async () => { + const response = await fetch(studioUrl, { + agent, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': PUBLIC_KEY_VERSION, + ...(projectId ? { 'x-cypress-project-slug': projectId } : {}), + 'x-cypress-studio-mount-version': '1', + 'x-os-name': os.platform(), + 'x-cypress-version': pkg.version, + }, + encrypt: 'signed', + }) - throw error - } + responseSignature = response.headers.get('x-cypress-signature') + + await new Promise((resolve, reject) => { + const writeStream = fs.createWriteStream(bundlePath) + + writeStream.on('error', reject) + writeStream.on('finish', () => { + resolve() + }) + + // @ts-expect-error - this is supported + response.body?.pipe(writeStream) + }) + }, { + maxAttempts: 3, + retryDelay: _delay, + shouldRetry: isRetryableError, + }))() if (!responseSignature) { throw new Error('Unable to get studio signature') @@ -141,7 +124,7 @@ export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId }: O return { studioHash: undefined } } - await downloadStudioBundleToTempDirectory({ studioUrl, projectId }) + await downloadStudioBundleWithTimeout({ studioUrl, projectId }) const studioHash = await getTarHash() From d77f7784618a50b6a4f88736f14a0da3c0213830 Mon Sep 17 00:00:00 2001 From: astone123 Date: Tue, 6 May 2025 09:32:14 -0400 Subject: [PATCH 06/19] fix types --- packages/app/src/runner/SpecRunnerOpenMode.vue | 4 ++-- packages/app/src/studio/StudioPanel.vue | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue index 1a17f2304879..99282c056e84 100644 --- a/packages/app/src/runner/SpecRunnerOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerOpenMode.vue @@ -267,11 +267,11 @@ const cloudStudioEnabled = computed(() => { }) const shouldShowStudioButton = computed(() => { - return cloudStudioEnabled.value && !studioStore.isOpen + return !!cloudStudioEnabled.value && !studioStore.isOpen }) const shouldShowStudioPanel = computed(() => { - return cloudStudioEnabled.value && (studioStore.isLoading || studioStore.isActive) + return !!cloudStudioEnabled.value && (studioStore.isLoading || studioStore.isActive) }) const hideCommandLog = runnerUiStore.hideCommandLog diff --git a/packages/app/src/studio/StudioPanel.vue b/packages/app/src/studio/StudioPanel.vue index f89fe73e2731..cc727b3c1f57 100644 --- a/packages/app/src/studio/StudioPanel.vue +++ b/packages/app/src/studio/StudioPanel.vue @@ -116,7 +116,7 @@ onMounted(maybeRenderReactComponent) onBeforeUnmount(unmountReactComponent) watch(() => props.studioStatus, (newStatus, oldStatus) => { - if (newStatus === 'ENABLED' || newStatus === 'IN_ERROR') { + if (newStatus === 'ENABLED') { loadStudioComponent() } From 12b58f27198cf567e7fbfdee15aca960023e0280 Mon Sep 17 00:00:00 2001 From: astone123 Date: Tue, 6 May 2025 12:59:41 -0400 Subject: [PATCH 07/19] types, tests, etc --- packages/app/cypress/e2e/studio/studio.cy.ts | 9 +- .../get_and_initialize_studio_manager.ts | 13 +- .../test/unit/StudioLifecycleManager_spec.ts | 30 ++++ .../get_and_initialize_studio_manager_spec.ts | 140 ++++++------------ 4 files changed, 89 insertions(+), 103 deletions(-) diff --git a/packages/app/cypress/e2e/studio/studio.cy.ts b/packages/app/cypress/e2e/studio/studio.cy.ts index c7842b20f613..94a1e5ee4277 100644 --- a/packages/app/cypress/e2e/studio/studio.cy.ts +++ b/packages/app/cypress/e2e/studio/studio.cy.ts @@ -54,13 +54,15 @@ describe('studio functionality', () => { it('loads the studio page', () => { launchStudio({ enableCloudStudio: true }) + cy.get('[data-cy="loading-studio-panel"]').should('not.exist') + cy.window().then((win) => { expect(win.Cypress.config('isDefaultProtocolEnabled')).to.be.false expect(win.Cypress.state('isProtocolEnabled')).to.be.true }) }) - 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(() => { @@ -149,6 +151,8 @@ describe('studio functionality', () => { 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() @@ -229,6 +233,9 @@ describe('studio functionality', () => { 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/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 f68867b28e8c..9e8be69994e8 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 @@ -31,11 +31,11 @@ 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) { +async function downloadStudioBundleWithTimeout (args: Options & { downloadTimeoutMs?: number }) { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error('Cloud studio download timed out')) - }, DOWNLOAD_TIMEOUT_MS) + }, args.downloadTimeoutMs || DOWNLOAD_TIMEOUT_MS) }) const funcPromise = downloadStudioBundleToTempDirectory(args) @@ -48,6 +48,7 @@ const downloadStudioBundleToTempDirectory = async ({ studioUrl, projectId }: Opt await (asyncRetry(async () => { const response = await fetch(studioUrl, { + // @ts-expect-error - this is supported agent, method: 'GET', headers: { @@ -108,7 +109,7 @@ const getTarHash = (): Promise => { }) } -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 }> => { // First remove studioPath to ensure we have a clean slate await fs.promises.rm(studioPath, { recursive: true, force: true }) await ensureDir(studioPath) @@ -124,7 +125,7 @@ export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId }: O return { studioHash: undefined } } - await downloadStudioBundleWithTimeout({ studioUrl, projectId }) + await downloadStudioBundleWithTimeout({ studioUrl, projectId, downloadTimeoutMs }) const studioHash = await getTarHash() @@ -136,7 +137,7 @@ export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId }: O return { studioHash } } -export const getAndInitializeStudioManager = async ({ studioUrl, projectId, cloudDataSource, shouldEnableStudio }: { studioUrl: string, projectId?: string, cloudDataSource: CloudDataSource, shouldEnableStudio: boolean }): Promise => { +export const getAndInitializeStudioManager = async ({ studioUrl, projectId, cloudDataSource, shouldEnableStudio, downloadTimeoutMs }: { studioUrl: string, projectId?: string, cloudDataSource: CloudDataSource, shouldEnableStudio: boolean, downloadTimeoutMs?: number }): Promise => { let script: string const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production' @@ -146,7 +147,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') diff --git a/packages/server/test/unit/StudioLifecycleManager_spec.ts b/packages/server/test/unit/StudioLifecycleManager_spec.ts index 647108a147f2..82ce24eb2fc4 100644 --- a/packages/server/test/unit/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/StudioLifecycleManager_spec.ts @@ -81,6 +81,36 @@ describe('StudioLifecycleManager', () => { sinon.restore() }) + describe('cloudStudioEnabled', () => { + 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.cloudStudioEnabled).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.cloudStudioEnabled).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.cloudStudioEnabled).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.cloudStudioEnabled).to.be.true + }) + }) + describe('initializeStudioManager', () => { it('initializes the studio manager and registers it in the data context', async () => { studioLifecycleManager.initializeStudioManager({ 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 ca6a4bd89d09..5c54a6ed7c38 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 @@ -77,97 +77,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({ - 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' @@ -191,6 +100,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') @@ -265,7 +175,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') @@ -332,7 +242,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') @@ -389,7 +299,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 +363,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') @@ -509,7 +419,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') @@ -524,5 +434,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', + }) + }) }) }) From 46518eafd3388d9a5035c8bb2b916978aed564f9 Mon Sep 17 00:00:00 2001 From: Adam Stone-Lord Date: Thu, 8 May 2025 09:59:16 -0400 Subject: [PATCH 08/19] Update packages/app/src/studio/StudioPanel.vue Co-authored-by: Ryan Manuel --- packages/app/src/studio/StudioPanel.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/studio/StudioPanel.vue b/packages/app/src/studio/StudioPanel.vue index cc727b3c1f57..0f85f11f7693 100644 --- a/packages/app/src/studio/StudioPanel.vue +++ b/packages/app/src/studio/StudioPanel.vue @@ -115,7 +115,7 @@ init({ onMounted(maybeRenderReactComponent) onBeforeUnmount(unmountReactComponent) -watch(() => props.studioStatus, (newStatus, oldStatus) => { +watch(() => props.studioStatus, (newStatus) => { if (newStatus === 'ENABLED') { loadStudioComponent() } From cd100a01a695c88151f476d0da2c7cfee023cf25 Mon Sep 17 00:00:00 2001 From: astone123 Date: Thu, 8 May 2025 10:04:50 -0400 Subject: [PATCH 09/19] use getter --- packages/server/lib/project-base.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index e847be38bb6a..fc99f16c3944 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?.cloudStudioEnabled) { try { studio?.captureStudioEvent({ type: StudioMetricsTypes.STUDIO_STARTED, From 3a7bc961a7820eb7c35d6664c4cfe2d8e874ffa2 Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 12 May 2025 11:22:26 -0400 Subject: [PATCH 10/19] fix types --- packages/app/src/runner/SpecRunnerOpenMode.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue index 2f70e56f21f8..5c9e8787f3e8 100644 --- a/packages/app/src/runner/SpecRunnerOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerOpenMode.vue @@ -268,7 +268,7 @@ const cloudStudioEnabled = computed(() => { }) const studioBetaAvailable = computed(() => { - return studioStatus.value === 'ENABLED' && !!props.gql.studio + return !!cloudStudioEnabled.value }) const shouldShowStudioButton = computed(() => { From 9a3c436984ae70d4370e65d8ba656817d6ca6eb5 Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 12 May 2025 11:47:13 -0400 Subject: [PATCH 11/19] some feedback --- packages/app/src/runner/SpecRunnerOpenMode.vue | 8 +++----- packages/app/src/studio/StudioPanel.vue | 5 +++-- packages/graphql/schemas/schema.graphql | 8 +------- .../src/schemaTypes/objectTypes/gql-Studio.ts | 13 +------------ .../src/schemaTypes/objectTypes/gql-Subscription.ts | 8 ++++---- 5 files changed, 12 insertions(+), 30 deletions(-) diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue index 5c9e8787f3e8..77e729fa2bf4 100644 --- a/packages/app/src/runner/SpecRunnerOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerOpenMode.vue @@ -203,9 +203,7 @@ mutation SpecRunnerOpenMode_OpenFileInIDE ($input: FileDetailsInput!) { gql` subscription StudioStatus_Change { - studioStatusChange { - status - } + studioStatusChange } ` @@ -256,8 +254,8 @@ const isSpecsListOpenPreferences = computed(() => { const studioStatus = ref(null) useSubscription({ query: StudioStatus_ChangeDocument }, (_, data) => { - if (data?.studioStatusChange?.status) { - studioStatus.value = data.studioStatusChange.status + if (data?.studioStatusChange) { + studioStatus.value = data.studioStatusChange } return data diff --git a/packages/app/src/studio/StudioPanel.vue b/packages/app/src/studio/StudioPanel.vue index 0f85f11f7693..fdb368c7805d 100644 --- a/packages/app/src/studio/StudioPanel.vue +++ b/packages/app/src/studio/StudioPanel.vue @@ -5,8 +5,9 @@ > - - + + +
diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index 129db10f1e06..0733f6fe598a 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -2371,12 +2371,6 @@ enum SpecType { integration } -"""The studio manager for the app""" -type Studio { - """The current status of the studio""" - status: StudioStatusType -} - enum StudioStatusType { ENABLED INITIALIZED @@ -2438,7 +2432,7 @@ type Subscription { specsChange: CurrentProject """Status of the studio manager""" - studioStatusChange: Studio + studioStatusChange: StudioStatusType } enum SupportStatusEnum { 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 68dd4c0a7df3..9fd64366d38e 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts @@ -1,6 +1,6 @@ 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' @@ -50,19 +50,19 @@ export const Subscription = subscriptionType({ }) t.field('studioStatusChange', { - type: 'Studio', + type: StudioStatusTypeEnum, description: 'Status of the studio manager', 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 } + return 'INITIALIZING' } const studio = await ctx.coreData.studioLifecycleManager?.getStudio() - return studio ? { status: studio.status } : null + return studio?.status ?? null }, }) From fed29ce8fb885cae361aa3a9b22c9202785566d6 Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 12 May 2025 14:14:43 -0400 Subject: [PATCH 12/19] some feedback --- packages/app/cypress/e2e/studio/helper.ts | 3 - .../app/cypress/e2e/studio/studio-cloud.cy.ts | 3 +- .../app/src/runner/SpecRunnerOpenMode.vue | 12 ++-- packages/graphql/schemas/schema.graphql | 4 +- .../src/schemaTypes/objectTypes/gql-Query.ts | 4 +- packages/server/lib/StudioLifecycleManager.ts | 31 +++------ .../get_and_initialize_studio_manager.ts | 5 +- .../cloud/api/studio/report_studio_error.ts | 1 - packages/server/lib/cloud/studio.ts | 28 ++++++-- .../test/unit/StudioLifecycleManager_spec.ts | 65 ------------------- packages/types/src/studio/index.ts | 1 + 11 files changed, 46 insertions(+), 111 deletions(-) diff --git a/packages/app/cypress/e2e/studio/helper.ts b/packages/app/cypress/e2e/studio/helper.ts index 6c66f9dbf475..b1aef99b4747 100644 --- a/packages/app/cypress/e2e/studio/helper.ts +++ b/packages/app/cypress/e2e/studio/helper.ts @@ -34,9 +34,6 @@ export function launchStudio ({ specName = 'spec.cy.js', createNewTest = false, .findByTestId('launch-studio') .click() - // Studio re-executes spec before waiting for commands - wait for the spec to finish executing. - cy.waitForSpecToFinish() - if (createNewTest) { cy.get('span.runnable-title').contains('New Test').should('exist') } else { diff --git a/packages/app/cypress/e2e/studio/studio-cloud.cy.ts b/packages/app/cypress/e2e/studio/studio-cloud.cy.ts index fc4035b925d3..8cbf98747751 100644 --- a/packages/app/cypress/e2e/studio/studio-cloud.cy.ts +++ b/packages/app/cypress/e2e/studio/studio-cloud.cy.ts @@ -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') diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue index 77e729fa2bf4..685ab6f5b5b7 100644 --- a/packages/app/src/runner/SpecRunnerOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerOpenMode.vue @@ -169,7 +169,7 @@ fragment SpecRunner_Preferences on Query { gql` fragment SpecRunner_Studio on Query { - cloudStudioEnabled + cloudStudioRequested } ` @@ -261,20 +261,20 @@ useSubscription({ query: StudioStatus_ChangeDocument }, (_, data) => { return data }) -const cloudStudioEnabled = computed(() => { - return props.gql.cloudStudioEnabled +const cloudStudioRequested = computed(() => { + return props.gql.cloudStudioRequested }) const studioBetaAvailable = computed(() => { - return !!cloudStudioEnabled.value + return !!cloudStudioRequested.value }) const shouldShowStudioButton = computed(() => { - return !!cloudStudioEnabled.value && !studioStore.isOpen + return !!cloudStudioRequested.value && !studioStore.isOpen }) const shouldShowStudioPanel = computed(() => { - return !!cloudStudioEnabled.value && (studioStore.isLoading || studioStore.isActive) + return !!cloudStudioRequested.value && (studioStore.isLoading || studioStore.isActive) }) const hideCommandLog = runnerUiStore.hideCommandLog diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index 0733f6fe598a..f3f7858399a1 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -2023,8 +2023,8 @@ type Query { specPath: String! ): CloudProjectSpecResult - """Whether cloud studio is enabled""" - cloudStudioEnabled: Boolean + """Whether cloud studio is requested by the environment""" + cloudStudioRequested: Boolean """A user within the Cypress Cloud""" cloudViewer: CloudUser diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts index 05f5e4f5cb2c..a9fe68446360 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts @@ -101,9 +101,9 @@ export const Query = objectType({ resolve: (source, args, ctx) => ctx.coreData.authState, }) - t.field('cloudStudioEnabled', { + t.field('cloudStudioRequested', { type: 'Boolean', - description: 'Whether cloud studio is enabled', + description: 'Whether cloud studio is requested by the environment', resolve: (source, args, ctx) => ctx.coreData.studioLifecycleManager?.cloudStudioEnabled ?? false, }) diff --git a/packages/server/lib/StudioLifecycleManager.ts b/packages/server/lib/StudioLifecycleManager.ts index c1d76d1d5224..de6d200c982b 100644 --- a/packages/server/lib/StudioLifecycleManager.ts +++ b/packages/server/lib/StudioLifecycleManager.ts @@ -135,10 +135,9 @@ export class StudioLifecycleManager { projectId, cloudDataSource, shouldEnableStudio: this.cloudStudioEnabled, + lifecycleManager: this, }) - this.setupStatusProxy(studioManager) - if (studioManager.status === 'ENABLED') { debug('Cloud studio is enabled - setting up protocol') const protocolManager = new ProtocolManager() @@ -202,34 +201,20 @@ export class StudioLifecycleManager { } } - private updateStatus (status: StudioStatus) { - if (status === this.lastStatus) return + 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) { - process.nextTick(() => { - this.ctx?.emitter.studioStatusChange() - }) + this.ctx?.emitter.studioStatusChange() } else { debug('No ctx available, cannot emit studioStatusChange') } } - - // Monitor status changes on the studioManager - private setupStatusProxy (studioManager: StudioManager) { - let currentStatus = studioManager.status - - Object.defineProperty(studioManager, 'status', { - get: () => currentStatus, - set: (newStatus: StudioStatus) => { - debug('Studio status change detected: %s → %s', currentStatus, newStatus) - currentStatus = newStatus - this.updateStatus(newStatus) - }, - enumerable: true, - configurable: true, - }) - } } 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 9cd2d38a442a..e1757937bd11 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 @@ -124,7 +125,7 @@ export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId, dow return { studioHash } } -export const getAndInitializeStudioManager = async ({ studioUrl, projectId, cloudDataSource, shouldEnableStudio, downloadTimeoutMs }: { studioUrl: string, projectId?: string, cloudDataSource: CloudDataSource, shouldEnableStudio: boolean, downloadTimeoutMs?: number }): 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' @@ -153,6 +154,7 @@ export const getAndInitializeStudioManager = async ({ studioUrl, projectId, clou asyncRetry, }, shouldEnableStudio, + lifecycleManager, }) return studioManager @@ -177,6 +179,7 @@ export const getAndInitializeStudioManager = async ({ studioUrl, projectId, clou projectSlug: projectId, error: actualError, studioMethod: 'getAndInitializeStudioManager', + lifecycleManager, }) } finally { await remove(bundlePath) 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/cloud/studio.ts b/packages/server/lib/cloud/studio.ts index 090e6c1e1793..ca1c1d24dd80 100644 --- a/packages/server/lib/cloud/studio.ts +++ b/packages/server/lib/cloud/studio.ts @@ -1,4 +1,4 @@ -import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent } from '@packages/types' +import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent, StudioLifecycleManagerShape } from '@packages/types' import type { Router } from 'express' import type { Socket } from 'socket.io' import Debug from 'debug' @@ -15,6 +15,7 @@ interface SetupOptions { projectSlug?: string cloudApi: StudioCloudApi shouldEnableStudio: boolean + lifecycleManager?: StudioLifecycleManagerShape } const debug = Debug('cypress:server:studio') @@ -23,11 +24,22 @@ export class StudioManager implements StudioManagerShape { status: StudioStatus = 'NOT_INITIALIZED' protocolManager: ProtocolManagerShape | undefined private _studioServer: StudioServerShape | undefined + private lifecycleManager?: StudioLifecycleManagerShape + + setStatus (status: StudioStatus) { + this.status = status + if (this.lifecycleManager) { + this.lifecycleManager.updateStatus(status) + } else { + debug('StudioManager.setStatus: lifecycleManager does not exist - not updating status') + } + } - static createInErrorManager ({ cloudApi, studioHash, projectSlug, error, studioMethod, studioMethodArgs }: ReportStudioErrorOptions): StudioManager { + static createInErrorManager ({ cloudApi, studioHash, projectSlug, error, studioMethod, studioMethodArgs, lifecycleManager }: ReportStudioErrorOptions & { lifecycleManager?: StudioLifecycleManagerShape }): StudioManager { const manager = new StudioManager() - manager.status = 'IN_ERROR' + manager.lifecycleManager = lifecycleManager + manager.setStatus('IN_ERROR') reportStudioError({ cloudApi, @@ -41,7 +53,9 @@ export class StudioManager implements StudioManagerShape { return manager } - async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio }: SetupOptions): Promise { + async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio, lifecycleManager }: SetupOptions): Promise { + this.lifecycleManager = lifecycleManager + const { createStudioServer } = requireScript(script).default this._studioServer = await createStudioServer({ @@ -52,7 +66,7 @@ export class StudioManager implements StudioManagerShape { betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')), }) - this.status = shouldEnableStudio ? 'ENABLED' : 'INITIALIZED' + this.setStatus(shouldEnableStudio ? 'ENABLED' : 'INITIALIZED') } initializeRoutes (router: Router): void { @@ -118,7 +132,7 @@ export class StudioManager implements StudioManagerShape { actualError = error } - this.status = 'IN_ERROR' + this.setStatus('IN_ERROR') this.reportError(actualError, method, ...args) } } @@ -150,7 +164,7 @@ export class StudioManager implements StudioManagerShape { // only set error state if this request is essential if (isEssential) { - this.status = 'IN_ERROR' + this.setStatus('IN_ERROR') } this.reportError(actualError, method, ...args) diff --git a/packages/server/test/unit/StudioLifecycleManager_spec.ts b/packages/server/test/unit/StudioLifecycleManager_spec.ts index 82ce24eb2fc4..16acebff0ba3 100644 --- a/packages/server/test/unit/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/StudioLifecycleManager_spec.ts @@ -10,7 +10,6 @@ import * as reportStudioErrorPath from '../../lib/cloud/api/studio/report_studio import ProtocolManager from '../../lib/cloud/protocol' const api = require('../../lib/cloud/api').default import * as postStudioSessionModule from '../../lib/cloud/api/studio/post_studio_session' -import type { StudioStatus } from '@packages/types' // Helper to wait for next tick in event loop const nextTick = () => new Promise((resolve) => process.nextTick(resolve)) @@ -382,7 +381,6 @@ describe('StudioLifecycleManager', () => { // @ts-expect-error - accessing private property studioLifecycleManager.ctx = mockCtx - // @ts-expect-error - calling private method studioLifecycleManager.updateStatus('INITIALIZING') // Wait for nextTick to process @@ -392,7 +390,6 @@ describe('StudioLifecycleManager', () => { // Same status should not trigger another event studioStatusChangeEmitterStub.reset() - // @ts-expect-error - calling private method studioLifecycleManager.updateStatus('INITIALIZING') await nextTick() @@ -400,74 +397,12 @@ describe('StudioLifecycleManager', () => { // Different status should trigger another event studioStatusChangeEmitterStub.reset() - // @ts-expect-error - calling private method studioLifecycleManager.updateStatus('ENABLED') await nextTick() expect(studioStatusChangeEmitterStub).to.be.calledOnce }) - it('emits events via nextTick to ensure asynchronous delivery', async () => { - // Setup the context to test status updates - // @ts-expect-error - accessing private property - studioLifecycleManager.ctx = mockCtx - - const statusUpdateOrder: string[] = [] - - // Capture the order of operations - studioStatusChangeEmitterStub.callsFake(() => { - statusUpdateOrder.push('event emitted') - }) - - // @ts-expect-error - calling private method - studioLifecycleManager.updateStatus('ENABLED') - statusUpdateOrder.push('updateStatus completed') - - // Before nextTick, the event should not have been emitted yet - expect(studioStatusChangeEmitterStub).not.to.be.called - expect(statusUpdateOrder).to.deep.equal(['updateStatus completed']) - - // After nextTick, the event should be emitted - await nextTick() - expect(statusUpdateOrder).to.deep.equal(['updateStatus completed', 'event emitted']) - expect(studioStatusChangeEmitterStub).to.be.calledOnce - }) - - it('proxies the status property to track changes', async () => { - // @ts-expect-error - accessing private property - studioLifecycleManager.ctx = mockCtx - - const studioManager = { - status: 'INITIALIZED' as StudioStatus, - } as StudioManager - - // @ts-expect-error - calling private method - studioLifecycleManager.setupStatusProxy(studioManager) - - expect(studioManager.status).to.equal('INITIALIZED') - // Same status should not trigger another event - expect(studioStatusChangeEmitterStub).not.to.be.called - - studioManager.status = 'ENABLED' - expect(studioManager.status).to.equal('ENABLED') - - await nextTick() - expect(studioStatusChangeEmitterStub).to.be.calledOnce - - studioStatusChangeEmitterStub.reset() - studioManager.status = 'ENABLED' - - await nextTick() - // Same status should not trigger another event - expect(studioStatusChangeEmitterStub).not.to.be.called - - studioStatusChangeEmitterStub.reset() - studioManager.status = 'IN_ERROR' - - await nextTick() - expect(studioStatusChangeEmitterStub).to.be.calledOnce - }) - it('updates status when getStudio is called', async () => { // @ts-expect-error - accessing private property studioLifecycleManager.ctx = mockCtx diff --git a/packages/types/src/studio/index.ts b/packages/types/src/studio/index.ts index a6d6b9ab668a..ea2252e1e1e9 100644 --- a/packages/types/src/studio/index.ts +++ b/packages/types/src/studio/index.ts @@ -19,6 +19,7 @@ export interface StudioLifecycleManagerShape { isStudioReady: () => boolean registerStudioReadyListener: (listener: (studioManager: StudioManagerShape) => void) => void cloudStudioEnabled: boolean + updateStatus: (status: StudioStatus) => void } export type StudioErrorReport = { From 82ed973cde1333cfc658a3255544f311790ace38 Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 12 May 2025 14:27:53 -0400 Subject: [PATCH 13/19] re-name cloudStudioEnabled --- .../graphql/src/schemaTypes/objectTypes/gql-Query.ts | 2 +- packages/server/lib/StudioLifecycleManager.ts | 4 ++-- packages/server/lib/project-base.ts | 2 +- .../server/test/unit/StudioLifecycleManager_spec.ts | 10 +++++----- packages/types/src/studio/index.ts | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts index a9fe68446360..7486dbc3a077 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts @@ -104,7 +104,7 @@ export const Query = objectType({ t.field('cloudStudioRequested', { type: 'Boolean', description: 'Whether cloud studio is requested by the environment', - resolve: (source, args, ctx) => ctx.coreData.studioLifecycleManager?.cloudStudioEnabled ?? false, + resolve: (source, args, ctx) => ctx.coreData.studioLifecycleManager?.cloudStudioRequested ?? false, }) t.nonNull.field('localSettings', { diff --git a/packages/server/lib/StudioLifecycleManager.ts b/packages/server/lib/StudioLifecycleManager.ts index de6d200c982b..fbc1157ff55f 100644 --- a/packages/server/lib/StudioLifecycleManager.ts +++ b/packages/server/lib/StudioLifecycleManager.ts @@ -24,7 +24,7 @@ export class StudioLifecycleManager { private ctx?: DataContext private lastStatus?: StudioStatus - public get cloudStudioEnabled () { + public get cloudStudioRequested () { return !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH) } @@ -134,7 +134,7 @@ export class StudioLifecycleManager { studioUrl: studioSession.studioUrl, projectId, cloudDataSource, - shouldEnableStudio: this.cloudStudioEnabled, + shouldEnableStudio: this.cloudStudioRequested, lifecycleManager: this, }) diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index fc99f16c3944..163342fb997a 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -432,7 +432,7 @@ export class ProjectBase extends EE { const studio = await this.ctx.coreData.studioLifecycleManager?.getStudio() // only capture studio started event if the user is accessing legacy studio - if (!this.ctx.coreData.studioLifecycleManager?.cloudStudioEnabled) { + 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 16acebff0ba3..3022ecba01d9 100644 --- a/packages/server/test/unit/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/StudioLifecycleManager_spec.ts @@ -80,33 +80,33 @@ describe('StudioLifecycleManager', () => { sinon.restore() }) - describe('cloudStudioEnabled', () => { + 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.cloudStudioEnabled).to.be.true + 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.cloudStudioEnabled).to.be.true + 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.cloudStudioEnabled).to.be.false + 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.cloudStudioEnabled).to.be.true + expect(studioLifecycleManager.cloudStudioRequested).to.be.true }) }) diff --git a/packages/types/src/studio/index.ts b/packages/types/src/studio/index.ts index ea2252e1e1e9..beffb5f4a4ed 100644 --- a/packages/types/src/studio/index.ts +++ b/packages/types/src/studio/index.ts @@ -18,7 +18,7 @@ export interface StudioLifecycleManagerShape { getStudio: () => Promise isStudioReady: () => boolean registerStudioReadyListener: (listener: (studioManager: StudioManagerShape) => void) => void - cloudStudioEnabled: boolean + cloudStudioRequested: boolean updateStatus: (status: StudioStatus) => void } From 7bb3f97d173b24d12563dc805356717416bd55cd Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 12 May 2025 15:39:29 -0400 Subject: [PATCH 14/19] fix status updates --- packages/server/lib/cloud/studio.ts | 28 +++++-------------- .../get_and_initialize_studio_manager_spec.ts | 2 +- 2 files changed, 8 insertions(+), 22 deletions(-) diff --git a/packages/server/lib/cloud/studio.ts b/packages/server/lib/cloud/studio.ts index ca1c1d24dd80..090e6c1e1793 100644 --- a/packages/server/lib/cloud/studio.ts +++ b/packages/server/lib/cloud/studio.ts @@ -1,4 +1,4 @@ -import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent, StudioLifecycleManagerShape } from '@packages/types' +import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent } from '@packages/types' import type { Router } from 'express' import type { Socket } from 'socket.io' import Debug from 'debug' @@ -15,7 +15,6 @@ interface SetupOptions { projectSlug?: string cloudApi: StudioCloudApi shouldEnableStudio: boolean - lifecycleManager?: StudioLifecycleManagerShape } const debug = Debug('cypress:server:studio') @@ -24,22 +23,11 @@ export class StudioManager implements StudioManagerShape { status: StudioStatus = 'NOT_INITIALIZED' protocolManager: ProtocolManagerShape | undefined private _studioServer: StudioServerShape | undefined - private lifecycleManager?: StudioLifecycleManagerShape - - setStatus (status: StudioStatus) { - this.status = status - if (this.lifecycleManager) { - this.lifecycleManager.updateStatus(status) - } else { - debug('StudioManager.setStatus: lifecycleManager does not exist - not updating status') - } - } - static createInErrorManager ({ cloudApi, studioHash, projectSlug, error, studioMethod, studioMethodArgs, lifecycleManager }: ReportStudioErrorOptions & { lifecycleManager?: StudioLifecycleManagerShape }): StudioManager { + static createInErrorManager ({ cloudApi, studioHash, projectSlug, error, studioMethod, studioMethodArgs }: ReportStudioErrorOptions): StudioManager { const manager = new StudioManager() - manager.lifecycleManager = lifecycleManager - manager.setStatus('IN_ERROR') + manager.status = 'IN_ERROR' reportStudioError({ cloudApi, @@ -53,9 +41,7 @@ export class StudioManager implements StudioManagerShape { return manager } - async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio, lifecycleManager }: SetupOptions): Promise { - this.lifecycleManager = lifecycleManager - + async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio }: SetupOptions): Promise { const { createStudioServer } = requireScript(script).default this._studioServer = await createStudioServer({ @@ -66,7 +52,7 @@ export class StudioManager implements StudioManagerShape { betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')), }) - this.setStatus(shouldEnableStudio ? 'ENABLED' : 'INITIALIZED') + this.status = shouldEnableStudio ? 'ENABLED' : 'INITIALIZED' } initializeRoutes (router: Router): void { @@ -132,7 +118,7 @@ export class StudioManager implements StudioManagerShape { actualError = error } - this.setStatus('IN_ERROR') + this.status = 'IN_ERROR' this.reportError(actualError, method, ...args) } } @@ -164,7 +150,7 @@ export class StudioManager implements StudioManagerShape { // only set error state if this request is essential if (isEssential) { - this.setStatus('IN_ERROR') + this.status = 'IN_ERROR' } this.reportError(actualError, method, ...args) 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 3f42c1ab70db..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 @@ -361,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') From 4cd63132ca5a750aef468dddb1927c1c91cb4b6c Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 12 May 2025 15:59:40 -0400 Subject: [PATCH 15/19] feedback --- packages/app/src/studio/StudioPanel.vue | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/app/src/studio/StudioPanel.vue b/packages/app/src/studio/StudioPanel.vue index fdb368c7805d..a66333b58186 100644 --- a/packages/app/src/studio/StudioPanel.vue +++ b/packages/app/src/studio/StudioPanel.vue @@ -120,8 +120,6 @@ watch(() => props.studioStatus, (newStatus) => { if (newStatus === 'ENABLED') { loadStudioComponent() } - - maybeRenderReactComponent() }, { immediate: true }) function loadStudioComponent () { From 6f386a27520dd25ce1f6ab55cd493be8341c37e5 Mon Sep 17 00:00:00 2001 From: astone123 Date: Mon, 12 May 2025 16:00:22 -0400 Subject: [PATCH 16/19] fix types --- .../lib/cloud/api/studio/get_and_initialize_studio_manager.ts | 2 -- 1 file changed, 2 deletions(-) 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 e1757937bd11..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 @@ -154,7 +154,6 @@ export const getAndInitializeStudioManager = async ({ studioUrl, projectId, clou asyncRetry, }, shouldEnableStudio, - lifecycleManager, }) return studioManager @@ -179,7 +178,6 @@ export const getAndInitializeStudioManager = async ({ studioUrl, projectId, clou projectSlug: projectId, error: actualError, studioMethod: 'getAndInitializeStudioManager', - lifecycleManager, }) } finally { await remove(bundlePath) From 45f36d71b3c13bb2725f2736f104f70381992da9 Mon Sep 17 00:00:00 2001 From: astone123 Date: Tue, 13 May 2025 09:19:37 -0400 Subject: [PATCH 17/19] fix tests --- packages/app/cypress/e2e/studio/helper.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app/cypress/e2e/studio/helper.ts b/packages/app/cypress/e2e/studio/helper.ts index b1aef99b4747..6c66f9dbf475 100644 --- a/packages/app/cypress/e2e/studio/helper.ts +++ b/packages/app/cypress/e2e/studio/helper.ts @@ -34,6 +34,9 @@ export function launchStudio ({ specName = 'spec.cy.js', createNewTest = false, .findByTestId('launch-studio') .click() + // Studio re-executes spec before waiting for commands - wait for the spec to finish executing. + cy.waitForSpecToFinish() + if (createNewTest) { cy.get('span.runnable-title').contains('New Test').should('exist') } else { From 317f1e9a0fb9d8747dea63f56d86a1966d6126ce Mon Sep 17 00:00:00 2001 From: astone123 Date: Tue, 13 May 2025 11:51:27 -0400 Subject: [PATCH 18/19] fix canAccessStudioAI status --- .../app/src/runner/SpecRunnerOpenMode.vue | 8 +++-- packages/graphql/schemas/schema.graphql | 9 +++-- .../objectTypes/gql-Subscription.ts | 36 ++++++++++++++++--- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue index 685ab6f5b5b7..cc5bd3b1de4a 100644 --- a/packages/app/src/runner/SpecRunnerOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerOpenMode.vue @@ -203,7 +203,10 @@ mutation SpecRunnerOpenMode_OpenFileInIDE ($input: FileDetailsInput!) { gql` subscription StudioStatus_Change { - studioStatusChange + studioStatusChange { + status + canAccessStudioAI + } } ` @@ -255,7 +258,8 @@ const studioStatus = ref(null) useSubscription({ query: StudioStatus_ChangeDocument }, (_, data) => { if (data?.studioStatusChange) { - studioStatus.value = data.studioStatusChange + studioStatus.value = data.studioStatusChange.status + studioStore.setCanAccessStudioAI(data.studioStatusChange.canAccessStudioAI) } return data diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql index f3f7858399a1..09092fc5534e 100644 --- a/packages/graphql/schemas/schema.graphql +++ b/packages/graphql/schemas/schema.graphql @@ -2371,6 +2371,11 @@ enum SpecType { integration } +type StudioStatusPayload { + canAccessStudioAI: Boolean! + status: StudioStatusType! +} + enum StudioStatusType { ENABLED INITIALIZED @@ -2431,8 +2436,8 @@ type Subscription { """Issued when the watched specs for the project changes""" specsChange: CurrentProject - """Status of the studio manager""" - studioStatusChange: StudioStatusType + """Status of the studio manager and AI access""" + studioStatusChange: StudioStatusPayload! } enum SupportStatusEnum { diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts index 9fd64366d38e..058a0264afa4 100644 --- a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts +++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts @@ -4,6 +4,17 @@ 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,20 +60,35 @@ export const Subscription = subscriptionType({ resolve: (source, args, ctx) => ctx.lifecycleManager, }) - t.field('studioStatusChange', { - type: StudioStatusTypeEnum, - description: 'Status of the studio manager', + 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 'INITIALIZING' + return { + status: 'INITIALIZING' as const, + canAccessStudioAI: false, + } } const studio = await ctx.coreData.studioLifecycleManager?.getStudio() - return studio?.status ?? null + 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, + } }, }) From 0ed20359d3a2189ab573b4088fd449f9e0339946 Mon Sep 17 00:00:00 2001 From: astone123 Date: Tue, 13 May 2025 14:17:55 -0400 Subject: [PATCH 19/19] fix test --- packages/app/cypress/e2e/studio/studio-cloud.cy.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/app/cypress/e2e/studio/studio-cloud.cy.ts b/packages/app/cypress/e2e/studio/studio-cloud.cy.ts index 8cbf98747751..74149c8a1219 100644 --- a/packages/app/cypress/e2e/studio/studio-cloud.cy.ts +++ b/packages/app/cypress/e2e/studio/studio-cloud.cy.ts @@ -132,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({