diff --git a/packages/app/src/studio/studio-app-types.ts b/packages/app/src/studio/studio-app-types.ts index eeb859abe294..68fec5e8fb48 100644 --- a/packages/app/src/studio/studio-app-types.ts +++ b/packages/app/src/studio/studio-app-types.ts @@ -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 @@ -19,6 +26,11 @@ export interface StudioAppDefaultShape { export type CypressInternal = Cypress.Cypress & CyEventEmitter & { state: (key: string) => any + $autIframe: JQuery + mocha: { + getRootSuite: () => Suite + } + areSourceMapsAvailable?: boolean } export interface TestBlock { @@ -53,6 +65,7 @@ export interface StudioAIStreamProps { runnerStatus: RunnerStatus testCode?: string isCreatingNewTest: boolean + Cypress: CypressInternal } export interface StudioAIStream { @@ -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 + } } diff --git a/packages/server/lib/cloud/studio/StudioElectron.ts b/packages/server/lib/cloud/studio/StudioElectron.ts new file mode 100644 index 000000000000..f9d2390db0a0 --- /dev/null +++ b/packages/server/lib/cloud/studio/StudioElectron.ts @@ -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 + } +} diff --git a/packages/server/lib/cloud/studio/studio.ts b/packages/server/lib/cloud/studio/studio.ts index d14833ebf822..067a58a8a500 100644 --- a/packages/server/lib/cloud/studio/studio.ts +++ b/packages/server/lib/cloud/studio/studio.ts @@ -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 } @@ -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() @@ -96,7 +98,15 @@ export class StudioManager implements StudioManagerShape { } async initializeStudioAI (options: StudioAIInitializeOptions): Promise { - 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 }, { + ...options, + studioElectron: this._studioElectron, + }) } updateSessionId (sessionId: string): void { diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 1f38276b1ba2..c85c0c5e663b 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -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, @@ -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) diff --git a/packages/server/test/unit/cloud/studio/StudioElectron_spec.ts b/packages/server/test/unit/cloud/studio/StudioElectron_spec.ts new file mode 100644 index 000000000000..47708d01f4a3 --- /dev/null +++ b/packages/server/test/unit/cloud/studio/StudioElectron_spec.ts @@ -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 + }) +}) diff --git a/packages/server/test/unit/cloud/studio/studio_spec.ts b/packages/server/test/unit/cloud/studio/studio_spec.ts index 70f27c240e12..bdb1fadb1222 100644 --- a/packages/server/test/unit/cloud/studio/studio_spec.ts +++ b/packages/server/test/unit/cloud/studio/studio_spec.ts @@ -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() @@ -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'), + ), + ) }) }) diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index 8fece97a8c1a..49971f864c9d 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -828,6 +828,8 @@ This option will not have an effect in Some-other-name. Tests that rely on web s this.project['_protocolManager'] = protocolManager }) + sinon.stub(this.project, 'resetBrowserState').resolves() + let studioInitPromise this.project.server.startWebsockets.callsFake(async (automation, config, callbacks) => { @@ -869,6 +871,68 @@ This option will not have an effect in Some-other-name. Tests that rely on web s }) }) + it('calls resetBrowserState during onStudioInit when AI is enabled', async function () { + const mockSetupProtocol = sinon.stub() + const mockBeforeSpec = sinon.stub() + const mockAccessStudioAI = sinon.stub().resolves(true) + const mockCaptureStudioEvent = sinon.stub().resolves() + + this.project.spec = {} + + this.project._cfg = this.project._cfg || {} + this.project._cfg.projectId = 'test-project-id' + this.project.ctx.coreData.user = { email: 'test@example.com' } + this.project.ctx.coreData.machineId = Promise.resolve('test-machine-id') + + const studioManager = new StudioManager() + + studioManager.canAccessStudioAI = mockAccessStudioAI + studioManager.captureStudioEvent = mockCaptureStudioEvent + studioManager.protocolManager = { + setupProtocol: mockSetupProtocol, + beforeSpec: mockBeforeSpec, + dbPath: 'test-db-path', + } + + const resetStub = sinon.stub(this.project, 'resetBrowserState').resolves() + + const studioLifecycleManager = new StudioLifecycleManager() + + this.project.ctx.coreData.studioLifecycleManager = studioLifecycleManager + + // Set up the studio manager promise directly + studioLifecycleManager.studioManagerPromise = Promise.resolve(studioManager) + studioLifecycleManager.isStudioReady = sinon.stub().returns(true) + + // Create a browser object + this.project.browser = { + name: 'chrome', + family: 'chromium', + } + + this.project.options = { browsers: [this.project.browser] } + + sinon.stub(browsers, 'closeProtocolConnection').resolves() + sinon.stub(browsers, 'connectProtocolToBrowser').resolves() + sinon.stub(this.project, 'protocolManager').get(() => { + return this.project['_protocolManager'] + }).set((protocolManager) => { + this.project['_protocolManager'] = protocolManager + }) + + let studioInitPromise + + this.project.server.startWebsockets.callsFake(async (automation, config, callbacks) => { + studioInitPromise = callbacks.onStudioInit() + }) + + this.project.startWebsockets({}, {}) + + await studioInitPromise + + expect(resetStub).to.be.calledOnce + }) + it('passes onStudioInit callback with AI enabled but no protocol manager', async function () { const mockSetupProtocol = sinon.stub() const mockBeforeSpec = sinon.stub() diff --git a/packages/types/src/studio/studio-server-types.ts b/packages/types/src/studio/studio-server-types.ts index 5fc3b590fdcb..94f934681b0a 100644 --- a/packages/types/src/studio/studio-server-types.ts +++ b/packages/types/src/studio/studio-server-types.ts @@ -1,3 +1,7 @@ +// 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. + /// import type { Router } from 'express' @@ -7,6 +11,12 @@ import type { BinaryLike } from 'crypto' export const StudioMetricsTypes = { STUDIO_STARTED: 'studio:started', + STUDIO_PANEL_OPENED: 'studio:panel:opened', + STUDIO_RECORDING_RESUMED: 'studio:recording:resumed', + STUDIO_RECORDING_PAUSED: 'studio:recording:paused', + STUDIO_INTERACTION_RECORDED: 'studio:interaction:recorded', + STUDIO_ASSERTION_RECORDED: 'studio:assertion:recorded', + STUDIO_EDITOR_SAVED: 'studio:editor:saved', } as const export type StudioMetricsType = @@ -14,8 +24,9 @@ export type StudioMetricsType = export interface StudioEvent { type: StudioMetricsType - machineId: string | null + machineId: string projectId?: string + studioSessionId?: string browser?: { name: string family: string @@ -45,18 +56,36 @@ type AsyncRetry = ( options: RetryOptions ) => (...args: TArgs) => Promise +export type BrowserWindow = { + webContents: { + loadURL: (url: string) => Promise + executeJavaScript: (script: string) => Promise + } + setSize: (width: number, height: number) => void + destroy: () => void + isDestroyed: () => boolean + show: () => void +} + +export type StudioElectronApi = { + createBrowserWindow: () => BrowserWindow +} + export interface StudioServerOptions { studioHash?: string studioPath: string projectSlug?: string cloudApi: StudioCloudApi betterSqlite3Path: string - manifest: Record + sessionId?: string + manifest?: Record verifyHash: (contents: BinaryLike, expectedHash: string) => boolean + studioElectron?: StudioElectronApi } export interface StudioAIInitializeOptions { protocolDbPath: string + studioElectron?: StudioElectronApi } export interface StudioAddSocketListenersOptions { @@ -68,7 +97,7 @@ export interface StudioAddSocketListenersOptions { export interface StudioServerShape { initializeRoutes(router: Router): void canAccessStudioAI(browser: Cypress.Browser): Promise - addSocketListeners(options: StudioAddSocketListenersOptions): void + addSocketListeners(options: StudioAddSocketListenersOptions | Socket): void initializeStudioAI(options: StudioAIInitializeOptions): Promise updateSessionId(sessionId: string): void reportError(