internal: (studio) show error and allow retry when studio cannot be initialized (#31951)

This commit is contained in:
Adam Stone-Lord
2025-07-03 12:07:44 -04:00
committed by GitHub
parent 72daa51404
commit e05de87b51
16 changed files with 782 additions and 180 deletions

View File

@@ -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')
})
})
})
})

View File

@@ -1,41 +1,20 @@
<template>
<div
class="loading-studio-panel border-l border-gray-900 h-screen flex flex-col"
<StudioPanelContainer
:event-manager="props.eventManager"
data-cy="loading-studio-panel"
container-class="text-gray-400"
>
<header class="border-b border-gray-800 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<StudioButton :event-manager="props.eventManager" />
</div>
</div>
</header>
<div class="flex flex-col items-center justify-center w-full h-full gap-[16px] p-[48px_16px_0_16px] text-gray-400">
<Spinner variant="dark" />
Setting up Cypress Studio...
</div>
</div>
<Spinner variant="dark" />
Setting up Cypress Studio...
</StudioPanelContainer>
</template>
<script lang="ts" setup>
import Spinner from '@cypress-design/vue-spinner/sfc'
import StudioButton from './StudioButton.vue'
import StudioPanelContainer from './StudioPanelContainer.vue'
import type { EventManager } from '../runner/event-manager'
const props = defineProps<{
eventManager: EventManager
}>()
</script>
<style scoped lang="scss">
.loading-studio-panel {
background-color: $gray-1100;
header {
background-color: $gray-1100;
}
}
</style>

View File

@@ -0,0 +1,69 @@
import StudioErrorPanel from './StudioErrorPanel.vue'
import type { EventManager } from '../runner/event-manager'
describe('<StudioErrorPanel />', () => {
it('renders error state with correct content', () => {
const mockEventManager = {
emit: cy.stub(),
} as unknown as EventManager
cy.mount(
<StudioErrorPanel
eventManager={mockEventManager}
onRetry={() => {}}
/>,
)
// 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(
<StudioErrorPanel
eventManager={mockEventManager}
onRetry={onRetry}
/>,
)
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(
<StudioErrorPanel
eventManager={mockEventManager}
onRetry={() => {}}
/>,
)
// Check that the Studio button is present in the header
cy.findByTestId('studio-button').should('be.visible')
})
})

View File

@@ -0,0 +1,53 @@
<template>
<StudioPanelContainer
:event-manager="props.eventManager"
data-cy="studio-error-panel"
container-class="text-center"
>
<div class="relative">
<IconTechnologyDashboardFail
size="48"
stroke-color="gray-500"
fill-color="gray-900"
secondary-fill-color="red-200"
secondary-stroke-color="red-500"
/>
</div>
<div class="flex flex-col items-center gap-[4px] max-w-[448px]">
<h2 class="text-white text-[16px] leading-[24px] font-medium">
Something went wrong
</h2>
<p class="text-gray-400 text-[16px] leading-[24px]">
There was a problem with Cypress Studio. Our team has been notified.
If the problem persists, please try again later.
</p>
</div>
<Button
variant="outline-dark"
size="32"
data-cy="studio-error-retry-button"
@click="onRetry"
>
<IconActionRefresh
size="16"
class="mr-2 pt-[1px]"
stroke-color="gray-500"
/>
Retry
</Button>
</StudioPanelContainer>
</template>
<script lang="ts" setup>
import Button from '@cypress-design/vue-button'
import { IconTechnologyDashboardFail, IconActionRefresh } from '@cypress-design/vue-icon'
import StudioPanelContainer from './StudioPanelContainer.vue'
import type { EventManager } from '../runner/event-manager'
const props = defineProps<{
eventManager: EventManager
onRetry: () => void
}>()
</script>

View File

