From 6e151bb401ba03ca41bfac58c7cc10d93de5edae Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 1 Jun 2025 22:20:02 -0500 Subject: [PATCH 1/3] chore: add watcher for cy-prompt development --- .../lib/cloud/StudioLifecycleManager.ts | 220 --------- .../cy-prompt/CyPromptLifecycleManager.ts | 94 +++- .../cy-prompt/ensure_cy_prompt_bundle.ts | 33 +- .../unit/cloud/StudioLifecycleManager_spec.ts | 465 ------------------ .../CyPromptLifecycleManager_spec.ts | 133 ++++- .../cy-prompt/ensure_cy_prompt_bundle_spec.ts | 72 +-- 6 files changed, 240 insertions(+), 777 deletions(-) delete mode 100644 packages/server/lib/cloud/StudioLifecycleManager.ts delete mode 100644 packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts diff --git a/packages/server/lib/cloud/StudioLifecycleManager.ts b/packages/server/lib/cloud/StudioLifecycleManager.ts deleted file mode 100644 index ff6115907fc7..000000000000 --- a/packages/server/lib/cloud/StudioLifecycleManager.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { StudioManager } from './studio' -import { ProtocolManager } from './protocol' -import { getAndInitializeStudioManager } from './api/studio/get_and_initialize_studio_manager' -import Debug from 'debug' -import type { CloudDataSource } from '@packages/data-context/src/sources' -import type { Cfg } from '../project-base' -import _ from 'lodash' -import type { DataContext } from '@packages/data-context' -import api from './api' -import { reportStudioError } from './api/studio/report_studio_error' -import { CloudRequest } from './api/cloud_request' -import { isRetryableError } from './network/is_retryable_error' -import { asyncRetry } from '../util/async_retry' -import { postStudioSession } from './api/studio/post_studio_session' -import type { StudioStatus } from '@packages/types' - -const debug = Debug('cypress:server:studio-lifecycle-manager') -const routes = require('./routes') - -export class StudioLifecycleManager { - private studioManagerPromise?: Promise - private studioManager?: StudioManager - private listeners: ((studioManager: StudioManager) => void)[] = [] - private ctx?: DataContext - private lastStatus?: StudioStatus - - public get cloudStudioRequested () { - return !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH) - } - - /** - * Initialize the studio manager and possibly set up protocol. - * Also registers this instance in the data context. - * @param projectId The project ID - * @param cloudDataSource The cloud data source - * @param cfg The project configuration - * @param debugData Debug data for the configuration - * @param ctx Data context to register this instance with - */ - initializeStudioManager ({ - projectId, - cloudDataSource, - cfg, - debugData, - ctx, - }: { - projectId?: string - cloudDataSource: CloudDataSource - cfg: Cfg - debugData: any - ctx: DataContext - }): void { - // Register this instance in the data context - ctx.update((data) => { - data.studioLifecycleManager = this - }) - - this.ctx = ctx - - this.updateStatus('INITIALIZING') - - const studioManagerPromise = this.createStudioManager({ - projectId, - cloudDataSource, - cfg, - debugData, - }).catch(async (error) => { - debug('Error during studio manager setup: %o', error) - - const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production' - const cloudUrl = ctx.cloud.getCloudUrl(cloudEnv) - const cloudHeaders = await ctx.cloud.additionalHeaders() - - reportStudioError({ - cloudApi: { - cloudUrl, - cloudHeaders, - CloudRequest, - isRetryableError, - asyncRetry, - }, - studioHash: projectId, - projectSlug: cfg.projectId, - error, - studioMethod: 'initializeStudioManager', - studioMethodArgs: [], - }) - - this.updateStatus('IN_ERROR') - - // Clean up any registered listeners - this.listeners = [] - - return null - }) - - this.studioManagerPromise = studioManagerPromise - } - - isStudioReady (): boolean { - return !!this.studioManager - } - - async getStudio () { - if (!this.studioManagerPromise) { - throw new Error('Studio manager has not been initialized') - } - - const studioManager = await this.studioManagerPromise - - if (studioManager) { - this.updateStatus(studioManager.status) - } - - return studioManager - } - - private async createStudioManager ({ - projectId, - cloudDataSource, - cfg, - debugData, - }: { - projectId?: string - cloudDataSource: CloudDataSource - cfg: Cfg - debugData: any - }): Promise { - const studioSession = await postStudioSession({ - projectId, - }) - - const studioManager = await getAndInitializeStudioManager({ - studioUrl: studioSession.studioUrl, - projectId, - cloudDataSource, - shouldEnableStudio: this.cloudStudioRequested, - lifecycleManager: this, - }) - - 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() - this.updateStatus(studioManager.status) - - return studioManager - } - - private callRegisteredListeners () { - if (!this.studioManager) { - throw new Error('Studio manager has not been initialized') - } - - const studioManager = this.studioManager - - debug('Calling all studio ready listeners') - this.listeners.forEach((listener) => { - listener(studioManager) - }) - - this.listeners = [] - } - - /** - * Register a listener that will be called when the studio is ready - * @param listener Function to call when studio is ready - */ - registerStudioReadyListener (listener: (studioManager: StudioManager) => void): void { - // if there is already a studio manager, call the listener immediately - if (this.studioManager) { - debug('Studio ready - calling listener immediately') - listener(this.studioManager) - } else { - debug('Studio not ready - registering studio ready listener') - this.listeners.push(listener) - } - } - - public updateStatus (status: StudioStatus) { - if (status === this.lastStatus) { - debug('Studio status unchanged: %s', status) - - return - } - - debug('Studio status changed: %s → %s', this.lastStatus, status) - this.lastStatus = status - - if (this.ctx) { - this.ctx?.emitter.studioStatusChange() - } else { - debug('No ctx available, cannot emit studioStatusChange') - } - } -} diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index e7bce3cd41cb..04911ad93b0d 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -10,10 +10,14 @@ import path from 'path' import os from 'os' import { readFile } from 'fs-extra' import { ensureCyPromptBundle } from './ensure_cy_prompt_bundle' +import chokidar from 'chokidar' +import { getCloudMetadata } from '../get_cloud_metadata' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') export class CyPromptLifecycleManager { + private static hashLoadingMap: Map> = new Map() + private static watcher: chokidar.FSWatcher | null = null private cyPromptManagerPromise?: Promise private cyPromptManager?: CyPromptManager private listeners: ((cyPromptManager: CyPromptManager) => void)[] = [] @@ -72,6 +76,11 @@ export class CyPromptLifecycleManager { }) this.cyPromptManagerPromise = cyPromptManagerPromise + + this.setupWatcher({ + projectId, + cloudDataSource, + }) } async getCyPrompt () { @@ -91,29 +100,42 @@ export class CyPromptLifecycleManager { projectId: string cloudDataSource: CloudDataSource }): Promise { + let cyPromptHash: string + let cyPromptPath: string + const cyPromptSession = await postCyPromptSession({ projectId, }) - // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension - const cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] - const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) - const bundlePath = path.join(cyPromptPath, 'bundle.tar') - const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension + cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] + cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) - await ensureCyPromptBundle({ - cyPromptUrl: cyPromptSession.cyPromptUrl, - projectId, - cyPromptPath, - bundlePath, - }) + let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(cyPromptHash) + + if (!hashLoadingPromise) { + hashLoadingPromise = ensureCyPromptBundle({ + cyPromptUrl: cyPromptSession.cyPromptUrl, + projectId, + cyPromptPath, + }) + + CyPromptLifecycleManager.hashLoadingMap.set(cyPromptHash, hashLoadingPromise) + } + + await hashLoadingPromise + } else { + cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH + cyPromptHash = 'local' + } + + const serverFilePath = path.join(cyPromptPath, 'server', 'index.js') const script = await readFile(serverFilePath, 'utf8') const cyPromptManager = new CyPromptManager() - const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production' - const cloudUrl = cloudDataSource.getCloudUrl(cloudEnv) - const cloudHeaders = await cloudDataSource.additionalHeaders() + const { cloudUrl, cloudHeaders } = await getCloudMetadata(cloudDataSource) await cyPromptManager.setup({ script, @@ -148,7 +170,43 @@ export class CyPromptLifecycleManager { listener(cyPromptManager) }) - this.listeners = [] + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + this.listeners = [] + } + } + + private setupWatcher ({ + projectId, + cloudDataSource, + }: { + projectId: string + cloudDataSource: CloudDataSource + }) { + // Don't setup a watcher if the cy prompt bundle is NOT local + if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + return + } + + // Close the watcher if a previous watcher exists + if (CyPromptLifecycleManager.watcher) { + CyPromptLifecycleManager.watcher.removeAllListeners() + CyPromptLifecycleManager.watcher.close().catch(() => {}) + } + + // Watch for changes to the cy prompt bundle + CyPromptLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server', 'index.js'), { + awaitWriteFinish: true, + }).on('change', async () => { + this.cyPromptManager = undefined + this.cyPromptManagerPromise = this.createCyPromptManager({ + projectId, + cloudDataSource, + }).catch((error) => { + debug('Error during reload of cy prompt manager: %o', error) + + return null + }) + }) } /** @@ -160,6 +218,12 @@ export class CyPromptLifecycleManager { if (this.cyPromptManager) { debug('cy prompt ready - calling listener immediately') listener(this.cyPromptManager) + + // If the cy prompt bundle is local, we need to register the listener + // so that we can reload the cy prompt when the bundle changes + if (process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { + this.listeners.push(listener) + } } else { debug('cy prompt not ready - registering cy prompt ready listener') this.listeners.push(listener) diff --git a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts index 14af0eac0e07..4ef932f5409b 100644 --- a/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts +++ b/packages/server/lib/cloud/cy-prompt/ensure_cy_prompt_bundle.ts @@ -1,4 +1,4 @@ -import { copy, remove, ensureDir } from 'fs-extra' +import { remove, ensureDir } from 'fs-extra' import tar from 'tar' import { getCyPromptBundle } from '../api/cy-prompt/get_cy_prompt_bundle' @@ -8,30 +8,23 @@ interface EnsureCyPromptBundleOptions { cyPromptPath: string cyPromptUrl: string projectId: string - bundlePath: string } -export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId, bundlePath }: EnsureCyPromptBundleOptions) => { +export const ensureCyPromptBundle = async ({ cyPromptPath, cyPromptUrl, projectId }: EnsureCyPromptBundleOptions) => { + const bundlePath = path.join(cyPromptPath, 'bundle.tar') + // First remove cyPromptPath to ensure we have a clean slate await remove(cyPromptPath) await ensureDir(cyPromptPath) - if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { - await getCyPromptBundle({ - cyPromptUrl, - projectId, - bundlePath, - }) - - await tar.extract({ - file: bundlePath, - cwd: cyPromptPath, - }) - } else { - const driverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'driver') - const serverPath = path.join(process.env.CYPRESS_LOCAL_CY_PROMPT_PATH, 'server') + await getCyPromptBundle({ + cyPromptUrl, + projectId, + bundlePath, + }) - await copy(driverPath, path.join(cyPromptPath, 'driver')) - await copy(serverPath, path.join(cyPromptPath, 'server')) - } + await tar.extract({ + file: bundlePath, + cwd: cyPromptPath, + }) } diff --git a/packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts deleted file mode 100644 index 47b5ce285aae..000000000000 --- a/packages/server/test/unit/cloud/StudioLifecycleManager_spec.ts +++ /dev/null @@ -1,465 +0,0 @@ -import { sinon } from '../../spec_helper' -import { expect } from 'chai' -import { StudioManager } from '../../../lib/cloud/studio' -import { StudioLifecycleManager } from '../../../lib/cloud/StudioLifecycleManager' -import type { DataContext } from '@packages/data-context' -import type { Cfg } from '../../../lib/project-base' -import type { CloudDataSource } from '@packages/data-context/src/sources' -import * as getAndInitializeStudioManagerModule from '../../../lib/cloud/api/studio/get_and_initialize_studio_manager' -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)) - -describe('StudioLifecycleManager', () => { - let studioLifecycleManager: StudioLifecycleManager - let mockStudioManager: StudioManager - 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 - let reportStudioErrorStub: sinon.SinonStub - let studioStatusChangeEmitterStub: sinon.SinonStub - - beforeEach(() => { - studioLifecycleManager = new StudioLifecycleManager() - mockStudioManager = { - addSocketListeners: sinon.stub(), - canAccessStudioAI: sinon.stub().resolves(true), - status: 'INITIALIZED', - } as unknown as StudioManager - - studioStatusChangeEmitterStub = sinon.stub() - - mockCtx = { - update: sinon.stub(), - coreData: {}, - cloud: { - getCloudUrl: sinon.stub().returns('https://cloud.cypress.io'), - additionalHeaders: sinon.stub().resolves({ 'Authorization': 'Bearer test-token' }), - }, - emitter: { - studioStatusChange: studioStatusChangeEmitterStub, - }, - } as unknown as DataContext - - mockCloudDataSource = {} as CloudDataSource - - mockCfg = { - projectId: 'abc123', - testingType: 'e2e', - projectRoot: '/test/project', - port: 8888, - proxyUrl: 'http://localhost:8888', - devServerPublicPathRoute: '/__cypress/src', - 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) - - getCaptureProtocolScriptStub = sinon.stub(api, 'getCaptureProtocolScript').resolves('console.log("hello")') - prepareProtocolStub = sinon.stub(ProtocolManager.prototype, 'prepareProtocol').resolves() - - reportStudioErrorStub = sinon.stub(reportStudioErrorPath, 'reportStudioError') - }) - - afterEach(() => { - sinon.restore() - }) - - describe('cloudStudioRequested', () => { - it('is true when CYPRESS_ENABLE_CLOUD_STUDIO is set', async () => { - process.env.CYPRESS_ENABLE_CLOUD_STUDIO = '1' - delete process.env.CYPRESS_LOCAL_STUDIO_PATH - - expect(studioLifecycleManager.cloudStudioRequested).to.be.true - }) - - it('is true when CYPRESS_LOCAL_STUDIO_PATH is set', async () => { - delete process.env.CYPRESS_ENABLE_CLOUD_STUDIO - process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio' - - expect(studioLifecycleManager.cloudStudioRequested).to.be.true - }) - - it('is false when neither env variable is set', async () => { - delete process.env.CYPRESS_ENABLE_CLOUD_STUDIO - delete process.env.CYPRESS_LOCAL_STUDIO_PATH - - expect(studioLifecycleManager.cloudStudioRequested).to.be.false - }) - - it('is true when both env variables are set', async () => { - process.env.CYPRESS_ENABLE_CLOUD_STUDIO = '1' - process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio' - - expect(studioLifecycleManager.cloudStudioRequested).to.be.true - }) - }) - - describe('initializeStudioManager', () => { - it('initializes the studio manager and registers it in the data context', async () => { - studioLifecycleManager.initializeStudioManager({ - projectId: 'test-project-id', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - const studioReadyPromise = new Promise((resolve) => { - studioLifecycleManager?.registerStudioReadyListener((studioManager) => { - resolve(studioManager) - }) - }) - - await studioReadyPromise - - expect(mockCtx.update).to.be.calledOnce - expect(studioLifecycleManager.isStudioReady()).to.be.true - }) - - it('sets up protocol if studio is enabled', async () => { - mockStudioManager.status = 'ENABLED' - - studioLifecycleManager.initializeStudioManager({ - projectId: 'abc123', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - const studioReadyPromise = new Promise((resolve) => { - studioLifecycleManager?.registerStudioReadyListener((studioManager) => { - resolve(studioManager) - }) - }) - - await studioReadyPromise - - 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', - testingType: 'e2e', - cloudApi: { - url: 'http://localhost:1234/', - retryWithBackoff: api.retryWithBackoff, - requestPromise: api.rp, - }, - projectConfig: { - devServerPublicPathRoute: '/__cypress/src', - namespace: '__cypress', - port: 8888, - proxyUrl: 'http://localhost:8888', - }, - mountVersion: 2, - debugData: {}, - mode: 'studio', - }) - }) - - it('handles errors during initialization and reports them', async () => { - const error = new Error('Test error') - const listener1 = sinon.stub() - const listener2 = sinon.stub() - - // Register listeners that should be cleaned up - studioLifecycleManager.registerStudioReadyListener(listener1) - studioLifecycleManager.registerStudioReadyListener(listener2) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners.length).to.equal(2) - - getAndInitializeStudioManagerStub.rejects(error) - - const reportErrorPromise = new Promise((resolve) => { - reportStudioErrorStub.callsFake(() => { - resolve() - - return undefined - }) - }) - - // Should not throw - studioLifecycleManager.initializeStudioManager({ - projectId: 'test-project-id', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - await reportErrorPromise - - expect(mockCtx.update).to.be.calledOnce - - // @ts-expect-error - accessing private property - const studioPromise = studioLifecycleManager.studioManagerPromise - - expect(studioPromise).to.not.be.null - - expect(reportStudioErrorStub).to.be.calledOnce - expect(reportStudioErrorStub).to.be.calledWithMatch({ - cloudApi: sinon.match.object, - studioHash: 'test-project-id', - projectSlug: 'abc123', - error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Test error')), - studioMethod: 'initializeStudioManager', - studioMethodArgs: [], - }) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners.length).to.equal(0) - - expect(listener1).not.to.be.called - expect(listener2).not.to.be.called - - if (studioPromise) { - const result = await studioPromise - - expect(result).to.be.null - } - }) - }) - - describe('isStudioReady', () => { - it('returns false when studio manager has not been initialized', () => { - expect(studioLifecycleManager.isStudioReady()).to.be.false - }) - - it('returns true when studio has been initialized', async () => { - // @ts-expect-error - accessing private property - studioLifecycleManager.studioManager = mockStudioManager - - expect(studioLifecycleManager.isStudioReady()).to.be.true - }) - }) - - describe('getStudio', () => { - it('throws an error when studio manager is not initialized', async () => { - try { - await studioLifecycleManager.getStudio() - expect.fail('Expected method to throw') - } catch (error) { - expect(error.message).to.equal('Studio manager has not been initialized') - } - }) - - it('returns the studio manager when initialized', async () => { - // @ts-expect-error - accessing private property - studioLifecycleManager.studioManagerPromise = Promise.resolve(mockStudioManager) - - const result = await studioLifecycleManager.getStudio() - - expect(result).to.equal(mockStudioManager) - }) - }) - - describe('registerStudioReadyListener', () => { - it('registers a listener that will be called when studio is ready', () => { - const listener = sinon.stub() - - studioLifecycleManager.registerStudioReadyListener(listener) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners).to.include(listener) - }) - - it('calls listener immediately if studio is already ready', async () => { - const listener = sinon.stub() - - // @ts-expect-error - accessing private property - studioLifecycleManager.studioManager = mockStudioManager - - // @ts-expect-error - accessing non-existent property - studioLifecycleManager.studioReady = true - - await Promise.resolve() - - studioLifecycleManager.registerStudioReadyListener(listener) - - await Promise.resolve() - await Promise.resolve() - await nextTick() - - expect(listener).to.be.calledWith(mockStudioManager) - }) - - it('does not call listener if studio manager is null', async () => { - const listener = sinon.stub() - - // @ts-expect-error - accessing private property - studioLifecycleManager.studioManager = null - - // @ts-expect-error - accessing non-existent property - studioLifecycleManager.studioReady = true - - studioLifecycleManager.registerStudioReadyListener(listener) - - // Give enough time for any promises to resolve - await Promise.resolve() - await Promise.resolve() - await nextTick() - - expect(listener).not.to.be.called - }) - - it('adds multiple listeners to the list', () => { - const listener1 = sinon.stub() - const listener2 = sinon.stub() - - studioLifecycleManager.registerStudioReadyListener(listener1) - studioLifecycleManager.registerStudioReadyListener(listener2) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners).to.include(listener1) - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners).to.include(listener2) - }) - - it('cleans up listeners after calling them when studio becomes ready', async () => { - 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((resolve) => { - listener1.callsFake(() => resolve()) - }), - new Promise((resolve) => { - listener2.callsFake(() => resolve()) - }), - ]) - - studioLifecycleManager.initializeStudioManager({ - projectId: 'test-project-id', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - await listenersCalledPromise - - await nextTick() - - expect(listener1).to.be.calledWith(mockStudioManager) - expect(listener2).to.be.calledWith(mockStudioManager) - - // @ts-expect-error - accessing private property - expect(studioLifecycleManager.listeners.length).to.equal(0) - }) - }) - - describe('status tracking', () => { - it('updates status and emits events when status changes', async () => { - // Setup the context to test status updates - // @ts-expect-error - accessing private property - studioLifecycleManager.ctx = mockCtx - - studioLifecycleManager.updateStatus('INITIALIZING') - - // Wait for nextTick to process - await nextTick() - - expect(studioStatusChangeEmitterStub).to.be.calledOnce - - // Same status should not trigger another event - studioStatusChangeEmitterStub.reset() - studioLifecycleManager.updateStatus('INITIALIZING') - - await nextTick() - expect(studioStatusChangeEmitterStub).not.to.be.called - - // Different status should trigger another event - studioStatusChangeEmitterStub.reset() - studioLifecycleManager.updateStatus('ENABLED') - - await nextTick() - expect(studioStatusChangeEmitterStub).to.be.calledOnce - }) - - it('updates status when getStudio is called', async () => { - // @ts-expect-error - accessing private property - studioLifecycleManager.ctx = mockCtx - // @ts-expect-error - accessing private property - studioLifecycleManager.studioManagerPromise = Promise.resolve(mockStudioManager) - - const updateStatusSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus') - - const result = await studioLifecycleManager.getStudio() - - expect(result).to.equal(mockStudioManager) - expect(updateStatusSpy).to.be.calledWith('INITIALIZED') - }) - - it('handles status updates properly during initialization', async () => { - const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus') - - studioLifecycleManager.initializeStudioManager({ - projectId: 'test-project-id', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - // Should set INITIALIZING status immediately - expect(statusChangesSpy).to.be.calledWith('INITIALIZING') - - const studioReadyPromise = new Promise((resolve) => { - studioLifecycleManager?.registerStudioReadyListener(() => { - resolve(true) - }) - }) - - await studioReadyPromise - - expect(statusChangesSpy).to.be.calledWith('INITIALIZED') - }) - - it('updates status to IN_ERROR when initialization fails', async () => { - getAndInitializeStudioManagerStub.rejects(new Error('Test error')) - - const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus') - - studioLifecycleManager.initializeStudioManager({ - projectId: 'test-project-id', - cloudDataSource: mockCloudDataSource, - cfg: mockCfg, - debugData: {}, - ctx: mockCtx, - }) - - expect(statusChangesSpy).to.be.calledWith('INITIALIZING') - - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(statusChangesSpy).to.be.calledWith('IN_ERROR') - }) - }) -}) diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index bbe38fb9beed..e6449302ef1a 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -21,6 +21,9 @@ describe('CyPromptLifecycleManager', () => { let ensureCyPromptBundleStub: sinon.SinonStub let cyPromptManagerSetupStub: sinon.SinonStub = sinon.stub() let readFileStub: sinon.SinonStub = sinon.stub() + let watcherStub: sinon.SinonStub = sinon.stub() + let watcherOnStub: sinon.SinonStub = sinon.stub() + let watcherCloseStub: sinon.SinonStub = sinon.stub() beforeEach(() => { postCyPromptSessionStub = sinon.stub() @@ -50,6 +53,12 @@ describe('CyPromptLifecycleManager', () => { 'fs-extra': { readFile: readFileStub.resolves('console.log("cy-prompt script")'), }, + 'chokidar': { + watch: watcherStub.returns({ + on: watcherOnStub, + close: watcherCloseStub, + }), + }, }).CyPromptLifecycleManager cyPromptLifecycleManager = new CyPromptLifecycleManager() @@ -80,6 +89,8 @@ describe('CyPromptLifecycleManager', () => { afterEach(() => { sinon.restore() + + delete process.env.CYPRESS_LOCAL_CY_PROMPT_PATH }) describe('initializeCyPromptManager', () => { @@ -103,7 +114,6 @@ describe('CyPromptLifecycleManager', () => { cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'), cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz', projectId: 'test-project-id', - bundlePath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'bundle.tar'), }) expect(cyPromptManagerSetupStub).to.be.calledWith({ @@ -128,6 +138,72 @@ describe('CyPromptLifecycleManager', () => { expect(mockCloudDataSource.additionalHeaders).to.be.called expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') }) + + it('initializes the cy-prompt manager in watch mode if CYPRESS_LOCAL_CY_PROMPT_PATH is set', async () => { + process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' + + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + }) + + const cyPromptReadyPromise = new Promise((resolve) => { + cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPromptManager) => { + resolve(cyPromptManager) + }) + }) + + await cyPromptReadyPromise + + expect(mockCtx.update).to.be.calledOnce + expect(ensureCyPromptBundleStub).to.not.be.called + + expect(cyPromptManagerSetupStub).to.be.calledWith({ + script: 'console.log("cy-prompt script")', + cyPromptPath: '/path/to/cy-prompt', + cyPromptHash: 'local', + projectSlug: 'test-project-id', + cloudApi: { + cloudUrl: 'https://cloud.cypress.io', + cloudHeaders: { 'Authorization': 'Bearer test-token' }, + CloudRequest, + isRetryableError, + asyncRetry, + }, + }) + + expect(postCyPromptSessionStub).to.be.calledWith({ + projectId: 'test-project-id', + }) + + expect(readFileStub).to.be.calledWith(path.join('/path', 'to', 'cy-prompt', 'server', 'index.js'), 'utf8') + + expect(CyPromptLifecycleManager['watcher']).to.be.present + expect(watcherStub).to.be.calledWith(path.join('/path', 'to', 'cy-prompt', 'server', 'index.js'), { + awaitWriteFinish: true, + }) + + expect(watcherOnStub).to.be.calledWith('change') + + const onCallback = watcherOnStub.args[0][1] + + let mockCyPromptManagerPromise: Promise + const updatedCyPromptManager = {} as unknown as CyPromptManager + + cyPromptLifecycleManager['createCyPromptManager'] = sinon.stub().callsFake(() => { + mockCyPromptManagerPromise = new Promise((resolve) => { + resolve(updatedCyPromptManager) + }) + + return mockCyPromptManagerPromise + }) + + onCallback() + + expect(mockCyPromptManagerPromise).to.be.present + expect(await mockCyPromptManagerPromise).to.equal(updatedCyPromptManager) + }) }) describe('getCyPrompt', () => { @@ -174,6 +250,25 @@ describe('CyPromptLifecycleManager', () => { expect(listener).to.be.calledWith(mockCyPromptManager) }) + it('calls listener immediately and adds to the list of listeners when CYPRESS_LOCAL_CY_PROMPT_PATH is set', async () => { + process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' + + const listener = sinon.stub() + + // @ts-expect-error - accessing private property + cyPromptLifecycleManager.cyPromptManager = mockCyPromptManager + + // @ts-expect-error - accessing non-existent property + cyPromptLifecycleManager.cyPromptReady = true + + cyPromptLifecycleManager.registerCyPromptReadyListener(listener) + + expect(listener).to.be.calledWith(mockCyPromptManager) + + // @ts-expect-error - accessing private property + expect(cyPromptLifecycleManager.listeners).to.include(listener) + }) + it('does not call listener if cy-prompt manager is null', async () => { const listener = sinon.stub() @@ -234,5 +329,41 @@ describe('CyPromptLifecycleManager', () => { // @ts-expect-error - accessing private property expect(cyPromptLifecycleManager.listeners.length).to.equal(0) }) + + it('does not clean up listeners when CYPRESS_LOCAL_CY_PROMPT_PATH is set', async () => { + process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' + + const listener1 = sinon.stub() + const listener2 = sinon.stub() + + cyPromptLifecycleManager.registerCyPromptReadyListener(listener1) + cyPromptLifecycleManager.registerCyPromptReadyListener(listener2) + + // @ts-expect-error - accessing private property + expect(cyPromptLifecycleManager.listeners.length).to.equal(2) + + const listenersCalledPromise = Promise.all([ + new Promise((resolve) => { + listener1.callsFake(() => resolve()) + }), + new Promise((resolve) => { + listener2.callsFake(() => resolve()) + }), + ]) + + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + }) + + await listenersCalledPromise + + expect(listener1).to.be.calledWith(mockCyPromptManager) + expect(listener2).to.be.calledWith(mockCyPromptManager) + + // @ts-expect-error - accessing private property + expect(cyPromptLifecycleManager.listeners.length).to.equal(2) + }) }) }) diff --git a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts index fe1eec6fb3cb..b40638f52a13 100644 --- a/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/ensure_cy_prompt_bundle_spec.ts @@ -7,16 +7,12 @@ describe('ensureCyPromptBundle', () => { let tmpdir: string = '/tmp' let rmStub: sinon.SinonStub = sinon.stub() let ensureStub: sinon.SinonStub = sinon.stub() - let copyStub: sinon.SinonStub = sinon.stub() - let readFileStub: sinon.SinonStub = sinon.stub() let extractStub: sinon.SinonStub = sinon.stub() let getCyPromptBundleStub: sinon.SinonStub = sinon.stub() beforeEach(() => { rmStub = sinon.stub() ensureStub = sinon.stub() - copyStub = sinon.stub() - readFileStub = sinon.stub() extractStub = sinon.stub() getCyPromptBundleStub = sinon.stub() @@ -28,8 +24,6 @@ describe('ensureCyPromptBundle', () => { 'fs-extra': { remove: rmStub.resolves(), ensureDir: ensureStub.resolves(), - copy: copyStub.resolves(), - readFile: readFileStub.resolves('console.log("cy-prompt script")'), }, tar: { extract: extractStub.resolves(), @@ -40,61 +34,27 @@ describe('ensureCyPromptBundle', () => { })).ensureCyPromptBundle }) - describe('CYPRESS_LOCAL_CY_PROMPT_PATH not set', () => { - beforeEach(() => { - delete process.env.CYPRESS_LOCAL_CY_PROMPT_PATH - }) - - it('should ensure the cy prompt bundle', async () => { - const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', '123') - const bundlePath = path.join(cyPromptPath, 'bundle.tar') - - await ensureCyPromptBundle({ - cyPromptPath, - cyPromptUrl: 'https://cypress.io/cy-prompt', - projectId: '123', - bundlePath, - }) + it('should ensure the cy prompt bundle', async () => { + const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', '123') + const bundlePath = path.join(cyPromptPath, 'bundle.tar') - expect(rmStub).to.be.calledWith(cyPromptPath) - expect(ensureStub).to.be.calledWith(cyPromptPath) - expect(getCyPromptBundleStub).to.be.calledWith({ - cyPromptUrl: 'https://cypress.io/cy-prompt', - projectId: '123', - bundlePath, - }) - - expect(extractStub).to.be.calledWith({ - file: bundlePath, - cwd: cyPromptPath, - }) + await ensureCyPromptBundle({ + cyPromptPath, + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', }) - }) - describe('CYPRESS_LOCAL_CY_PROMPT_PATH set', () => { - beforeEach(() => { - process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' + expect(rmStub).to.be.calledWith(cyPromptPath) + expect(ensureStub).to.be.calledWith(cyPromptPath) + expect(getCyPromptBundleStub).to.be.calledWith({ + cyPromptUrl: 'https://cypress.io/cy-prompt', + projectId: '123', + bundlePath, }) - afterEach(() => { - delete process.env.CYPRESS_LOCAL_CY_PROMPT_PATH - }) - - it('should ensure the cy prompt bundle', async () => { - const cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', '123') - const bundlePath = path.join(cyPromptPath, 'bundle.tar') - - await ensureCyPromptBundle({ - cyPromptPath, - cyPromptUrl: 'https://cypress.io/cy-prompt', - projectId: '123', - bundlePath, - }) - - expect(rmStub).to.be.calledWith(cyPromptPath) - expect(ensureStub).to.be.calledWith(cyPromptPath) - expect(copyStub).to.be.calledWith('/path/to/cy-prompt/driver', path.join(cyPromptPath, 'driver')) - expect(copyStub).to.be.calledWith('/path/to/cy-prompt/server', path.join(cyPromptPath, 'server')) + expect(extractStub).to.be.calledWith({ + file: bundlePath, + cwd: cyPromptPath, }) }) }) From 0f4b74324c81fa8bb9ff6955e5d44f92c9cff27a Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Sun, 1 Jun 2025 22:30:09 -0500 Subject: [PATCH 2/3] test caching --- .../CyPromptLifecycleManager_spec.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index e6449302ef1a..428a25638d76 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -139,6 +139,68 @@ describe('CyPromptLifecycleManager', () => { expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') }) + it('only calls ensureCyPromptBundle once per cy prompt hash', async () => { + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + }) + + const cyPromptReadyPromise1 = new Promise((resolve) => { + cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPromptManager) => { + resolve(cyPromptManager) + }) + }) + + const cyPromptManager1 = await cyPromptReadyPromise1 + + cyPromptLifecycleManager.initializeCyPromptManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + }) + + const cyPromptReadyPromise2 = new Promise((resolve) => { + cyPromptLifecycleManager?.registerCyPromptReadyListener((cyPromptManager) => { + resolve(cyPromptManager) + }) + }) + + const cyPromptManager2 = await cyPromptReadyPromise2 + + expect(cyPromptManager1).to.equal(cyPromptManager2) + + expect(ensureCyPromptBundleStub).to.be.calledOnce + expect(ensureCyPromptBundleStub).to.be.calledWith({ + cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'), + cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz', + projectId: 'test-project-id', + }) + + expect(cyPromptManagerSetupStub).to.be.calledOnce + expect(cyPromptManagerSetupStub).to.be.calledWith({ + script: 'console.log("cy-prompt script")', + cyPromptPath: path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc'), + cyPromptHash: 'abc', + projectSlug: 'test-project-id', + cloudApi: { + cloudUrl: 'https://cloud.cypress.io', + cloudHeaders: { 'Authorization': 'Bearer test-token' }, + CloudRequest, + isRetryableError, + asyncRetry, + }, + }) + + expect(postCyPromptSessionStub).to.be.calledWith({ + projectId: 'test-project-id', + }) + + expect(mockCloudDataSource.getCloudUrl).to.be.calledWith('test') + expect(mockCloudDataSource.additionalHeaders).to.be.called + expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'cy-prompt', 'abc', 'server', 'index.js'), 'utf8') + }) + it('initializes the cy-prompt manager in watch mode if CYPRESS_LOCAL_CY_PROMPT_PATH is set', async () => { process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' From 7444ea8238643603060558dc35262e21a86c8f86 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Tue, 3 Jun 2025 12:23:26 -0500 Subject: [PATCH 3/3] fix types --- packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 640cc6eb8060..22ae9a6afab8 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -179,7 +179,7 @@ export class CyPromptLifecycleManager { projectId, cloudDataSource, }: { - projectId: string + projectId?: string cloudDataSource: CloudDataSource }) { // Don't setup a watcher if the cy prompt bundle is NOT local