Skip to content

internal: rework studio handshake to allow better caching #31599

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 62 commits into from
Apr 30, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
5c96c83
feat: (studio) Capture telemetry for studio initialization
astone123 Apr 8, 2025
585d6cb
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 10, 2025
27268ae
fixes
astone123 Apr 10, 2025
bd0ffdb
revert type changes
astone123 Apr 10, 2025
e22d26d
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 17, 2025
1e84877
add studio lifecycle manager
astone123 Apr 17, 2025
9cb761c
fix studio router
ryanthemanuel Apr 17, 2025
5189107
finish lifecycle manager, add test coverage
astone123 Apr 18, 2025
eb0e1cc
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 18, 2025
62d7ef2
fix types
astone123 Apr 18, 2025
35bf761
fix types for real
astone123 Apr 18, 2025
61d3a5f
fix types actually
astone123 Apr 18, 2025
9f36293
fix lint
astone123 Apr 18, 2025
ab0dfa6
fix routes spec
astone123 Apr 18, 2025
3bda77e
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 18, 2025
3e5796b
fix lint, add try/catch
astone123 Apr 21, 2025
b263416
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 21, 2025
90e3fda
remove work to capture telemetry
astone123 Apr 21, 2025
75f68a4
didn't mean to remove this part
astone123 Apr 21, 2025
e24e862
fix types
astone123 Apr 21, 2025
a5593fe
remove test for other branch
astone123 Apr 21, 2025
b6e122c
feedback
astone123 Apr 22, 2025
a05b0d0
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 22, 2025
8e9c883
fix test
astone123 Apr 22, 2025
4b2586f
encapsulate more logic into studio lifecycle manager
astone123 Apr 23, 2025
c6e15d7
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 23, 2025
cb32e09
feedback
astone123 Apr 23, 2025
8db857a
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 23, 2025
b24dbb9
merge develop
ryanthemanuel Apr 23, 2025
5a581b8
fix tests
ryanthemanuel Apr 24, 2025
c8d92a2
blank
ryanthemanuel Apr 24, 2025
3eb84ff
feedback
astone123 Apr 24, 2025
30c07b6
Update packages/app/src/runner/event-manager.ts
astone123 Apr 24, 2025
04eaa28
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 24, 2025
030e929
fix lint
astone123 Apr 24, 2025
c9d7afb
fix hiding runner ui
ryanthemanuel Apr 24, 2025
ae7bb80
update test
astone123 Apr 24, 2025
cd21cbd
re-work lifecycle manager implementation to not use EventEmitter
astone123 Apr 24, 2025
c3073f3
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 24, 2025
d79518c
fix listener registration logic
astone123 Apr 25, 2025
5d54e11
ensure that studio still loads even when cloud bundle doesn't
astone123 Apr 25, 2025
eaf6b4e
feedback
astone123 Apr 25, 2025
3b678de
feedback
astone123 Apr 25, 2025
327e4e3
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 25, 2025
33fd989
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 28, 2025
fc3cf5a
use getter for isProtocolEnabled, report studio manager initializatio…
astone123 Apr 28, 2025
c8c3e42
clean up listeners when initialization errors
astone123 Apr 28, 2025
efd8e59
Update packages/server/lib/StudioLifecycleManager.ts
astone123 Apr 29, 2025
e3f8aaa
Update packages/server/lib/StudioLifecycleManager.ts
astone123 Apr 29, 2025
303bd8d
Update packages/server/lib/StudioLifecycleManager.ts
astone123 Apr 29, 2025
2bc7221
feedback
astone123 Apr 29, 2025
6b66ce1
Merge branch 'develop' into 10501-studio-telemetry
astone123 Apr 29, 2025
34e24aa
fix: rework studio handshake to allow better caching
ryanthemanuel Apr 29, 2025
0365f88
Merge branch '10501-studio-telemetry' into ryanm/fix/make-caching-pos…
ryanthemanuel Apr 29, 2025
0d759ea
merge conflict
ryanthemanuel Apr 29, 2025
effb16f
Merge branch 'develop' into ryanm/fix/make-caching-possible
ryanthemanuel Apr 29, 2025
7b39c1d
slight refactor
ryanthemanuel Apr 29, 2025
ca6aa3d
update types
ryanthemanuel Apr 29, 2025
b2aef6f
Update packages/server/lib/cloud/api/studio/post_studio_session.ts
ryanthemanuel Apr 29, 2025
49fc556
Apply suggestions from code review
ryanthemanuel Apr 29, 2025
b14a214
fix test
ryanthemanuel Apr 29, 2025
4e326ff
PR comment
ryanthemanuel Apr 30, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions packages/app/src/studio/studio-app-types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export interface StudioPanelProps {
canAccessStudioAI: boolean
onStudioPanelClose: () => void
useStudioEventManager?: StudioEventManagerShape
onStudioPanelClose?: () => void
useRunnerStatus?: RunnerStatusShape
useTestContentRetriever?: TestContentRetrieverShape
useStudioAIStream?: StudioAIStreamShape
useCypress?: CypressShape
}

