diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 931d709c1ed1..22ae9a6afab8 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 5248c479e558..fadf2e62eff8 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/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index 7f6d4df6e7e9..47fe38b6d30a 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,134 @@ 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('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' + + 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 +312,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 +391,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, }) }) })