Skip to content

Commit a5ba8c4

Browse files
astone123ryanthemanuelmschile
authored
internal: (studio) initialize cloud studio asynchronously (#31469)
Co-authored-by: Ryan Manuel <[email protected]> Co-authored-by: Matt Schile <[email protected]>
1 parent edba8e4 commit a5ba8c4

27 files changed

+1040
-384
lines changed

cli/types/cypress.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3330,7 +3330,6 @@ declare namespace Cypress {
33303330
spec: Cypress['spec'] | null
33313331
specs: Array<Cypress['spec']>
33323332
isDefaultProtocolEnabled: boolean
3333-
isStudioProtocolEnabled: boolean
33343333
hideCommandLog: boolean
33353334
hideRunnerUi: boolean
33363335
}

packages/app/cypress/e2e/studio/studio.cy.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,52 @@ describe('studio functionality', () => {
5656

5757
cy.window().then((win) => {
5858
expect(win.Cypress.config('isDefaultProtocolEnabled')).to.be.false
59-
expect(win.Cypress.config('isStudioProtocolEnabled')).to.be.true
6059
expect(win.Cypress.state('isProtocolEnabled')).to.be.true
6160
})
6261
})
6362

63+
it('loads the studio UI correctly when studio bundle is taking too long to load', () => {
64+
loadProjectAndRunSpec({ enableCloudStudio: false })
65+
66+
cy.window().then(() => {
67+
cy.withCtx((ctx) => {
68+
// Mock the studioLifecycleManager.getStudio method to return a hanging promise
69+
if (ctx.coreData.studioLifecycleManager) {
70+
const neverResolvingPromise = new Promise<null>(() => {})
71+
72+
ctx.coreData.studioLifecycleManager.getStudio = () => neverResolvingPromise
73+
ctx.coreData.studioLifecycleManager.isStudioReady = () => false
74+
}
75+
})
76+
})
77+
78+
cy.contains('visits a basic html page')
79+
.closest('.runnable-wrapper')
80+
.findByTestId('launch-studio')
81+
.click()
82+
83+
cy.waitForSpecToFinish()
84+
85+
// Verify the cloud studio panel is not present
86+
cy.findByTestId('studio-panel').should('not.exist')
87+
88+
cy.get('[data-cy="loading-studio-panel"]').should('not.exist')
89+
90+
cy.get('[data-cy="hook-name-studio commands"]').should('exist')
91+
92+
cy.getAutIframe().within(() => {
93+
cy.get('#increment').realClick()
94+
})
95+
96+
cy.findByTestId('hook-name-studio commands').closest('.hook-studio').within(() => {
97+
cy.get('.command').should('have.length', 2)
98+
cy.get('.command-name-get').should('contain.text', '#increment')
99+
cy.get('.command-name-click').should('contain.text', 'click')
100+
})
101+
102+
cy.get('button').contains('Save Commands').should('not.be.disabled')
103+
})
104+
64105
it('does not display Studio button when not using cloud studio', () => {
65106
loadProjectAndRunSpec({ })
66107

packages/app/src/runner/SpecRunnerOpenMode.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,11 @@ const studioStatus = computed(() => {
248248
})
249249
250250
const shouldShowStudioButton = computed(() => {
251-
return !!props.gql.studio && !studioStore.isOpen
251+
return !!props.gql.studio && studioStatus.value === 'ENABLED' && !studioStore.isOpen
252252
})
253253
254254
const shouldShowStudioPanel = computed(() => {
255-
return studioStatus.value === 'INITIALIZED' && (studioStore.isLoading || studioStore.isActive)
255+
return studioStatus.value === 'ENABLED' && (studioStore.isLoading || studioStore.isActive)
256256
})
257257
258258
const hideCommandLog = runnerUiStore.hideCommandLog

packages/app/src/runner/event-manager.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,10 +449,20 @@ export class EventManager {
449449
this.studioStore.setup(config)
450450

451451
const isDefaultProtocolEnabled = Cypress.config('isDefaultProtocolEnabled')
452-
const isStudioProtocolEnabled = Cypress.config('isStudioProtocolEnabled')
452+
453453
const isStudioInScope = this.studioStore.isActive || this.studioStore.isLoading
454454

455-
Cypress.state('isProtocolEnabled', isDefaultProtocolEnabled || (isStudioProtocolEnabled && isStudioInScope))
455+
if (isStudioInScope && !isDefaultProtocolEnabled) {
456+
await new Promise<void>((resolve) => {
457+
this.ws.emit('studio:protocol:enabled', ({ studioProtocolEnabled }) => {
458+
Cypress.state('isProtocolEnabled', studioProtocolEnabled)
459+
460+
resolve()
461+
})
462+
})
463+
} else {
464+
Cypress.state('isProtocolEnabled', isDefaultProtocolEnabled)
465+
}
456466

457467
this._addListeners()
458468
}

packages/config/__snapshots__/index.spec.ts.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,6 @@ exports['config/src/index .getDefaultValues returns list of public config keys 1
105105
'socketIoCookie': '__socket',
106106
'socketIoRoute': '/__socket',
107107
'isDefaultProtocolEnabled': false,
108-
'isStudioProtocolEnabled': false,
109108
'hideCommandLog': false,
110109
'hideRunnerUi': false,
111110
}
@@ -197,7 +196,6 @@ exports['config/src/index .getDefaultValues returns list of public config keys f
197196
'socketIoCookie': '__socket',
198197
'socketIoRoute': '/__socket',
199198
'isDefaultProtocolEnabled': false,
200-
'isStudioProtocolEnabled': false,
201199
'hideCommandLog': false,
202200
'hideRunnerUi': false,
203201
}

packages/config/src/options.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -593,11 +593,6 @@ const runtimeOptions: Array<RuntimeConfigOption> = [
593593
defaultValue: false,
594594
validation: validate.isBoolean,
595595
isInternal: true,
596-
}, {
597-
name: 'isStudioProtocolEnabled',
598-
defaultValue: false,
599-
validation: validate.isBoolean,
600-
isInternal: true,
601596
}, {
602597
name: 'hideCommandLog',
603598
defaultValue: false,

packages/data-context/src/data/coreDataShape.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { FoundBrowser, Editor, AllowedState, AllModeOptions, TestingType, BrowserStatus, PACKAGE_MANAGERS, AuthStateName, MIGRATION_STEPS, MigrationStep, StudioManagerShape } from '@packages/types'
1+
import { FoundBrowser, Editor, AllowedState, AllModeOptions, TestingType, BrowserStatus, PACKAGE_MANAGERS, AuthStateName, MIGRATION_STEPS, MigrationStep, StudioLifecycleManagerShape } from '@packages/types'
22
import { WizardBundler, CT_FRAMEWORKS, resolveComponentFrameworkDefinition, ErroredFramework } from '@packages/scaffold-config'
33
import type { NexusGenObjects } from '@packages/graphql/src/gen/nxs.gen'
44
// tslint:disable-next-line no-implicit-dependencies - electron dep needs to be defined
@@ -164,7 +164,7 @@ export interface CoreDataShape {
164164
cloudProject: CloudDataShape
165165
eventCollectorSource: EventCollectorSource | null
166166
didBrowserPreviouslyHaveUnexpectedExit: boolean
167-
studio: StudioManagerShape | null
167+
studioLifecycleManager?: StudioLifecycleManagerShape
168168
}
169169

170170
/**
@@ -246,7 +246,7 @@ export function makeCoreData (modeOptions: Partial<AllModeOptions> = {}): CoreDa
246246
},
247247
eventCollectorSource: null,
248248
didBrowserPreviouslyHaveUnexpectedExit: false,
249-
studio: null,
249+
studioLifecycleManager: undefined,
250250
}
251251

252252
async function machineId (): Promise<string | null> {

packages/data-context/src/sources/HtmlDataSource.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ export class HtmlDataSource {
5757
'namespace',
5858
'socketIoRoute',
5959
'isDefaultProtocolEnabled',
60-
'isStudioProtocolEnabled',
6160
'hideCommandLog',
6261
'hideRunnerUi',
6362
]

packages/driver/src/util/config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ const omitConfigReadOnlyDifferences = (objectLikeConfig: Cypress.ObjectLike) =>
2525
return
2626
}
2727

28-
if ((overrideLevels === 'never' && configKey !== 'isDefaultProtocolEnabled') ||
29-
(overrideLevels === 'never' && configKey !== 'isStudioProtocolEnabled')) {
28+
if ((overrideLevels === 'never' && configKey !== 'isDefaultProtocolEnabled')) {
3029
delete objectLikeConfig[configKey]
3130
}
3231
})

packages/driver/types/internal-types.d.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,6 @@ declare namespace Cypress {
8080

8181
interface TestConfigOverrides extends Cypress.TestConfigOverrides {
8282
isDefaultProtocolEnabled?: boolean
83-
isStudioProtocolEnabled?: boolean
8483
}
8584

8685
interface ResolvedConfigOptions {

packages/graphql/schemas/schema.graphql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,6 +2380,7 @@ type Studio {
23802380
}
23812381

23822382
enum StudioStatusType {
2383+
ENABLED
23832384
INITIALIZED
23842385
IN_ERROR
23852386
NOT_INITIALIZED

packages/graphql/src/schemaTypes/objectTypes/gql-Query.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { ErrorWrapper } from './gql-ErrorWrapper'
1212
import { CachedUser } from './gql-CachedUser'
1313
import { Cohort } from './gql-Cohorts'
1414
import { Studio } from './gql-Studio'
15+
import type { StudioStatusType } from '@packages/data-context/src/gen/graphcache-config.gen'
1516

1617
export const Query = objectType({
1718
name: 'Query',
@@ -105,7 +106,17 @@ export const Query = objectType({
105106
t.field('studio', {
106107
type: Studio,
107108
description: 'Data pertaining to studio and the studio manager that is loaded from the cloud',
108-
resolve: (source, args, ctx) => ctx.coreData.studio,
109+
resolve: async (source, args, ctx) => {
110+
const isStudioReady = ctx.coreData.studioLifecycleManager?.isStudioReady()
111+
112+
if (!isStudioReady) {
113+
return { status: 'INITIALIZED' as StudioStatusType }
114+
}
115+
116+
const studio = await ctx.coreData.studioLifecycleManager?.getStudio()
117+
118+
return studio ? { status: studio.status } : null
119+
},
109120
})
110121

111122
t.nonNull.field('localSettings', {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import type { StudioManager } from './cloud/studio'
2+
import { ProtocolManager } from './cloud/protocol'
3+
import { getAndInitializeStudioManager } from './cloud/api/studio/get_and_initialize_studio_manager'
4+
import Debug from 'debug'
5+
import type { CloudDataSource } from '@packages/data-context/src/sources'
6+
import type { Cfg } from './project-base'
7+
import _ from 'lodash'
8+
import type { DataContext } from '@packages/data-context'
9+
import api from './cloud/api'
10+
import { reportStudioError } from './cloud/api/studio/report_studio_error'
11+
import { CloudRequest } from './cloud/api/cloud_request'
12+
import { isRetryableError } from './cloud/network/is_retryable_error'
13+
import { asyncRetry } from './util/async_retry'
14+
const debug = Debug('cypress:server:studio-lifecycle-manager')
15+
const routes = require('./cloud/routes')
16+
17+
export class StudioLifecycleManager {
18+
private studioManagerPromise?: Promise<StudioManager | null>
19+
private studioManager?: StudioManager
20+
private listeners: ((studioManager: StudioManager) => void)[] = []
21+
/**
22+
* Initialize the studio manager and possibly set up protocol.
23+
* Also registers this instance in the data context.
24+
* @param projectId The project ID
25+
* @param cloudDataSource The cloud data source
26+
* @param cfg The project configuration
27+
* @param debugData Debug data for the configuration
28+
* @param ctx Data context to register this instance with
29+
*/
30+
initializeStudioManager ({
31+
projectId,
32+
cloudDataSource,
33+
cfg,
34+
debugData,
35+
ctx,
36+
}: {
37+
projectId?: string
38+
cloudDataSource: CloudDataSource
39+
cfg: Cfg
40+
debugData: any
41+
ctx: DataContext
42+
}): void {
43+
debug('Initializing studio manager')
44+
45+
const studioManagerPromise = getAndInitializeStudioManager({
46+
projectId,
47+
cloudDataSource,
48+
}).then(async (studioManager) => {
49+
if (studioManager.status === 'ENABLED') {
50+
debug('Cloud studio is enabled - setting up protocol')
51+
const protocolManager = new ProtocolManager()
52+
const protocolUrl = routes.apiRoutes.captureProtocolCurrent()
53+
const script = await api.getCaptureProtocolScript(protocolUrl)
54+
55+
await protocolManager.prepareProtocol(script, {
56+
runId: 'studio',
57+
projectId: cfg.projectId,
58+
testingType: cfg.testingType,
59+
cloudApi: {
60+
url: routes.apiUrl,
61+
retryWithBackoff: api.retryWithBackoff,
62+
requestPromise: api.rp,
63+
},
64+
projectConfig: _.pick(cfg, ['devServerPublicPathRoute', 'port', 'proxyUrl', 'namespace']),
65+
mountVersion: api.runnerCapabilities.protocolMountVersion,
66+
debugData,
67+
mode: 'studio',
68+
})
69+
70+
studioManager.protocolManager = protocolManager
71+
} else {
72+
debug('Cloud studio is not enabled - skipping protocol setup')
73+
}
74+
75+
debug('Studio is ready')
76+
this.studioManager = studioManager
77+
this.callRegisteredListeners()
78+
79+
return studioManager
80+
}).catch(async (error) => {
81+
debug('Error during studio manager setup: %o', error)
82+
83+
const cloudEnv = (process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
84+
const cloudUrl = ctx.cloud.getCloudUrl(cloudEnv)
85+
const cloudHeaders = await ctx.cloud.additionalHeaders()
86+
87+
reportStudioError({
88+
cloudApi: {
89+
cloudUrl,
90+
cloudHeaders,
91+
CloudRequest,
92+
isRetryableError,
93+
asyncRetry,
94+
},
95+
studioHash: projectId,
96+
projectSlug: cfg.projectId,
97+
error,
98+
studioMethod: 'initializeStudioManager',
99+
studioMethodArgs: [],
100+
})
101+
102+
// Clean up any registered listeners
103+
this.listeners = []
104+
105+
return null
106+
})
107+
108+
this.studioManagerPromise = studioManagerPromise
109+
110+
// Register this instance in the data context
111+
ctx.update((data) => {
112+
data.studioLifecycleManager = this
113+
})
114+
}
115+
116+
isStudioReady (): boolean {
117+
return !!this.studioManager
118+
}
119+
120+
async getStudio () {
121+
if (!this.studioManagerPromise) {
122+
throw new Error('Studio manager has not been initialized')
123+
}
124+
125+
return await this.studioManagerPromise
126+
}
127+
128+
private callRegisteredListeners () {
129+
if (!this.studioManager) {
130+
throw new Error('Studio manager has not been initialized')
131+
}
132+
133+
const studioManager = this.studioManager
134+
135+
debug('Calling all studio ready listeners')
136+
this.listeners.forEach((listener) => {
137+
listener(studioManager)
138+
})
139+
140+
this.listeners = []
141+
}
142+
143+
/**
144+
* Register a listener that will be called when the studio is ready
145+
* @param listener Function to call when studio is ready
146+
*/
147+
registerStudioReadyListener (listener: (studioManager: StudioManager) => void): void {
148+
// if there is already a studio manager, call the listener immediately
149+
if (this.studioManager) {
150+
debug('Studio ready - calling listener immediately')
151+
listener(this.studioManager)
152+
} else {
153+
debug('Studio not ready - registering studio ready listener')
154+
this.listeners.push(listener)
155+
}
156+
}
157+
}

packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export const getAndInitializeStudioManager = async ({ projectId, cloudDataSource
146146
isRetryableError,
147147
asyncRetry,
148148
},
149+
shouldEnableStudio: !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH),
149150
})
150151

151152
return studioManager

0 commit comments

Comments
 (0)