mirror of
https://github.com/cypress-io/cypress.git
synced 2026-01-06 22:49:46 -06:00
internal: (studio) show error and allow retry when studio cannot be initialized (#31951)
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
69
packages/app/src/studio/StudioErrorPanel.cy.tsx
Normal file
69
packages/app/src/studio/StudioErrorPanel.cy.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
53
packages/app/src/studio/StudioErrorPanel.vue
Normal file
53
packages/app/src/studio/StudioErrorPanel.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
42
packages/app/src/studio/StudioPanelContainer.vue
Normal file
42
packages/app/src/studio/StudioPanelContainer.vue
Normal 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>
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user