export type StudioPanelShape = (props: StudioPanelProps) => JSX.Element
Expand All @@ -18,21 +20,49 @@ CyEventEmitter & {
state: (key: string) => any
}

export interface StudioEventManagerProps {
Cypress: CypressInternal
export interface TestBlock {
content: string
testBodyPosition: {
contentStart: number
contentEnd: number
indentation: number
}
}

export type RunnerStatus = 'running' | 'finished'

export type StudioEventManagerShape = (props: StudioEventManagerProps) => {
export interface RunnerStatusProps {
Cypress: CypressInternal
}

export interface CypressProps {
Cypress: CypressInternal
}

export type CypressShape = (props: CypressProps) => {
currentCypress: CypressInternal
}

export type RunnerStatusShape = (props: RunnerStatusProps) => {
runnerStatus: RunnerStatus
testBlock: string | null
}

export interface StudioAIStreamProps {
canAccessStudioAI: boolean
AIOutputRef: { current: HTMLTextAreaElement | null }
runnerStatus: RunnerStatus
testCode?: string
isCreatingNewTest: boolean
}

export type StudioAIStreamShape = (props: StudioAIStreamProps) => void

export interface TestContentRetrieverProps {
Cypress: CypressInternal
}

export type TestContentRetrieverShape = (props: TestContentRetrieverProps) => {
isLoading: boolean
testBlock: TestBlock | null
isCreatingNewTest: boolean
}
91 changes: 58 additions & 33 deletions packages/server/lib/StudioLifecycleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { reportStudioError } from './cloud/api/studio/report_studio_error'
import { CloudRequest } from './cloud/api/cloud_request'
import { isRetryableError } from './cloud/network/is_retryable_error'
import { asyncRetry } from './util/async_retry'
import { postStudioSession } from './cloud/api/studio/post_studio_session'

const debug = Debug('cypress:server:studio-lifecycle-manager')
const routes = require('./cloud/routes')

Expand Down Expand Up @@ -42,41 +44,11 @@ export class StudioLifecycleManager {
}): void {
debug('Initializing studio manager')

const studioManagerPromise = getAndInitializeStudioManager({
const studioManagerPromise = this.createStudioManager({
projectId,
cloudDataSource,
}).then(async (studioManager) => {
if (studioManager.status === 'ENABLED') {
debug('Cloud studio is enabled - setting up protocol')
const protocolManager = new ProtocolManager()
const protocolUrl = routes.apiRoutes.captureProtocolCurrent()
const script = await api.getCaptureProtocolScript(protocolUrl)

await protocolManager.prepareProtocol(script, {
runId: 'studio',
projectId: cfg.projectId,
testingType: cfg.testingType,
cloudApi: {
url: routes.apiUrl,
retryWithBackoff: api.retryWithBackoff,
requestPromise: api.rp,
},
projectConfig: _.pick(cfg, ['devServerPublicPathRoute', 'port', 'proxyUrl', 'namespace']),
mountVersion: api.runnerCapabilities.protocolMountVersion,
debugData,
mode: 'studio',
})

studioManager.protocolManager = protocolManager
} else {
debug('Cloud studio is not enabled - skipping protocol setup')
}

debug('Studio is ready')
this.studioManager = studioManager
this.callRegisteredListeners()

return studioManager
cfg,
debugData,
}).catch(async (error) => {
debug('Error during studio manager setup: %o', error)

Expand Down Expand Up @@ -125,6 +97,59 @@ export class StudioLifecycleManager {
return await this.studioManagerPromise
}

private async createStudioManager ({
projectId,
cloudDataSource,
cfg,
debugData,
}: {
projectId?: string
cloudDataSource: CloudDataSource
cfg: Cfg
debugData: any
}): Promise<StudioManager> {
const studioSession = await postStudioSession({
projectId,
})

const studioManager = await getAndInitializeStudioManager({
studioUrl: studioSession.studioUrl,
projectId,
cloudDataSource,
})

if (studioManager.status === 'ENABLED') {
debug('Cloud studio is enabled - setting up protocol')
const protocolManager = new ProtocolManager()
const script = await api.getCaptureProtocolScript(studioSession.protocolUrl)

await protocolManager.prepareProtocol(script, {
runId: 'studio',
projectId: cfg.projectId,
testingType: cfg.testingType,
cloudApi: {
url: routes.apiUrl,
retryWithBackoff: api.retryWithBackoff,
requestPromise: api.rp,
},
projectConfig: _.pick(cfg, ['devServerPublicPathRoute', 'port', 'proxyUrl', 'namespace']),
mountVersion: api.runnerCapabilities.protocolMountVersion,
debugData,
mode: 'studio',
})

studioManager.protocolManager = protocolManager
} else {
debug('Cloud studio is not enabled - skipping protocol setup')
}

debug('Studio is ready')
this.studioManager = studioManager
this.callRegisteredListeners()

return studioManager
}

private callRegisteredListeners () {
if (!this.studioManager) {
throw new Error('Studio manager has not been initialized')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ import { PUBLIC_KEY_VERSION } from '../../constants'
import { CloudRequest } from '../cloud_request'
import type { CloudDataSource } from '@packages/data-context/src/sources'

interface Options {
studioUrl: string
projectId?: string
}

const pkg = require('@packages/root')
const routes = require('../../routes')

const _delay = linearDelay(500)

Expand All @@ -24,11 +28,11 @@ export const studioPath = path.join(os.tmpdir(), 'cypress', 'studio')
const bundlePath = path.join(studioPath, 'bundle.tar')
const serverFilePath = path.join(studioPath, 'server', 'index.js')

const downloadStudioBundleToTempDirectory = async (projectId?: string): Promise<void> => {
const downloadStudioBundleToTempDirectory = async ({ studioUrl, projectId }: Options): Promise<void> => {
let responseSignature: string | null = null

await (asyncRetry(async () => {
const response = await fetch(routes.apiRoutes.studio() as string, {
const response = await fetch(studioUrl, {
// @ts-expect-error - this is supported
agent,
method: 'GET',
Expand Down Expand Up @@ -90,7 +94,7 @@ const getTarHash = (): Promise<string> => {
})
}

export const retrieveAndExtractStudioBundle = async ({ projectId }: { projectId?: string } = {}): Promise<{ studioHash: string | undefined }> => {
export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId }: Options): Promise<{ studioHash: string | undefined }> => {
// First remove studioPath to ensure we have a clean slate
await fs.promises.rm(studioPath, { recursive: true, force: true })
await ensureDir(studioPath)
Expand All @@ -106,7 +110,7 @@ export const retrieveAndExtractStudioBundle = async ({ projectId }: { projectId?
return { studioHash: undefined }
}

await downloadStudioBundleToTempDirectory(projectId)
await downloadStudioBundleToTempDirectory({ studioUrl, projectId })

const studioHash = await getTarHash()

Expand All @@ -118,7 +122,7 @@ export const retrieveAndExtractStudioBundle = async ({ projectId }: { projectId?
return { studioHash }
}

export const getAndInitializeStudioManager = async ({ projectId, cloudDataSource }: { projectId?: string, cloudDataSource: CloudDataSource }): Promise<StudioManager> => {
export const getAndInitializeStudioManager = async ({ studioUrl, projectId, cloudDataSource }: { studioUrl: string, projectId?: string, cloudDataSource: CloudDataSource }): Promise<StudioManager> => {
let script: string

const cloudEnv = (process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
Expand All @@ -128,7 +132,7 @@ export const getAndInitializeStudioManager = async ({ projectId, cloudDataSource
let studioHash: string | undefined

try {
({ studioHash } = await retrieveAndExtractStudioBundle({ projectId }))
({ studioHash } = await retrieveAndExtractStudioBundle({ studioUrl, projectId }))

script = await readFile(serverFilePath, 'utf8')

Expand Down
42 changes: 42 additions & 0 deletions packages/server/lib/cloud/api/studio/post_studio_session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { asyncRetry, linearDelay } from '../../../util/async_retry'
import { isRetryableError } from '../../network/is_retryable_error'
import fetch from 'cross-fetch'
import os from 'os'

const pkg = require('@packages/root')
const routes = require('../../routes') as typeof import('../../routes')

interface GetStudioSessionOptions {
projectId?: string
}

const _delay = linearDelay(500)

export const postStudioSession = async ({ projectId }: GetStudioSessionOptions) => {
return await (asyncRetry(async () => {
const response = await fetch(routes.apiRoutes.studioSession(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-os-name': os.platform(),
'x-cypress-version': pkg.version,
},
body: JSON.stringify({ projectSlug: projectId, studioMountVersion: 1, protocolMountVersion: 2 }),
})

if (!response.ok) {
throw new Error('Failed to create studio session')
}

const data = await response.json()

return {
studioUrl: data.studioUrl,
protocolUrl: data.protocolUrl,
}
}, {
maxAttempts: 3,
retryDelay: _delay,
shouldRetry: isRetryableError,
}))()
}
3 changes: 1 addition & 2 deletions packages/server/lib/cloud/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ const CLOUD_ENDPOINTS = {
instanceStdout: 'instances/:id/stdout',
instanceArtifacts: 'instances/:id/artifacts',
captureProtocolErrors: 'capture-protocol/errors',
captureProtocolCurrent: 'capture-protocol/script/current.js',
studio: 'studio/bundle/current.tgz',
studioSession: 'studio/session',
studioErrors: 'studio/errors',
exceptions: 'exceptions',
telemetry: 'telemetry',
Expand Down
5 changes: 3 additions & 2 deletions packages/server/lib/project-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,11 +476,12 @@ export class ProjectBase extends EE {

const studio = await this.ctx.coreData.studioLifecycleManager?.getStudio()

if (studio?.protocolManager) {
await studio?.destroy()

if (this.protocolManager) {
await browsers.closeProtocolConnection({ browser: this.browser, foundBrowsers: this.options.browsers })
this.protocolManager?.close()
this.protocolManager = undefined
await studio.destroy()
}
},

Expand Down
14 changes: 13 additions & 1 deletion packages/server/test/unit/StudioLifecycleManager_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as getAndInitializeStudioManagerModule from '../../lib/cloud/api/studio
import * as reportStudioErrorPath from '../../lib/cloud/api/studio/report_studio_error'
import ProtocolManager from '../../lib/cloud/protocol'
const api = require('../../lib/cloud/api').default
import * as postStudioSessionModule from '../../lib/cloud/api/studio/post_studio_session'

// Helper to wait for next tick in event loop
const nextTick = () => new Promise((resolve) => process.nextTick(resolve))
Expand All @@ -19,6 +20,7 @@ describe('StudioLifecycleManager', () => {
let mockCtx: DataContext
let mockCloudDataSource: CloudDataSource
let mockCfg: Cfg
let postStudioSessionStub: sinon.SinonStub
let getAndInitializeStudioManagerStub: sinon.SinonStub
let getCaptureProtocolScriptStub: sinon.SinonStub
let prepareProtocolStub: sinon.SinonStub
Expand Down Expand Up @@ -53,6 +55,12 @@ describe('StudioLifecycleManager', () => {
namespace: '__cypress',
} as unknown as Cfg

postStudioSessionStub = sinon.stub(postStudioSessionModule, 'postStudioSession')
postStudioSessionStub.resolves({
studioUrl: 'https://cloud.cypress.io/studio/bundle/abc.tgz',
protocolUrl: 'https://cloud.cypress.io/capture-protocol/script/def.js',
})

getAndInitializeStudioManagerStub = sinon.stub(getAndInitializeStudioManagerModule, 'getAndInitializeStudioManager')
getAndInitializeStudioManagerStub.resolves(mockStudioManager)

Expand Down Expand Up @@ -107,7 +115,11 @@ describe('StudioLifecycleManager', () => {

await studioReadyPromise

expect(getCaptureProtocolScriptStub).to.be.calledWith('http://localhost:1234/capture-protocol/script/current.js')
expect(postStudioSessionStub).to.be.calledWith({
projectId: 'abc123',
})

expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js')
expect(prepareProtocolStub).to.be.calledWith('console.log("hello")', {
runId: 'studio',
projectId: 'abc123',
Expand Down
Loading
Loading