-
- Error loading the panel
-
-
{{ error }}
-
diff --git a/packages/app/src/studio/StudioPanelContainer.vue b/packages/app/src/studio/StudioPanelContainer.vue
new file mode 100644
index 0000000000..221e38cd0a
--- /dev/null
+++ b/packages/app/src/studio/StudioPanelContainer.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
diff --git a/packages/graphql/schemas/schema.graphql b/packages/graphql/schemas/schema.graphql
index 50c4487926..ecb460e3bd 100644
--- a/packages/graphql/schemas/schema.graphql
+++ b/packages/graphql/schemas/schema.graphql
@@ -1612,6 +1612,9 @@ type Mutation {
"""Reset the Wizard to the starting position"""
resetWizard: Boolean!
+ """Retry studio initialization after an error"""
+ retryStudio: Boolean
+
"""
Run a single spec file using a supplied path. This initiates but does not wait for completion of the requested spec run.
"""
diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts
index c0fbc5d9c4..5446fcd046 100644
--- a/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts
+++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Mutation.ts
@@ -196,6 +196,16 @@ export const mutation = mutationType({
},
})
+ t.field('retryStudio', {
+ type: 'Boolean',
+ description: 'Retry studio initialization after an error',
+ resolve: (_, args, ctx) => {
+ ctx.coreData.studioLifecycleManager?.retry()
+
+ return true
+ },
+ })
+
t.field('wizardUpdate', {
type: Wizard,
description: 'Updates the different fields of the wizard data store',
diff --git a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts
index 058a0264af..6f79b93ecd 100644
--- a/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts
+++ b/packages/graphql/src/schemaTypes/objectTypes/gql-Subscription.ts
@@ -65,11 +65,20 @@ export const Subscription = subscriptionType({
description: 'Status of the studio manager and AI access',
subscribe: (source, args, ctx) => ctx.emitter.subscribeTo('studioStatusChange'),
resolve: async (source, args, ctx) => {
+ const currentStatus = ctx.coreData.studioLifecycleManager?.getCurrentStatus()
+
+ if (currentStatus === 'IN_ERROR') {
+ return {
+ status: 'IN_ERROR' as const,
+ canAccessStudioAI: false,
+ }
+ }
+
const isStudioReady = ctx.coreData.studioLifecycleManager?.isStudioReady()
if (!isStudioReady) {
return {
- status: 'INITIALIZING' as const,
+ status: currentStatus || 'INITIALIZING' as const,
canAccessStudioAI: false,
}
}
diff --git a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts
index 465fb9e2a7..ffe253b0fc 100644
--- a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts
+++ b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts
@@ -9,43 +9,70 @@ import { verifySignatureFromFile } from '../../encryption'
const pkg = require('@packages/root')
const _delay = linearDelay(500)
+const DEFAULT_TIMEOUT = 25000
export const getStudioBundle = async ({ studioUrl, bundlePath }: { studioUrl: string, bundlePath: string }): Promise
=> {
let responseSignature: string | null = null
let responseManifestSignature: 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,
- 'x-os-name': os.platform(),
- 'x-cypress-version': pkg.version,
- },
- encrypt: 'signed',
- })
+ const controller = new AbortController()
+ const fetchTimeout = setTimeout(() => {
+ controller.abort()
+ }, DEFAULT_TIMEOUT)
- if (!response.ok) {
- throw new Error(`Failed to download studio bundle: ${response.statusText}`)
- }
-
- responseSignature = response.headers.get('x-cypress-signature')
- responseManifestSignature = response.headers.get('x-cypress-manifest-signature')
-
- await new Promise((resolve, reject) => {
- const writeStream = createWriteStream(bundlePath)
-
- writeStream.on('error', reject)
- writeStream.on('finish', () => {
- resolve()
+ try {
+ 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,
+ 'x-os-name': os.platform(),
+ 'x-cypress-version': pkg.version,
+ },
+ encrypt: 'signed',
+ signal: controller.signal,
})
- // @ts-expect-error - this is supported
- response.body?.pipe(writeStream)
- })
+ if (!response.ok) {
+ throw new Error(`Failed to download studio bundle: ${response.statusText}`)
+ }
+
+ responseSignature = response.headers.get('x-cypress-signature')
+ responseManifestSignature = response.headers.get('x-cypress-manifest-signature')
+
+ await new Promise((resolve, reject) => {
+ const writeStream = createWriteStream(bundlePath)
+
+ writeStream.on('error', (err) => {
+ writeStream.destroy()
+ reject(err)
+ })
+
+ writeStream.on('finish', () => {
+ resolve()
+ })
+
+ // @ts-expect-error - this is supported
+ response.body?.pipe(writeStream)
+ })
+
+ // Check if the operation was aborted due to timeout
+ if (controller.signal.aborted) {
+ throw new Error('Studio bundle fetch timed out')
+ }
+
+ clearTimeout(fetchTimeout)
+ } catch (error) {
+ clearTimeout(fetchTimeout)
+ if (error.name === 'AbortError') {
+ throw new Error('Studio bundle fetch timed out')
+ }
+
+ throw error
+ }
}, {
maxAttempts: 3,
retryDelay: _delay,
diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts
index f2dabda28d..d80866ef00 100644
--- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts
+++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts
@@ -35,6 +35,15 @@ export class StudioLifecycleManager {
private listeners: ((studioManager: StudioManager) => void)[] = []
private ctx?: DataContext
private lastStatus?: StudioStatus
+ private currentStudioHash?: string
+
+ private initializationParams?: {
+ projectId?: string
+ cloudDataSource: CloudDataSource
+ cfg: Cfg
+ debugData: any
+ ctx: DataContext
+ }
public get cloudStudioRequested () {
// TODO: Remove cloudStudioRequested when we remove the legacy studio code
@@ -66,6 +75,9 @@ export class StudioLifecycleManager {
}): void {
debug('Initializing studio manager')
+ // Store initialization parameters for retry
+ this.initializationParams = { projectId, cloudDataSource, cfg, debugData, ctx }
+
// Register this instance in the data context
ctx.update((data) => {
data.studioLifecycleManager = this
@@ -102,9 +114,6 @@ export class StudioLifecycleManager {
this.updateStatus('IN_ERROR')
- // Clean up any registered listeners
- this.listeners = []
-
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.BUNDLE_LIFECYCLE_END)
reportTelemetry(BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES.COMPLETE_BUNDLE_LIFECYCLE, {
success: false,
@@ -182,6 +191,9 @@ export class StudioLifecycleManager {
studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0]
studioPath = path.join(os.tmpdir(), 'cypress', 'studio', studioHash)
+ // Store the current studio hash so that we can clear the cache entry when retrying
+ this.currentStudioHash = studioHash
+
let hashLoadingPromise = StudioLifecycleManager.hashLoadingMap.get(studioHash)
if (!hashLoadingPromise) {
@@ -198,6 +210,7 @@ export class StudioLifecycleManager {
} else {
studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH
studioHash = 'local'
+ this.currentStudioHash = studioHash
manifest = {}
}
@@ -301,9 +314,8 @@ export class StudioLifecycleManager {
listener(studioManager)
})
- if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) {
- this.listeners = []
- }
+ debug('Clearing %d studio ready listeners after successful initialization', this.listeners.length)
+ this.listeners = []
}
private setupWatcher ({
@@ -362,18 +374,50 @@ export class StudioLifecycleManager {
if (this.studioManager) {
debug('Studio ready - calling listener immediately')
listener(this.studioManager)
-
- // If the studio bundle is local, we need to register the listener
- // so that we can reload the studio when the bundle changes
- if (process.env.CYPRESS_LOCAL_STUDIO_PATH) {
- this.listeners.push(listener)
- }
+ this.listeners.push(listener)
} else {
debug('Studio not ready - registering studio ready listener')
this.listeners.push(listener)
}
}
+ public getCurrentStatus (): StudioStatus | undefined {
+ return this.lastStatus
+ }
+
+ public retry (): void {
+ if (!this.ctx) {
+ debug('No ctx available, cannot retry studio initialization')
+
+ return
+ }
+
+ debug('Retrying studio initialization')
+
+ this.studioManager = undefined
+ this.studioManagerPromise = undefined
+ this.lastStatus = undefined
+
+ // Clear the cache entry for the current studio hash
+ if (this.currentStudioHash) {
+ const hadCachedPromise = StudioLifecycleManager.hashLoadingMap.has(this.currentStudioHash)
+
+ StudioLifecycleManager.hashLoadingMap.delete(this.currentStudioHash)
+ debug('Cleared cached studio bundle promise for hash: %s (was cached: %s)', this.currentStudioHash, hadCachedPromise)
+ this.currentStudioHash = undefined
+ } else {
+ debug('No current studio hash available to clear from cache')
+ }
+
+ // Re-initialize with the same parameters we stored
+ if (this.initializationParams) {
+ this.initializeStudioManager(this.initializationParams)
+ } else {
+ debug('No initialization parameters available for retry')
+ this.updateStatus('IN_ERROR')
+ }
+ }
+
public updateStatus (status: StudioStatus) {
if (status === this.lastStatus) {
debug('Studio status unchanged: %s', status)
diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts
index 38e3e4c7b9..fdbbe70c5b 100644
--- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts
+++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts
@@ -9,24 +9,19 @@ interface EnsureStudioBundleOptions {
studioUrl: string
projectId?: string
studioPath: string
- downloadTimeoutMs?: number
}
-const DOWNLOAD_TIMEOUT = 30000
-
/**
* Ensures that the studio bundle is downloaded and extracted into the given path
* @param options - The options for the ensure studio bundle operation
* @param options.studioUrl - The URL of the studio bundle
* @param options.projectId - The project ID of the studio bundle
* @param options.studioPath - The path to extract the studio bundle to
- * @param options.downloadTimeoutMs - The timeout for the download operation
*/
export const ensureStudioBundle = async ({
studioUrl,
projectId,
studioPath,
- downloadTimeoutMs = DOWNLOAD_TIMEOUT,
}: EnsureStudioBundleOptions): Promise> => {
const bundlePath = path.join(studioPath, 'bundle.tar')
@@ -34,21 +29,10 @@ export const ensureStudioBundle = async ({
await remove(studioPath)
await ensureDir(studioPath)
- let timeoutId: NodeJS.Timeout
-
- const responseManifestSignature: string = await Promise.race([
- getStudioBundle({
- studioUrl,
- bundlePath,
- }),
- new Promise((_, reject) => {
- timeoutId = setTimeout(() => {
- reject(new Error('Studio bundle download timed out'))
- }, downloadTimeoutMs)
- }),
- ]).finally(() => {
- clearTimeout(timeoutId)
- }) as string
+ const responseManifestSignature = await getStudioBundle({
+ studioUrl,
+ bundlePath,
+ })
await tar.extract({
file: bundlePath,
diff --git a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts
index 210f265ff6..3b92997539 100644
--- a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts
+++ b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts
@@ -75,6 +75,7 @@ describe('getStudioBundle', () => {
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
+ signal: sinon.match.any,
})
expect(writeResult).to.eq('console.log("studio bundle")')
@@ -117,6 +118,7 @@ describe('getStudioBundle', () => {
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
+ signal: sinon.match.any,
})
expect(writeResult).to.eq('console.log("studio bundle")')
@@ -144,6 +146,7 @@ describe('getStudioBundle', () => {
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
+ signal: sinon.match.any,
})
})
@@ -165,6 +168,7 @@ describe('getStudioBundle', () => {
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
+ signal: sinon.match.any,
})
})
@@ -204,6 +208,7 @@ describe('getStudioBundle', () => {
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
+ signal: sinon.match.any,
})
expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159')
@@ -235,6 +240,7 @@ describe('getStudioBundle', () => {
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
+ signal: sinon.match.any,
})
})
@@ -264,6 +270,52 @@ describe('getStudioBundle', () => {
'x-cypress-version': '1.2.3',
},
encrypt: 'signed',
+ signal: sinon.match.any,
})
})
+
+ it('handles AbortError and converts to timeout message', async () => {
+ const abortError = new Error('AbortError')
+
+ abortError.name = 'AbortError'
+
+ crossFetchStub.rejects(abortError)
+
+ await expect(getStudioBundle({
+ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz',
+ bundlePath: '/tmp/cypress/studio/abc/bundle.tar',
+ })).to.be.rejectedWith('Studio bundle fetch timed out')
+ })
+
+ it('calls cleanup function when pipe operation errors', async () => {
+ const errorStream = new Writable({
+ write: (chunk, encoding, callback) => {
+ callback(new Error('Write error'))
+ },
+ })
+
+ const destroySpy = sinon.spy(errorStream, 'destroy')
+
+ createWriteStreamStub.returns(errorStream)
+
+ crossFetchStub.resolves({
+ ok: true,
+ statusText: 'OK',
+ body: readStream,
+ headers: {
+ get: (header) => {
+ if (header === 'x-cypress-signature') return '159'
+
+ if (header === 'x-cypress-manifest-signature') return '160'
+ },
+ },
+ })
+
+ await expect(getStudioBundle({
+ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz',
+ bundlePath: '/tmp/cypress/studio/abc/bundle.tar',
+ })).to.be.rejected
+
+ expect(destroySpy).to.have.been.called
+ })
})
diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts
index cd357edec3..b41b91f2e7 100644
--- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts
+++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts
@@ -562,7 +562,6 @@ describe('StudioLifecycleManager', () => {
const listener1 = sinon.stub()
const listener2 = sinon.stub()
- // Register listeners that should be cleaned up
studioLifecycleManager.registerStudioReadyListener(listener1)
studioLifecycleManager.registerStudioReadyListener(listener2)
@@ -607,7 +606,7 @@ describe('StudioLifecycleManager', () => {
})
// @ts-expect-error - accessing private property
- expect(studioLifecycleManager.listeners.length).to.equal(0)
+ expect(studioLifecycleManager.listeners.length).to.equal(2)
expect(listener1).not.to.be.called
expect(listener2).not.to.be.called
@@ -789,47 +788,10 @@ describe('StudioLifecycleManager', () => {
expect(listener1).to.be.calledWith(mockStudioManager)
expect(listener2).to.be.calledWith(mockStudioManager)
+ // Listeners should be cleared after successful initialization
// @ts-expect-error - accessing private property
expect(studioLifecycleManager.listeners.length).to.equal(0)
})
-
- it('does not clean up listeners when CYPRESS_LOCAL_STUDIO_PATH is set', async () => {
- process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio'
-
- const listener1 = sinon.stub()
- const listener2 = sinon.stub()
-
- studioLifecycleManager.registerStudioReadyListener(listener1)
- studioLifecycleManager.registerStudioReadyListener(listener2)
-
- // @ts-expect-error - accessing private property
- expect(studioLifecycleManager.listeners.length).to.equal(2)
-
- const listenersCalledPromise = Promise.all([
- new Promise((resolve) => {
- listener1.callsFake(() => resolve())
- }),
- new Promise((resolve) => {
- listener2.callsFake(() => resolve())
- }),
- ])
-
- studioLifecycleManager.initializeStudioManager({
- projectId: 'test-project-id',
- cloudDataSource: mockCloudDataSource,
- ctx: mockCtx,
- cfg: mockCfg,
- debugData: {},
- })
-
- await listenersCalledPromise
-
- expect(listener1).to.be.calledWith(mockStudioManager)
- expect(listener2).to.be.calledWith(mockStudioManager)
-
- // @ts-expect-error - accessing private property
- expect(studioLifecycleManager.listeners.length).to.equal(2)
- })
})
describe('status tracking', () => {
@@ -927,4 +889,178 @@ describe('StudioLifecycleManager', () => {
expect(statusChangesSpy).to.be.calledWith('IN_ERROR')
})
})
+
+ describe('getCurrentStatus', () => {
+ it('returns undefined when no status has been set', () => {
+ expect(studioLifecycleManager.getCurrentStatus()).to.be.undefined
+ })
+
+ it('returns the current status after it has been set', () => {
+ studioLifecycleManager.updateStatus('INITIALIZING')
+ expect(studioLifecycleManager.getCurrentStatus()).to.equal('INITIALIZING')
+
+ studioLifecycleManager.updateStatus('ENABLED')
+ expect(studioLifecycleManager.getCurrentStatus()).to.equal('ENABLED')
+
+ studioLifecycleManager.updateStatus('IN_ERROR')
+ expect(studioLifecycleManager.getCurrentStatus()).to.equal('IN_ERROR')
+ })
+ })
+
+ describe('retry', () => {
+ it('clears state and re-initializes studio manager', async () => {
+ // Cloud studio is enabled
+ studioManagerSetupStub.callsFake((args) => {
+ mockStudioManager.status = 'ENABLED'
+
+ return Promise.resolve()
+ })
+
+ const mockManifest = {
+ 'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
+ }
+
+ ensureStudioBundleStub.resolves(mockManifest)
+
+ // First initialize with some state
+ studioLifecycleManager.initializeStudioManager({
+ projectId: 'test-project-id',
+ cloudDataSource: mockCloudDataSource,
+ ctx: mockCtx,
+ cfg: mockCfg,
+ debugData: {},
+ })
+
+ // Wait for initialization to complete
+ await new Promise((resolve) => {
+ studioLifecycleManager.registerStudioReadyListener(() => {
+ resolve(true)
+ })
+ })
+
+ // Initial state
+ expect(studioLifecycleManager.getCurrentStatus()).to.equal('ENABLED')
+ expect(studioLifecycleManager.isStudioReady()).to.be.true
+
+ const initialCallCount = postStudioSessionStub.callCount
+
+ studioLifecycleManager.retry()
+
+ // Verify state was cleared
+ expect(studioLifecycleManager.getCurrentStatus()).to.equal('INITIALIZING')
+ expect(studioLifecycleManager.isStudioReady()).to.be.false
+
+ // Wait for retry initialization to complete by waiting for the promise to resolve
+ // @ts-expect-error - accessing private property
+ const retryPromise = studioLifecycleManager.studioManagerPromise
+
+ await retryPromise
+
+ // Verify retry worked
+ expect(studioLifecycleManager.getCurrentStatus()).to.equal('ENABLED')
+ expect(studioLifecycleManager.isStudioReady()).to.be.true
+
+ // Verify initialization was called again (should be initial + 1 more for retry)
+ expect(postStudioSessionStub.callCount).to.equal(initialCallCount + 1)
+ expect(studioManagerSetupStub.callCount).to.equal(initialCallCount + 1)
+ expect(ensureStudioBundleStub.callCount).to.equal(initialCallCount + 1)
+ })
+
+ it('sets status to IN_ERROR when no initialization parameters are available', () => {
+ // Set up ctx so retry doesn't return early
+ // @ts-expect-error - accessing private property
+ studioLifecycleManager.ctx = mockCtx
+
+ // Don't initialize first, so no params are stored
+ studioLifecycleManager.retry()
+
+ expect(studioLifecycleManager.getCurrentStatus()).to.equal('IN_ERROR')
+ })
+
+ it('does nothing when no ctx is available', () => {
+ const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
+
+ // Call retry without ctx
+ studioLifecycleManager.retry()
+
+ // Should not have updated status
+ expect(statusChangesSpy).not.to.be.called
+ })
+
+ it('clears the current studio hash from cached bundle promises on retry', async () => {
+ // Add some cached promises to the static map
+ const dummyPromise = Promise.resolve()
+
+ // @ts-expect-error - accessing private static property
+ StudioLifecycleManager.hashLoadingMap.set('test-hash-1', dummyPromise)
+ // @ts-expect-error - accessing private static property
+ StudioLifecycleManager.hashLoadingMap.set('abc', dummyPromise) // This should be the current hash (from studioUrl)
+
+ // @ts-expect-error - accessing private static property
+ expect(StudioLifecycleManager.hashLoadingMap.size).to.equal(2)
+
+ // Initialize with ctx so retry will work
+ studioLifecycleManager.initializeStudioManager({
+ projectId: 'test-project-id',
+ cloudDataSource: mockCloudDataSource,
+ ctx: mockCtx,
+ cfg: mockCfg,
+ debugData: {},
+ })
+
+ // @ts-expect-error - accessing private property
+ studioLifecycleManager.currentStudioHash = 'abc'
+
+ studioLifecycleManager.retry()
+
+ // Verify only the current studio hash was cleared (abc from the studioUrl)
+ // @ts-expect-error - accessing private static property
+ expect(StudioLifecycleManager.hashLoadingMap.has('test-hash-1')).to.be.true
+ // @ts-expect-error - accessing private static property
+ expect(StudioLifecycleManager.hashLoadingMap.has('abc')).to.be.false
+ // @ts-expect-error - accessing private static property
+ expect(StudioLifecycleManager.hashLoadingMap.size).to.equal(1)
+ })
+
+ it('clears the local hash when using local studio path', async () => {
+ process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio'
+
+ // Add some cached promises to the static map, including 'local' hash
+ const dummyPromise = Promise.resolve()
+
+ // @ts-expect-error - accessing private static property
+ StudioLifecycleManager.hashLoadingMap.set('test-hash-1', dummyPromise)
+ // @ts-expect-error - accessing private static property
+ StudioLifecycleManager.hashLoadingMap.set('local', dummyPromise) // This should be cleared
+
+ // @ts-expect-error - accessing private static property
+ expect(StudioLifecycleManager.hashLoadingMap.size).to.equal(2)
+
+ // Initialize with ctx so retry will work
+ studioLifecycleManager.initializeStudioManager({
+ projectId: 'test-project-id',
+ cloudDataSource: mockCloudDataSource,
+ ctx: mockCtx,
+ cfg: mockCfg,
+ debugData: {},
+ })
+
+ // Wait for initialization to complete
+ await new Promise((resolve) => {
+ studioLifecycleManager.registerStudioReadyListener(() => {
+ resolve(true)
+ })
+ })
+
+ studioLifecycleManager.retry()
+
+ // Verify only the 'local' hash was cleared
+ // @ts-expect-error - accessing private static property
+ expect(StudioLifecycleManager.hashLoadingMap.has('test-hash-1')).to.be.true
+ // @ts-expect-error - accessing private static property
+ expect(StudioLifecycleManager.hashLoadingMap.has('local')).to.be.false
+ // @ts-expect-error - accessing private static property
+ expect(StudioLifecycleManager.hashLoadingMap.size).to.equal(1)
+ })
+ })
})
diff --git a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts
index 2cc63729a4..33c3ffc4c4 100644
--- a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts
+++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts
@@ -103,23 +103,4 @@ describe('ensureStudioBundle', () => {
await expect(ensureStudioBundlePromise).to.be.rejectedWith('Unable to find studio manifest')
})
-
- it('should throw an error if the studio bundle download times out', async () => {
- getStudioBundleStub.callsFake(() => {
- return new Promise((resolve) => {
- setTimeout(() => {
- resolve(new Error('Studio bundle download timed out'))
- }, 3000)
- })
- })
-
- const ensureStudioBundlePromise = ensureStudioBundle({
- studioPath: '/tmp/cypress/studio/123',
- studioUrl: 'https://cypress.io/studio',
- projectId: '123',
- downloadTimeoutMs: 500,
- })
-
- await expect(ensureStudioBundlePromise).to.be.rejectedWith('Studio bundle download timed out')
- })
})
diff --git a/packages/types/src/studio/index.ts b/packages/types/src/studio/index.ts
index beffb5f4a4..c7433339aa 100644
--- a/packages/types/src/studio/index.ts
+++ b/packages/types/src/studio/index.ts
@@ -20,6 +20,8 @@ export interface StudioLifecycleManagerShape {
registerStudioReadyListener: (listener: (studioManager: StudioManagerShape) => void) => void
cloudStudioRequested: boolean
updateStatus: (status: StudioStatus) => void
+ getCurrentStatus: () => StudioStatus | undefined
+ retry: () => void
}
export type StudioErrorReport = {