From e05de87b51ca54def6d2f941bbef8281f8732c4e Mon Sep 17 00:00:00 2001 From: Adam Stone-Lord Date: Thu, 3 Jul 2025 12:07:44 -0400 Subject: [PATCH] internal: (studio) show error and allow retry when studio cannot be initialized (#31951) --- .../app/cypress/e2e/studio/studio-cloud.cy.ts | 222 ++++++++++++++++-- .../app/src/studio/LoadingStudioPanel.vue | 35 +-- .../app/src/studio/StudioErrorPanel.cy.tsx | 69 ++++++ packages/app/src/studio/StudioErrorPanel.vue | 53 +++++ packages/app/src/studio/StudioPanel.vue | 53 +++-- .../app/src/studio/StudioPanelContainer.vue | 42 ++++ packages/graphql/schemas/schema.graphql | 3 + .../schemaTypes/objectTypes/gql-Mutation.ts | 10 + .../objectTypes/gql-Subscription.ts | 11 +- .../lib/cloud/api/studio/get_studio_bundle.ts | 83 ++++--- .../cloud/studio/StudioLifecycleManager.ts | 68 +++++- .../lib/cloud/studio/ensure_studio_bundle.ts | 24 +- .../api/studio/get_studio_bundle_spec.ts | 52 ++++ .../studio/StudioLifecycleManager_spec.ts | 216 +++++++++++++---- .../cloud/studio/ensure_studio_bundle_spec.ts | 19 -- packages/types/src/studio/index.ts | 2 + 16 files changed, 782 insertions(+), 180 deletions(-) create mode 100644 packages/app/src/studio/StudioErrorPanel.cy.tsx create mode 100644 packages/app/src/studio/StudioErrorPanel.vue create mode 100644 packages/app/src/studio/StudioPanelContainer.vue diff --git a/packages/app/cypress/e2e/studio/studio-cloud.cy.ts b/packages/app/cypress/e2e/studio/studio-cloud.cy.ts index 4df583010c..5a3b5b3234 100644 --- a/packages/app/cypress/e2e/studio/studio-cloud.cy.ts +++ b/packages/app/cypress/e2e/studio/studio-cloud.cy.ts @@ -30,11 +30,11 @@ describe('Studio Cloud', () => { .click() // regular studio is not loaded until after the test finishes - cy.get('[data-cy="hook-name-studio commands"]').should('not.exist') + cy.findByTestId('hook-name-studio commands').should('not.exist') // cloud studio is loaded immediately cy.findByTestId('studio-panel').then(() => { // check for the loading panel from the app first - cy.get('[data-cy="loading-studio-panel"]').should('be.visible') + cy.findByTestId('loading-studio-panel').should('be.visible') // we've verified the studio panel is loaded, now resolve the promise so the test can finish deferred.resolve() }) @@ -46,7 +46,7 @@ describe('Studio Cloud', () => { // Verify the studio panel is still open cy.findByTestId('studio-panel') - cy.get('[data-cy="hook-name-studio commands"]') + cy.findByTestId('hook-name-studio commands') }) it('hides selector playground and studio controls when studio beta is available', () => { @@ -54,17 +54,17 @@ describe('Studio Cloud', () => { 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') + cy.findByTestId('playground-activator').should('not.exist') + cy.findByTestId('studio-toolbar').should('not.exist') }) it('closes studio panel when clicking studio button (from the cloud)', () => { launchStudio() cy.findByTestId('studio-panel').should('be.visible') - cy.get('[data-cy="loading-studio-panel"]').should('not.exist') + cy.findByTestId('loading-studio-panel').should('not.exist') - cy.get('[data-cy="studio-header-studio-button"]').click() + cy.findByTestId('studio-header-studio-button').click() assertClosingPanelWithoutChanges() }) @@ -73,12 +73,12 @@ describe('Studio Cloud', () => { cy.viewport(1500, 1000) loadProjectAndRunSpec() // studio button should be visible when using cloud studio - cy.get('[data-cy="studio-button"]').should('be.visible').click() - cy.get('[data-cy="studio-panel"]').should('be.visible') + cy.findByTestId('studio-button').should('be.visible').click() + cy.findByTestId('studio-panel').should('be.visible') cy.contains('New Test') - cy.get('[data-cy="studio-url-prompt"]').should('not.exist') + cy.findByTestId('studio-url-prompt').should('not.exist') cy.percySnapshot() }) @@ -136,11 +136,11 @@ describe('Studio Cloud', () => { .click() // regular studio is not loaded until after the test finishes - cy.get('[data-cy="hook-name-studio commands"]').should('not.exist') + cy.findByTestId('hook-name-studio commands').should('not.exist') // cloud studio is loaded immediately cy.findByTestId('studio-panel').then(() => { // check for the loading panel from the app first - cy.get('[data-cy="loading-studio-panel"]').should('be.visible') + cy.findByTestId('loading-studio-panel').should('be.visible') // we've verified the studio panel is loaded, now resolve the promise so the test can finish deferred.resolve() }) @@ -152,16 +152,16 @@ describe('Studio Cloud', () => { // Verify the studio panel is still open cy.findByTestId('studio-panel') - cy.get('[data-cy="hook-name-studio commands"]') + cy.findByTestId('hook-name-studio commands') // make sure studio is not loading - cy.get('[data-cy="loading-studio-panel"]').should('not.exist') + cy.findByTestId('loading-studio-panel').should('not.exist') // Verify that AI is enabled - cy.get('[data-cy="ai-status-text"]').should('contain.text', 'Enabled') + cy.findByTestId('ai-status-text').should('contain.text', 'Enabled') // Verify that the AI output is correct - cy.get('[data-cy="recommendation-editor"]').should('contain', aiOutput) + cy.findByTestId('recommendation-editor').should('contain', aiOutput) }) it('does not exit studio mode if the spec is changed on the file system', () => { @@ -214,6 +214,194 @@ describe('studio functionality', () => { cy.findByTestId('studio-panel').should('be.visible') - cy.get('[data-cy="studio-toolbar"]').should('not.exist') + cy.findByTestId('studio-toolbar').should('not.exist') + }) + + describe('failing to load studio and retrying', () => { + it('displays error panel when studio bundle fails to load', () => { + // Intercept the studio bundle request and make it fail + cy.intercept('GET', '/__cypress-studio/app-studio.js', { + statusCode: 500, + body: 'Internal Server Error', + }).as('studioBundleFail') + + loadProjectAndRunSpec() + + cy.contains('visits a basic html page') + .closest('.runnable-wrapper') + .findByTestId('launch-studio') + .click() + + cy.waitForSpecToFinish() + + // Wait for the failed studio bundle request + cy.wait('@studioBundleFail') + + // Verify the error panel is displayed + cy.findByTestId('studio-error-panel').should('be.visible') + cy.contains('Something went wrong') + cy.findByTestId('studio-error-panel').should('contain.text', 'There was a problem with Cypress Studio. Our team has been notified. If the problem persists, please try again later.') + + // Verify retry button is present + cy.findByTestId('studio-error-retry-button').should('be.visible') + + cy.percySnapshot('studio-error-panel') + }) + + it('shows retry button with refresh icon', () => { + // Intercept and fail the studio bundle request + cy.intercept('GET', '/__cypress-studio/app-studio.js', { + statusCode: 404, + body: 'Not Found', + }).as('studioBundleNotFound') + + loadProjectAndRunSpec() + + cy.contains('visits a basic html page') + .closest('.runnable-wrapper') + .findByTestId('launch-studio') + .click() + + cy.waitForSpecToFinish() + + // Wait for the failed request + cy.wait('@studioBundleNotFound') + + // Verify error panel and retry button + cy.findByTestId('studio-error-panel').should('be.visible') + cy.findByTestId('studio-error-retry-button') + .should('be.visible') + .should('contain', 'Retry') + .find('svg') // Check for the refresh icon + .should('exist') + }) + + it('retries studio initialization when retry button is clicked', () => { + let firstCallMade = false + + cy.intercept('GET', '/__cypress-studio/app-studio.js*', (req) => { + if (!firstCallMade) { + // First call fails + firstCallMade = true + req.reply({ + statusCode: 500, + body: 'Server Error', + }) + } else { + // Subsequent calls succeed + req.continue() + } + }).as('studioBundleRequest') + + loadProjectAndRunSpec() + + cy.contains('visits a basic html page') + .closest('.runnable-wrapper') + .findByTestId('launch-studio') + .click() + + cy.waitForSpecToFinish() + + // Wait for the first failed request + cy.wait('@studioBundleRequest') + + // Verify error panel is shown + cy.findByTestId('studio-error-panel').should('be.visible') + + // Click retry button + cy.findByTestId('studio-error-retry-button').click() + + // Verify that the error panel disappears (indicating retry worked) + cy.findByTestId('studio-error-panel').should('not.exist') + + // Verify loading panel appears + cy.findByTestId('loading-studio-panel').should('be.visible') + + // Wait for studio to load successfully + cy.findByTestId('studio-panel', { timeout: 10000 }).should('be.visible') + + cy.findByTestId('test-block-editor').within(() => { + cy.contains('cy.visit') + }) + }) + + it('maintains studio button functionality during error state', () => { + // Intercept and fail the studio bundle request + cy.intercept('GET', '/__cypress-studio/app-studio.js', { + statusCode: 503, + body: 'Service Unavailable', + }).as('studioBundleUnavailable') + + loadProjectAndRunSpec() + + cy.contains('visits a basic html page') + .closest('.runnable-wrapper') + .findByTestId('launch-studio') + .click() + + cy.waitForSpecToFinish() + + // Wait for the failed request + cy.wait('@studioBundleUnavailable') + + // Verify error panel is displayed + cy.findByTestId('studio-error-panel').should('be.visible') + + // Verify studio button is still present in the error panel header + cy.findByTestId('studio-error-panel').within(() => { + cy.findByTestId('studio-button').should('be.visible') + }) + + // Click studio button to close error panel + cy.findByTestId('studio-button').click() + + // Verify error panel is closed + cy.findByTestId('studio-error-panel').should('not.exist') + }) + + it('handles multiple retry attempts gracefully', () => { + let failedCallCount = 0 + + cy.intercept('GET', '/__cypress-studio/app-studio.js*', (req) => { + if (failedCallCount < 2) { + // First two calls fail + failedCallCount++ + req.reply({ + statusCode: 500, + body: 'Attempt failed', + }) + } else { + // Third call succeeds + req.continue() + } + }).as('studioBundleRequest') + + loadProjectAndRunSpec() + + cy.contains('visits a basic html page') + .closest('.runnable-wrapper') + .findByTestId('launch-studio') + .click() + + cy.waitForSpecToFinish() + + // Wait for first failed request + cy.wait('@studioBundleRequest') + + // First retry attempt + cy.findByTestId('studio-error-panel').should('be.visible') + cy.findByTestId('studio-error-retry-button').click() + + // Second retry attempt + cy.findByTestId('studio-error-panel').should('be.visible') + cy.findByTestId('studio-error-retry-button').click() + + // Third attempt should succeed + cy.findByTestId('studio-error-panel').should('not.exist') + cy.findByTestId('studio-panel', { timeout: 10000 }).should('be.visible') + cy.findByTestId('test-block-editor').within(() => { + cy.contains('cy.visit') + }) + }) }) }) diff --git a/packages/app/src/studio/LoadingStudioPanel.vue b/packages/app/src/studio/LoadingStudioPanel.vue index e7073133a3..bea6662e20 100644 --- a/packages/app/src/studio/LoadingStudioPanel.vue +++ b/packages/app/src/studio/LoadingStudioPanel.vue @@ -1,41 +1,20 @@ - - diff --git a/packages/app/src/studio/StudioErrorPanel.cy.tsx b/packages/app/src/studio/StudioErrorPanel.cy.tsx new file mode 100644 index 0000000000..88d688661f --- /dev/null +++ b/packages/app/src/studio/StudioErrorPanel.cy.tsx @@ -0,0 +1,69 @@ +import StudioErrorPanel from './StudioErrorPanel.vue' +import type { EventManager } from '../runner/event-manager' + +describe('', () => { + it('renders error state with correct content', () => { + const mockEventManager = { + emit: cy.stub(), + } as unknown as EventManager + + cy.mount( + {}} + />, + ) + + // Check that the error panel is displayed + cy.findByTestId('studio-error-panel').should('be.visible') + + // Check for the error icon + cy.findByTestId('studio-error-panel') + .find('svg') + .should('be.visible') + + // Check for the error description + cy.findByTestId('studio-error-panel').should('contain.text', 'There was a problem with Cypress Studio. Our team has been notified. If the problem persists, please try again later.') + cy.contains('Our team has been notified').should('be.visible') + + // Check for the retry button + cy.findByTestId('studio-error-retry-button') + .should('be.visible') + .should('contain', 'Retry') + }) + + it('calls onRetry when retry button is clicked', () => { + const mockEventManager = { + emit: cy.stub(), + } as unknown as EventManager + + const onRetry = cy.stub().as('onRetry') + + cy.mount( + , + ) + + cy.findByTestId('studio-error-retry-button').click() + + cy.get('@onRetry').should('have.been.calledOnce') + }) + + it('shows Studio button in header', () => { + const mockEventManager = { + emit: cy.stub(), + } as unknown as EventManager + + cy.mount( + {}} + />, + ) + + // Check that the Studio button is present in the header + cy.findByTestId('studio-button').should('be.visible') + }) +}) diff --git a/packages/app/src/studio/StudioErrorPanel.vue b/packages/app/src/studio/StudioErrorPanel.vue new file mode 100644 index 0000000000..b5e4968a8b --- /dev/null +++ b/packages/app/src/studio/StudioErrorPanel.vue @@ -0,0 +1,53 @@ + + + diff --git a/packages/app/src/studio/StudioPanel.vue b/packages/app/src/studio/StudioPanel.vue index 315bfd85b6..240ae2764b 100644 --- a/packages/app/src/studio/StudioPanel.vue +++ b/packages/app/src/studio/StudioPanel.vue @@ -8,20 +8,11 @@ -
-
-
- Error fetching studio bundle from cloud -
-
-
-
-
-
- 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 = {