@@ -8,20 +8,11 @@
<!-- these are two distinct errors: -->
<!-- * if studio status is IN_ERROR, it means that the studio bundle failed to load from the cloud -->
<!-- * if there is an error in the component state, it means module federation failed to load the component -->
<div v-else-if="props.studioStatus === 'IN_ERROR'">
<div class="p-4 text-red-500 font-medium">
<div class="mb-2">
Error fetching studio bundle from cloud
</div>
</div>
</div>
<div v-else-if="error">
<div class="p-4 text-red-500 font-medium">
<div class="mb-2">
Error loading the panel
</div>
<div>{{ error }}</div>
</div>
<div v-else-if="props.studioStatus === 'IN_ERROR' || error">
<StudioErrorPanel
:event-manager="props.eventManager"
:on-retry="handleRetry"
/>
</div>
<div
v-else
@@ -35,10 +26,12 @@
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { init, loadRemote } from '@module-federation/runtime'
import { init, loadRemote, registerRemotes } from '@module-federation/runtime'
import type { StudioAppDefaultShape, StudioPanelShape } from './studio-app-types'
import LoadingStudioPanel from './LoadingStudioPanel.vue'
import StudioErrorPanel from './StudioErrorPanel.vue'
import type { EventManager } from '../runner/event-manager'
import { useMutation, gql } from '@urql/vue'
// Mirrors the ReactDOM.Root type since incorporating those types
// messes up vue typing elsewhere
@@ -47,6 +40,12 @@ interface Root {
unmount: () => void
}
const retryStudioMutationGql = gql`
mutation RetryStudio {
retryStudio
}
`
const props = defineProps<{
canAccessStudioAI: boolean
onStudioPanelClose: () => void
@@ -62,6 +61,8 @@ const error = ref<string | null>(null)
const ReactStudioPanel = ref<StudioPanelShape | null>(null)
const reactRoot = ref<Root | null>(null)
const retryStudioMutation = useMutation(retryStudioMutationGql)
const maybeRenderReactComponent = () => {
// Skip rendering if studio is initializing or errored out
if (props.studioStatus === 'INITIALIZING' || props.studioStatus === 'IN_ERROR') {
@@ -147,4 +148,26 @@ function loadStudioComponent () {
})
}
function handleRetry () {
error.value = null
ReactStudioPanel.value = null
// If status was IN_ERROR, we need to retry the studio initialization
if (props.studioStatus === 'IN_ERROR') {
retryStudioMutation.executeMutation({})
} else {
// Otherwise, try to reload the studio component with a cache-busting parameter
registerRemotes([{
alias: 'app-studio',
type: 'module',
name: 'app-studio',
entryGlobalName: 'app-studio',
entry: `/__cypress-studio/app-studio.js?retry=${Date.now()}`,
shareScope: 'default',
}], { force: true })
loadStudioComponent()
}
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div
:class="[
'studio-panel-container border-l border-gray-900 h-screen flex flex-col',
containerClass
]"
:data-cy="dataCy"
>
<header class="border-b border-gray-800 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<StudioButton :event-manager="eventManager" />
</div>
</div>
</header>
<div class="flex flex-col items-center justify-center w-full h-full gap-[16px] p-[48px_16px_0_16px]">
<slot />
</div>
</div>
</template>
<script lang="ts" setup>
import StudioButton from './StudioButton.vue'
import type { EventManager } from '../runner/event-manager'
defineProps<{
eventManager: EventManager
dataCy: string
containerClass?: string
}>()
</script>
<style scoped lang="scss">
.studio-panel-container {
background-color: $gray-1100;
header {
background-color: $gray-1100;
}
}
</style>

View File

@@ -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.
"""

View File

@@ -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',

View File

@@ -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,
}
}

View File

@@ -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<string> => {
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<void>((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<void>((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,

View File

@@ -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)

View File

@@ -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<Record<string, string>> => {
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,

View File

@@ -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
})
})

View File

@@ -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<void>((resolve) => {
listener1.callsFake(() => resolve())
}),
new Promise<void>((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)
})
})
})

View File

@@ -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')
})
})

View File

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