Skip to content

internal: updating studio-server initialization with renderer APIs #32204

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 11 commits into from
Aug 15, 2025
Merged
35 changes: 22 additions & 13 deletions packages/app/src/studio/studio-app-types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
// Note: This file is owned by the cloud delivered
// `studio` bundle. It is downloaded and copied to the app.
// It should not be modified directly in the app.

export type RecordingState = 'recording' | 'paused' | 'disabled'

export interface StudioPanelProps {
canAccessStudioAI: boolean
onStudioPanelClose?: () => void
studioSessionId?: string
useRunnerStatus?: RunnerStatusShape
useTestContentRetriever?: TestContentRetrieverShape
useStudioAIStream?: StudioAIStreamShape
useCypress?: CypressShape
autUrlSelector?: string
studioAiAvailable?: boolean
}

export type StudioPanelShape = (props: StudioPanelProps) => JSX.Element
Expand All @@ -19,6 +26,11 @@ export interface StudioAppDefaultShape {
export type CypressInternal = Cypress.Cypress &
CyEventEmitter & {
state: (key: string) => any
$autIframe: JQuery<HTMLIFrameElement>
mocha: {
getRootSuite: () => Suite
}
areSourceMapsAvailable?: boolean
}

export interface TestBlock {
Expand Down Expand Up @@ -53,6 +65,7 @@ export interface StudioAIStreamProps {
runnerStatus: RunnerStatus
testCode?: string
isCreatingNewTest: boolean
Cypress: CypressInternal
}

export interface StudioAIStream {
Expand All @@ -73,16 +86,12 @@ export type TestContentRetrieverShape = (props: TestContentRetrieverProps) => {
isCreatingNewTest: boolean
}

export interface Command {
selector?: string
name: string
message?: string | string[]
isAssertion?: boolean
}

export interface SaveDetails {
absoluteFile: string
runnableTitle: string
contents: string
testName?: string
export type Suite = {
id: string
title: string
suites?: Suite[]
invocationDetails: {
line: number
column: number
}
}
54 changes: 54 additions & 0 deletions packages/server/lib/cloud/studio/StudioElectron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// tslint:disable-next-line no-implicit-dependencies - electron dep needs to be defined
import { BrowserWindow } from 'electron'
import Debug from 'debug'

const debug = Debug('cypress:server:studio:electron')

/**
* This interface exposes a selection of Electrons APIs
* to the dynamic studio bundle.
*/
export class StudioElectron {
private browserWindow: BrowserWindow | undefined

createBrowserWindow () {
debug('creating new browser window')

this.destroy()

this.browserWindow = new BrowserWindow({
// Hide the title bar for accurate viewport sizes
titleBarStyle: 'hidden',
// Hide window by default - we should never show it
// in production environments
show: false,
})

debug('created browser window')

return this.browserWindow
}

destroy () {
this.safeCloseBrowserWindow()
}

private safeCloseBrowserWindow () {
if (!this.browserWindow) {
debug('no browser window to destroy')

return
}

if (!this.browserWindow.isDestroyed()) {
try {
this.browserWindow.destroy()
} catch (error) {
debug('error destroying browser window', error)
}
}

debug('browser window destroyed')
this.browserWindow = undefined
}
}
12 changes: 11 additions & 1 deletion packages/server/lib/cloud/studio/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { requireScript } from '../require_script'
import path from 'path'
import { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error'
import crypto, { BinaryLike } from 'crypto'
import { StudioElectron } from './StudioElectron'

interface StudioServer { default: StudioServerDefaultShape }

Expand All @@ -24,6 +25,7 @@ export class StudioManager implements StudioManagerShape {
status: StudioStatus = 'NOT_INITIALIZED'
protocolManager: ProtocolManagerShape | undefined
private _studioServer: StudioServerShape | undefined
private _studioElectron: StudioElectron | undefined

static createInErrorManager ({ cloudApi, studioHash, projectSlug, error, studioMethod, studioMethodArgs }: ReportStudioErrorOptions): StudioManager {
const manager = new StudioManager()
Expand Down Expand Up @@ -96,7 +98,15 @@ export class StudioManager implements StudioManagerShape {
}

async initializeStudioAI (options: StudioAIInitializeOptions): Promise<void> {
await this.invokeAsync('initializeStudioAI', { isEssential: true }, options)
// Only create a studio electron instance when studio AI is enabled
if (!this._studioElectron) {
this._studioElectron = new StudioElectron()
}

await this.invokeAsync('initializeStudioAI', { isEssential: true }, {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. I like how this is tied to AI initialization now.

...options,
studioElectron: this._studioElectron,
})
}

updateSessionId (sessionId: string): void {
Expand Down
13 changes: 9 additions & 4 deletions packages/server/lib/project-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ export class ProjectBase extends EE {
try {
studio?.captureStudioEvent({
type: StudioMetricsTypes.STUDIO_STARTED,
machineId: await this.ctx.coreData.machineId,
machineId: await this.ctx.coreData.machineId ?? '',
projectId: this.cfg.projectId,
browser: this.browser ? {
name: this.browser.name,
Expand Down Expand Up @@ -502,9 +502,14 @@ export class ProjectBase extends EE {
}

telemetryManager.mark(INITIALIZATION_MARK_NAMES.INITIALIZE_STUDIO_AI_START)
await studio.initializeStudioAI({
protocolDbPath: studio.protocolManager.dbPath,
})
await Promise.all([
studio.initializeStudioAI({
protocolDbPath: studio.protocolManager.dbPath,
}),
// Reset browser state on initialization to avoid issues
// with cached assets from previous test executions.
this.resetBrowserState(),
])

telemetryManager.mark(INITIALIZATION_MARK_NAMES.INITIALIZE_STUDIO_AI_END)

Expand Down
124 changes: 124 additions & 0 deletions packages/server/test/unit/cloud/studio/StudioElectron_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { sinon, proxyquire } from '../../../spec_helper'
import { expect } from 'chai'

describe('StudioElectron', () => {
class FakeBrowserWindow {
public options: any
private destroyed = false

constructor (options: any) {
this.options = options
}

isDestroyed () {
return this.destroyed
}

destroy () {
this.destroyed = true
}
}

let StudioElectron: typeof import('../../../../lib/cloud/studio/StudioElectron').StudioElectron

beforeEach(() => {
const mod = proxyquire('../lib/cloud/studio/StudioElectron', {
electron: {
BrowserWindow: FakeBrowserWindow,
},
}) as typeof import('../../../../lib/cloud/studio/StudioElectron')

StudioElectron = mod.StudioElectron
})

afterEach(() => {
sinon.restore()
})

it('creates a hidden BrowserWindow with hidden title bar and returns it', () => {
const studioElectron = new StudioElectron()

const win = studioElectron.createBrowserWindow()

expect((win as any)).to.be.instanceOf(FakeBrowserWindow)

const options = (win as any).options

expect(options).to.include({
show: false,
titleBarStyle: 'hidden',
})

// destroy should clean up
studioElectron.destroy()
expect((studioElectron as any).browserWindow).to.be.undefined
})

it('destroys any existing window before creating a new one', () => {
const studioElectron = new StudioElectron()

// Seed an existing window
const existing = new FakeBrowserWindow({})
const destroyStub = sinon.stub(existing, 'destroy').callThrough()

;(studioElectron as any).browserWindow = existing

const win = studioElectron.createBrowserWindow()

expect(destroyStub).to.be.calledOnce
expect((win as any)).to.be.instanceOf(FakeBrowserWindow)
expect(win).to.not.equal(existing)
})

it('destroy is a no-op when no window exists', () => {
const studioElectron = new StudioElectron()

// No window set
studioElectron.destroy()

expect((studioElectron as any).browserWindow).to.be.undefined
})

it('destroy calls BrowserWindow.destroy when not already destroyed and clears reference', () => {
const studioElectron = new StudioElectron()
const existing = new FakeBrowserWindow({})
const destroySpy = sinon.spy(existing, 'destroy')

sinon.stub(existing, 'isDestroyed').returns(false)

;(studioElectron as any).browserWindow = existing

studioElectron.destroy()

expect(destroySpy).to.be.calledOnce
expect((studioElectron as any).browserWindow).to.be.undefined
})

it('does not call destroy when BrowserWindow is already destroyed, but still clears reference', () => {
const studioElectron = new StudioElectron()
const existing = new FakeBrowserWindow({})
const destroySpy = sinon.spy(existing, 'destroy')

sinon.stub(existing, 'isDestroyed').returns(true)

;(studioElectron as any).browserWindow = existing

studioElectron.destroy()

expect(destroySpy).to.not.be.called
expect((studioElectron as any).browserWindow).to.be.undefined
})

it('catches errors thrown during BrowserWindow.destroy and still clears reference', () => {
const studioElectron = new StudioElectron()
const existing = new FakeBrowserWindow({})

sinon.stub(existing, 'isDestroyed').returns(false)
sinon.stub(existing, 'destroy').throws(new Error('fail to destroy'))

;(studioElectron as any).browserWindow = existing

expect(() => studioElectron.destroy()).to.not.throw()
expect((studioElectron as any).browserWindow).to.be.undefined
})
})
22 changes: 19 additions & 3 deletions packages/server/test/unit/cloud/studio/studio_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,22 @@ describe('lib/cloud/studio', () => {

beforeEach(async () => {
reportStudioError = sinon.stub()
// Fake StudioElectron so we can assert calls
class FakeStudioElectron {
// @ts-ignore - assigned in ctor
destroy: sinon.SinonStub
constructor () {
this.destroy = sinon.stub()
}
}

StudioManager = (proxyquire('../lib/cloud/studio/studio', {
'../api/studio/report_studio_error': {
reportStudioError,
},
'./StudioElectron': {
StudioElectron: FakeStudioElectron,
},
}) as typeof import('@packages/server/lib/cloud/studio/studio')).StudioManager

studioManager = new StudioManager()
Expand Down Expand Up @@ -211,9 +223,13 @@ describe('lib/cloud/studio', () => {
protocolDbPath: 'test-db-path',
})

expect(studio.initializeStudioAI).to.be.calledWith({
protocolDbPath: 'test-db-path',
})
expect((studioManager as any)._studioElectron).to.exist

expect(studio.initializeStudioAI).to.be.calledWith(
sinon.match.has('protocolDbPath', 'test-db-path').and(
sinon.match.has('studioElectron'),
),
)
})
})

Expand Down
Loading
Loading