From 12ba74e3b721403060032360c5e94feb09cc1c2f Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 16:24:55 -0500 Subject: [PATCH 01/15] internal: (studio) set up hot reloading for the studio bundle --- .../get_and_initialize_studio_manager.ts | 185 --------- .../lib/cloud/api/studio/get_studio_bundle.ts | 62 +++ .../cloud/api/studio/post_studio_session.ts | 4 +- .../server/lib/cloud/get_cloud_metadata.ts | 12 + .../studio}/StudioLifecycleManager.ts | 116 +++++- .../lib/cloud/studio/ensure_studio_bundle.ts | 44 +++ .../server/lib/cloud/{ => studio}/studio.ts | 4 +- packages/server/lib/project-base.ts | 2 +- .../api/studio/get_studio_bundle_spec.ts | 232 +++++++++++ .../unit/cloud/get_cloud_metadata_spec.ts | 60 +++ .../studio}/StudioLifecycleManager_spec.ts | 364 +++++++++++++++--- .../cloud/studio/ensure_studio_bundle_spec.ts | 66 ++++ .../unit/cloud/{ => studio}/studio_spec.ts | 14 +- packages/server/test/unit/project_spec.js | 7 +- scripts/gulp/tasks/gulpCloudDeliveredTypes.ts | 56 ++- 15 files changed, 948 insertions(+), 280 deletions(-) delete mode 100644 packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts create mode 100644 packages/server/lib/cloud/api/studio/get_studio_bundle.ts create mode 100644 packages/server/lib/cloud/get_cloud_metadata.ts rename packages/server/lib/{ => cloud/studio}/StudioLifecycleManager.ts (62%) create mode 100644 packages/server/lib/cloud/studio/ensure_studio_bundle.ts rename packages/server/lib/cloud/{ => studio}/studio.ts (97%) create mode 100644 packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts create mode 100644 packages/server/test/unit/cloud/get_cloud_metadata_spec.ts rename packages/server/test/unit/{ => cloud/studio}/StudioLifecycleManager_spec.ts (55%) create mode 100644 packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts rename packages/server/test/unit/cloud/{ => studio}/studio_spec.ts (92%) diff --git a/packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts b/packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts deleted file mode 100644 index 8a732f2e0e00..000000000000 --- a/packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts +++ /dev/null @@ -1,185 +0,0 @@ -import path from 'path' -import os from 'os' -import { ensureDir, copy, readFile, remove } from 'fs-extra' -import { StudioManager } from '../../studio' -import tar from 'tar' -import { verifySignatureFromFile } from '../../encryption' -import fs from 'fs' -import fetch from 'cross-fetch' -import { agent } from '@packages/network' -import { asyncRetry, linearDelay } from '../../../util/async_retry' -import { isRetryableError } from '../../network/is_retryable_error' -import { PUBLIC_KEY_VERSION } from '../../constants' -import { CloudRequest } from '../cloud_request' -import type { CloudDataSource } from '@packages/data-context/src/sources' -import type { StudioLifecycleManagerShape } from '@packages/types' - -interface Options { - studioUrl: string - projectId?: string -} - -const pkg = require('@packages/root') - -const _delay = linearDelay(500) - -// Default timeout of 30 seconds for the download -const DOWNLOAD_TIMEOUT_MS = 30000 - -export const studioPath = path.join(os.tmpdir(), 'cypress', 'studio') - -const bundlePath = path.join(studioPath, 'bundle.tar') -const serverFilePath = path.join(studioPath, 'server', 'index.js') - -async function downloadStudioBundleWithTimeout (args: Options & { downloadTimeoutMs?: number }) { - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Cloud studio download timed out')) - }, args.downloadTimeoutMs || DOWNLOAD_TIMEOUT_MS) - }) - - const funcPromise = downloadStudioBundleToTempDirectory(args) - - return Promise.race([funcPromise, timeoutPromise]) -} - -const downloadStudioBundleToTempDirectory = async ({ studioUrl, projectId }: Options): Promise => { - let responseSignature: string | null = null - - await (asyncRetry(async () => { - const response = await fetch(studioUrl, { - // @ts-expect-error - this is supported - agent, - method: 'GET', - headers: { - 'x-route-version': '1', - 'x-cypress-signature': PUBLIC_KEY_VERSION, - ...(projectId ? { 'x-cypress-project-slug': projectId } : {}), - 'x-cypress-studio-mount-version': '1', - 'x-os-name': os.platform(), - 'x-cypress-version': pkg.version, - }, - encrypt: 'signed', - }) - - if (!response.ok) { - throw new Error(`Failed to download studio bundle: ${response.statusText}`) - } - - responseSignature = response.headers.get('x-cypress-signature') - - await new Promise((resolve, reject) => { - const writeStream = fs.createWriteStream(bundlePath) - - writeStream.on('error', reject) - writeStream.on('finish', () => { - resolve() - }) - - // @ts-expect-error - this is supported - response.body?.pipe(writeStream) - }) - }, { - maxAttempts: 3, - retryDelay: _delay, - shouldRetry: isRetryableError, - }))() - - if (!responseSignature) { - throw new Error('Unable to get studio signature') - } - - const verified = await verifySignatureFromFile(bundlePath, responseSignature) - - if (!verified) { - throw new Error('Unable to verify studio signature') - } -} - -export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId, downloadTimeoutMs }: Options & { downloadTimeoutMs?: number }): Promise<{ studioHash: string | undefined }> => { - // The studio hash is the last part of the studio URL, after the last slash and before the extension - const studioHash = studioUrl.split('/').pop()?.split('.')[0] - - // First remove studioPath to ensure we have a clean slate - await remove(studioPath) - await ensureDir(studioPath) - - // Note: CYPRESS_LOCAL_STUDIO_PATH is stripped from the binary, effectively removing this code path - if (process.env.CYPRESS_LOCAL_STUDIO_PATH) { - const appPath = path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'app') - const serverPath = path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'server') - - await copy(appPath, path.join(studioPath, 'app')) - await copy(serverPath, path.join(studioPath, 'server')) - - return { studioHash: undefined } - } - - await downloadStudioBundleWithTimeout({ studioUrl, projectId, downloadTimeoutMs }) - - await tar.extract({ - file: bundlePath, - cwd: studioPath, - }) - - return { studioHash } -} - -export const getAndInitializeStudioManager = async ({ studioUrl, projectId, cloudDataSource, shouldEnableStudio, downloadTimeoutMs, lifecycleManager }: { studioUrl: string, projectId?: string, cloudDataSource: CloudDataSource, shouldEnableStudio: boolean, downloadTimeoutMs?: number, lifecycleManager?: StudioLifecycleManagerShape }): Promise => { - let script: string - - 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() - - let studioHash: string | undefined - - try { - ({ studioHash } = await retrieveAndExtractStudioBundle({ studioUrl, projectId, downloadTimeoutMs })) - - script = await readFile(serverFilePath, 'utf8') - - const studioManager = new StudioManager() - - await studioManager.setup({ - script, - studioPath, - studioHash, - projectSlug: projectId, - cloudApi: { - cloudUrl, - cloudHeaders, - CloudRequest, - isRetryableError, - asyncRetry, - }, - shouldEnableStudio, - }) - - return studioManager - } catch (error: unknown) { - let actualError: Error - - if (!(error instanceof Error)) { - actualError = new Error(String(error)) - } else { - actualError = error - } - - return StudioManager.createInErrorManager({ - cloudApi: { - cloudUrl, - cloudHeaders, - CloudRequest, - isRetryableError, - asyncRetry, - }, - studioHash, - projectSlug: projectId, - error: actualError, - studioMethod: 'getAndInitializeStudioManager', - }) - } finally { - await remove(bundlePath) - } -} diff --git a/packages/server/lib/cloud/api/studio/get_studio_bundle.ts b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts new file mode 100644 index 000000000000..6347b3f1046c --- /dev/null +++ b/packages/server/lib/cloud/api/studio/get_studio_bundle.ts @@ -0,0 +1,62 @@ +import { asyncRetry, linearDelay } from '../../../util/async_retry' +import { isRetryableError } from '../../network/is_retryable_error' +import fetch from 'cross-fetch' +import os from 'os' +import { agent } from '@packages/network' +import { PUBLIC_KEY_VERSION } from '../../constants' +import { createWriteStream } from 'fs' +import { verifySignatureFromFile } from '../../encryption' + +const pkg = require('@packages/root') +const _delay = linearDelay(500) + +export const getStudioBundle = async ({ studioUrl, projectId, bundlePath }: { studioUrl: string, projectId?: string, bundlePath: string }) => { + let responseSignature: string | null = null + + await (asyncRetry(async () => { + const response = await fetch(studioUrl, { + // @ts-expect-error - this is supported + agent, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': PUBLIC_KEY_VERSION, + 'x-os-name': os.platform(), + 'x-cypress-version': pkg.version, + }, + encrypt: 'signed', + }) + + if (!response.ok) { + throw new Error(`Failed to download studio bundle: ${response.statusText}`) + } + + responseSignature = response.headers.get('x-cypress-signature') + + await new Promise((resolve, reject) => { + const writeStream = createWriteStream(bundlePath) + + writeStream.on('error', reject) + writeStream.on('finish', () => { + resolve() + }) + + // @ts-expect-error - this is supported + response.body?.pipe(writeStream) + }) + }, { + maxAttempts: 3, + retryDelay: _delay, + shouldRetry: isRetryableError, + }))() + + if (!responseSignature) { + throw new Error('Unable to get studio signature') + } + + const verified = await verifySignatureFromFile(bundlePath, responseSignature) + + if (!verified) { + throw new Error('Unable to verify studio signature') + } +} diff --git a/packages/server/lib/cloud/api/studio/post_studio_session.ts b/packages/server/lib/cloud/api/studio/post_studio_session.ts index 21ad20150f2d..7ec93b306cc4 100644 --- a/packages/server/lib/cloud/api/studio/post_studio_session.ts +++ b/packages/server/lib/cloud/api/studio/post_studio_session.ts @@ -7,13 +7,13 @@ import { agent } from '@packages/network' const pkg = require('@packages/root') const routes = require('../../routes') as typeof import('../../routes') -interface GetStudioSessionOptions { +interface PostStudioSessionOptions { projectId?: string } const _delay = linearDelay(500) -export const postStudioSession = async ({ projectId }: GetStudioSessionOptions) => { +export const postStudioSession = async ({ projectId }: PostStudioSessionOptions) => { return await (asyncRetry(async () => { const response = await fetch(routes.apiRoutes.studioSession(), { // @ts-expect-error - this is supported diff --git a/packages/server/lib/cloud/get_cloud_metadata.ts b/packages/server/lib/cloud/get_cloud_metadata.ts new file mode 100644 index 000000000000..805ce5ca5b71 --- /dev/null +++ b/packages/server/lib/cloud/get_cloud_metadata.ts @@ -0,0 +1,12 @@ +import { CloudDataSource } from '@packages/data-context/src/sources' + +export const getCloudMetadata = async (cloudDataSource: CloudDataSource) => { + 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() + + return { + cloudUrl, + cloudHeaders, + } +} diff --git a/packages/server/lib/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts similarity index 62% rename from packages/server/lib/StudioLifecycleManager.ts rename to packages/server/lib/cloud/studio/StudioLifecycleManager.ts index fbc1157ff55f..c813fda2f9fb 100644 --- a/packages/server/lib/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -1,23 +1,30 @@ -import type { StudioManager } from './cloud/studio' -import { ProtocolManager } from './cloud/protocol' -import { getAndInitializeStudioManager } from './cloud/api/studio/get_and_initialize_studio_manager' +import { StudioManager } from './studio' +import { ProtocolManager } from '../protocol' import Debug from 'debug' import type { CloudDataSource } from '@packages/data-context/src/sources' -import type { Cfg } from './project-base' +import type { Cfg } from '../../project-base' import _ from 'lodash' import type { DataContext } from '@packages/data-context' -import api from './cloud/api' -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' +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' +import path from 'path' +import os from 'os' +import { ensureStudioBundle } from './ensure_studio_bundle' +import chokidar from 'chokidar' +import { readFile } from 'fs/promises' +import { getCloudMetadata } from '../get_cloud_metadata' const debug = Debug('cypress:server:studio-lifecycle-manager') -const routes = require('./cloud/routes') +const routes = require('../routes') export class StudioLifecycleManager { + private static hashLoadingMap: Map> = new Map() + private static watcher: chokidar.FSWatcher | null = null private studioManagerPromise?: Promise private studioManager?: StudioManager private listeners: ((studioManager: StudioManager) => void)[] = [] @@ -50,6 +57,8 @@ export class StudioLifecycleManager { debugData: any ctx: DataContext }): void { + debug('Initializing studio manager') + // Register this instance in the data context ctx.update((data) => { data.studioLifecycleManager = this @@ -95,6 +104,33 @@ export class StudioLifecycleManager { }) this.studioManagerPromise = studioManagerPromise + + // If the studio bundle is local, we need to watch for changes to the bundle + // and reload the manager on file changes + if (process.env.CYPRESS_LOCAL_STUDIO_PATH) { + // Close the watcher if it already exists + if (StudioLifecycleManager.watcher) { + StudioLifecycleManager.watcher.close() + } + + // Watch for changes to the cy prompt + StudioLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'server', 'index.js'), { + awaitWriteFinish: true, + }).on('change', async () => { + await this.studioManager?.destroy() + this.studioManager = undefined + this.studioManagerPromise = this.createStudioManager({ + projectId, + cloudDataSource, + cfg, + debugData, + }).catch((error) => { + debug('Error during reload of studio manager: %o', error) + + return null + }) + }) + } } isStudioReady (): boolean { @@ -126,16 +162,56 @@ export class StudioLifecycleManager { cfg: Cfg debugData: any }): Promise { + let studioPath: string + let studioHash: string + const studioSession = await postStudioSession({ projectId, }) - const studioManager = await getAndInitializeStudioManager({ - studioUrl: studioSession.studioUrl, - projectId, - cloudDataSource, + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) { + // The studio hash is the last part of the studio URL, after the last slash and before the extension + studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0] + studioPath = path.join(os.tmpdir(), 'cypress', 'studio', studioHash) + + let hashLoadingPromise = StudioLifecycleManager.hashLoadingMap.get(studioHash) + + if (!hashLoadingPromise) { + hashLoadingPromise = ensureStudioBundle({ + studioUrl: studioSession.studioUrl, + studioPath, + projectId, + }) + + StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise) + } + + await hashLoadingPromise + } else { + studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH + studioHash = 'local' + } + + const serverFilePath = path.join(studioPath, 'server', 'index.js') + + const script = await readFile(serverFilePath, 'utf8') + const studioManager = new StudioManager() + + const { cloudUrl, cloudHeaders } = await getCloudMetadata(cloudDataSource) + + await studioManager.setup({ + script, + studioPath, + studioHash, + projectSlug: projectId, + cloudApi: { + cloudUrl, + cloudHeaders, + CloudRequest, + isRetryableError, + asyncRetry, + }, shouldEnableStudio: this.cloudStudioRequested, - lifecycleManager: this, }) if (studioManager.status === 'ENABLED') { @@ -183,7 +259,9 @@ export class StudioLifecycleManager { listener(studioManager) }) - this.listeners = [] + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) { + this.listeners = [] + } } /** @@ -195,6 +273,10 @@ export class StudioLifecycleManager { if (this.studioManager) { debug('Studio ready - calling listener immediately') listener(this.studioManager) + + if (process.env.CYPRESS_LOCAL_STUDIO_PATH) { + this.listeners.push(listener) + } } else { debug('Studio not ready - registering studio ready listener') this.listeners.push(listener) diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts new file mode 100644 index 000000000000..eb0c7ff8c15c --- /dev/null +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -0,0 +1,44 @@ +import { remove, ensureDir } from 'fs-extra' + +import tar from 'tar' +import { getStudioBundle } from '../api/studio/get_studio_bundle' +import path from 'path' + +interface EnsureStudioBundleOptions { + studioUrl: string + projectId?: string + studioPath: string + downloadTimeoutMs?: number +} + +const DOWNLOAD_TIMEOUT = 30000 + +export const ensureStudioBundle = async ({ studioUrl, projectId, studioPath, downloadTimeoutMs = DOWNLOAD_TIMEOUT }: EnsureStudioBundleOptions) => { + const bundlePath = path.join(studioPath, 'bundle.tar') + + // First remove cyPromptPath to ensure we have a clean slate + await remove(studioPath) + await ensureDir(studioPath) + + let timeoutId: NodeJS.Timeout + + await Promise.race([ + getStudioBundle({ + studioUrl, + projectId, + bundlePath, + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error('Studio bundle download timed out')) + }, downloadTimeoutMs) + }), + ]).finally(() => { + clearTimeout(timeoutId) + }) + + await tar.extract({ + file: bundlePath, + cwd: studioPath, + }) +} diff --git a/packages/server/lib/cloud/studio.ts b/packages/server/lib/cloud/studio/studio.ts similarity index 97% rename from packages/server/lib/cloud/studio.ts rename to packages/server/lib/cloud/studio/studio.ts index 090e6c1e1793..b095c9e8512c 100644 --- a/packages/server/lib/cloud/studio.ts +++ b/packages/server/lib/cloud/studio/studio.ts @@ -2,9 +2,9 @@ import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, Studio import type { Router } from 'express' import type { Socket } from 'socket.io' import Debug from 'debug' -import { requireScript } from './require_script' +import { requireScript } from '../require_script' import path from 'path' -import { reportStudioError, ReportStudioErrorOptions } from './api/studio/report_studio_error' +import { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error' interface StudioServer { default: StudioServerDefaultShape } diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 163342fb997a..66590adb74c3 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -24,7 +24,7 @@ import { ServerBase } from './server-base' import type Protocol from 'devtools-protocol' import type { ServiceWorkerClientEvent } from '@packages/proxy/lib/http/util/service-worker-manager' import { v4 } from 'uuid' -import { StudioLifecycleManager } from './StudioLifecycleManager' +import { StudioLifecycleManager } from './cloud/studio/StudioLifecycleManager' import { reportStudioError } from './cloud/api/studio/report_studio_error' import { CloudRequest } from './cloud/api/cloud_request' import { isRetryableError } from './cloud/network/is_retryable_error' diff --git a/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts new file mode 100644 index 000000000000..7b22a25897d2 --- /dev/null +++ b/packages/server/test/unit/cloud/api/studio/get_studio_bundle_spec.ts @@ -0,0 +1,232 @@ +import { sinon, proxyquire } from '../../../../spec_helper' +import { Readable, Writable } from 'stream' +import { HttpError } from '../../../../../lib/cloud/network/http_error' + +describe('getStudioBundle', () => { + let writeResult: string + let readStream: Readable + let createWriteStreamStub: sinon.SinonStub + let crossFetchStub: sinon.SinonStub + let verifySignatureFromFileStub: sinon.SinonStub + let getStudioBundle: typeof import('../../../../../lib/cloud/api/studio/get_studio_bundle').getStudioBundle + + beforeEach(() => { + createWriteStreamStub = sinon.stub() + crossFetchStub = sinon.stub() + verifySignatureFromFileStub = sinon.stub() + readStream = Readable.from('console.log("studio bundle")') + + writeResult = '' + const writeStream = new Writable({ + write: (chunk, encoding, callback) => { + writeResult += chunk.toString() + callback() + }, + }) + + createWriteStreamStub.returns(writeStream) + + getStudioBundle = proxyquire('../lib/cloud/api/studio/get_studio_bundle', { + 'fs': { + createWriteStream: createWriteStreamStub, + }, + 'cross-fetch': crossFetchStub, + '../../encryption': { + verifySignatureFromFile: verifySignatureFromFileStub, + }, + 'os': { + platform: () => 'linux', + }, + '@packages/root': { + version: '1.2.3', + }, + }).getStudioBundle + }) + + it('downloads the studio bundle and extracts it', async () => { + crossFetchStub.resolves({ + ok: true, + statusText: 'OK', + body: readStream, + headers: { + get: (header) => { + if (header === 'x-cypress-signature') { + return '159' + } + }, + }, + }) + + verifySignatureFromFileStub.resolves(true) + + const projectId = '12345' + + await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + + expect(writeResult).to.eq('console.log("studio bundle")') + + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') + }) + + it('downloads the studio bundle and extracts it after 1 fetch failure', async () => { + crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub())) + crossFetchStub.onSecondCall().resolves({ + ok: true, + statusText: 'OK', + body: readStream, + headers: { + get: (header) => { + if (header === 'x-cypress-signature') { + return '159' + } + }, + }, + }) + + verifySignatureFromFileStub.resolves(true) + + const projectId = '12345' + + await getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' }) + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + + expect(writeResult).to.eq('console.log("studio bundle")') + + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') + }) + + it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => { + const error = new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub()) + + crossFetchStub.rejects(error) + + const projectId = '12345' + + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + + expect(crossFetchStub).to.be.calledThrice + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + }) + + it('throws an error and returns a studio manager in error state if the response status is not ok', async () => { + crossFetchStub.resolves({ + ok: false, + statusText: 'Some failure', + }) + + const projectId = '12345' + + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + }) + + it('throws an error and returns a studio manager in error state if the signature verification fails', async () => { + verifySignatureFromFileStub.resolves(false) + + crossFetchStub.resolves({ + ok: true, + statusText: 'OK', + body: readStream, + headers: { + get: (header) => { + if (header === 'x-cypress-signature') { + return '159' + } + }, + }, + }) + + verifySignatureFromFileStub.resolves(false) + + const projectId = '12345' + + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + + expect(writeResult).to.eq('console.log("studio bundle")') + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + + expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/abc/bundle.tar', '159') + }) + + it('throws an error if there is no signature in the response headers', async () => { + crossFetchStub.resolves({ + ok: true, + statusText: 'OK', + body: readStream, + headers: { + get: () => null, + }, + }) + + const projectId = '12345' + + await expect(getStudioBundle({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, bundlePath: '/tmp/cypress/studio/abc/bundle.tar' })).to.be.rejected + + expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { + agent: sinon.match.any, + method: 'GET', + headers: { + 'x-route-version': '1', + 'x-cypress-signature': '1', + 'x-os-name': 'linux', + 'x-cypress-version': '1.2.3', + }, + encrypt: 'signed', + }) + }) +}) diff --git a/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts b/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts new file mode 100644 index 000000000000..14c9d163a2fb --- /dev/null +++ b/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts @@ -0,0 +1,60 @@ +import { sinon } from '../../spec_helper' +import { CloudDataSource } from '@packages/data-context/src/sources' +import { getCloudMetadata } from '../../../lib/cloud/get_cloud_metadata' + +describe('getCloudMetadata', () => { + let mockCloudDataSource: CloudDataSource + + beforeEach(() => { + mockCloudDataSource = { + getCloudUrl: sinon.stub().returns('https://cloud.cypress.io'), + additionalHeaders: sinon.stub().resolves({ 'x-cypress-cloud-header': 'test' }), + } as unknown as CloudDataSource + }) + + afterEach(() => { + delete process.env.CYPRESS_CONFIG_ENV + delete process.env.CYPRESS_INTERNAL_ENV + }) + + it('should return the cloud metadata based on the cypress cloud config', async () => { + process.env.CYPRESS_CONFIG_ENV = 'staging' + process.env.CYPRESS_INTERNAL_ENV = 'development' + + const cloudMetadata = await getCloudMetadata(mockCloudDataSource) + + expect(mockCloudDataSource.getCloudUrl).to.have.been.calledWith('staging') + expect(mockCloudDataSource.additionalHeaders).to.have.been.called + expect(cloudMetadata).to.deep.equal({ + cloudUrl: 'https://cloud.cypress.io', + cloudHeaders: { 'x-cypress-cloud-header': 'test' }, + }) + }) + + it('should return the cloud metadata based on the cypress internal config', async () => { + process.env.CYPRESS_INTERNAL_ENV = 'development' + + const cloudMetadata = await getCloudMetadata(mockCloudDataSource) + + expect(mockCloudDataSource.getCloudUrl).to.have.been.calledWith('development') + expect(mockCloudDataSource.additionalHeaders).to.have.been.called + expect(cloudMetadata).to.deep.equal({ + cloudUrl: 'https://cloud.cypress.io', + cloudHeaders: { 'x-cypress-cloud-header': 'test' }, + }) + }) + + it('should return the cloud metadata based on the default environment', async () => { + process.env.CYPRESS_CONFIG_ENV = 'production' + process.env.CYPRESS_INTERNAL_ENV = 'production' + + const cloudMetadata = await getCloudMetadata(mockCloudDataSource) + + expect(mockCloudDataSource.getCloudUrl).to.have.been.calledWith('production') + expect(mockCloudDataSource.additionalHeaders).to.have.been.called + expect(cloudMetadata).to.deep.equal({ + cloudUrl: 'https://cloud.cypress.io', + cloudHeaders: { 'x-cypress-cloud-header': 'test' }, + }) + }) +}) diff --git a/packages/server/test/unit/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts similarity index 55% rename from packages/server/test/unit/StudioLifecycleManager_spec.ts rename to packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index 3022ecba01d9..102157cefa1f 100644 --- a/packages/server/test/unit/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -1,15 +1,18 @@ -import { sinon } from '../spec_helper' +import { sinon, proxyquire } from '../../../spec_helper' import { expect } from 'chai' -import { StudioManager } from '../../lib/cloud/studio' -import { StudioLifecycleManager } from '../../lib/StudioLifecycleManager' +import { StudioManager } from '../../../../lib/cloud/studio/studio' +import { StudioLifecycleManager } from '../../../../lib/cloud/studio/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' +import path from 'path' +import os from 'os' +import { CloudRequest } from '../../../../lib/cloud/api/cloud_request' +import { isRetryableError } from '../../../../lib/cloud/network/is_retryable_error' +import { asyncRetry } from '../../../../lib/util/async_retry' +import { Cfg } from '../../../../lib/project-base' +import api from '../../../../lib/cloud/api' +import ProtocolManager from '../../../../lib/cloud/protocol' +import * as reportStudioErrorPath from '../../../../lib/cloud/api/studio/report_studio_error' // Helper to wait for next tick in event loop const nextTick = () => new Promise((resolve) => process.nextTick(resolve)) @@ -19,22 +22,76 @@ describe('StudioLifecycleManager', () => { let mockStudioManager: StudioManager let mockCtx: DataContext let mockCloudDataSource: CloudDataSource - let mockCfg: Cfg + let StudioLifecycleManager: typeof import('../../../../lib/cloud/studio/StudioLifecycleManager').StudioLifecycleManager let postStudioSessionStub: sinon.SinonStub - let getAndInitializeStudioManagerStub: sinon.SinonStub - let getCaptureProtocolScriptStub: sinon.SinonStub + let studioStatusChangeEmitterStub: sinon.SinonStub + let ensureStudioBundleStub: sinon.SinonStub + let studioManagerSetupStub: sinon.SinonStub = sinon.stub() + let readFileStub: sinon.SinonStub = sinon.stub() + let mockCfg: Cfg let prepareProtocolStub: sinon.SinonStub let reportStudioErrorStub: sinon.SinonStub - let studioStatusChangeEmitterStub: sinon.SinonStub + let getCaptureProtocolScriptStub: sinon.SinonStub + let watcherStub: sinon.SinonStub + let watcherOnStub: sinon.SinonStub + let watcherCloseStub: sinon.SinonStub + let studioManagerDestroyStub: sinon.SinonStub beforeEach(() => { - studioLifecycleManager = new StudioLifecycleManager() + postStudioSessionStub = sinon.stub() + studioManagerSetupStub = sinon.stub() + ensureStudioBundleStub = sinon.stub() + studioStatusChangeEmitterStub = sinon.stub() + prepareProtocolStub = sinon.stub() + reportStudioErrorStub = sinon.stub() + getCaptureProtocolScriptStub = sinon.stub() + watcherStub = sinon.stub() + watcherOnStub = sinon.stub() + watcherCloseStub = sinon.stub() + studioManagerDestroyStub = sinon.stub() mockStudioManager = { - addSocketListeners: sinon.stub(), - canAccessStudioAI: sinon.stub().resolves(true), status: 'INITIALIZED', + setup: studioManagerSetupStub.resolves(), + destroy: studioManagerDestroyStub.resolves(), } as unknown as StudioManager + readFileStub = sinon.stub() + StudioLifecycleManager = proxyquire('../lib/cloud/studio/StudioLifecycleManager', { + './ensure_studio_bundle': { + ensureStudioBundle: ensureStudioBundleStub, + }, + '../api/studio/post_studio_session': { + postStudioSession: postStudioSessionStub, + }, + './studio': { + StudioManager: class StudioManager { + constructor () { + return mockStudioManager + } + }, + }, + 'fs-extra': { + readFile: readFileStub.resolves('console.log("studio script")'), + }, + '../get_cloud_metadata': { + getCloudMetadata: sinon.stub().resolves({ + cloudUrl: 'https://cloud.cypress.io', + cloudHeaders: { 'Authorization': 'Bearer test-token' }, + }), + }, + 'fs/promises': { + readFile: readFileStub.resolves('console.log("studio script")'), + }, + 'chokidar': { + watch: watcherStub.returns({ + on: watcherOnStub, + close: watcherCloseStub, + }), + }, + }).StudioLifecycleManager + + studioLifecycleManager = new StudioLifecycleManager() + studioStatusChangeEmitterStub = sinon.stub() mockCtx = { @@ -49,7 +106,10 @@ describe('StudioLifecycleManager', () => { }, } as unknown as DataContext - mockCloudDataSource = {} as CloudDataSource + mockCloudDataSource = { + getCloudUrl: sinon.stub().returns('https://cloud.cypress.io'), + additionalHeaders: sinon.stub().resolves({ 'Authorization': 'Bearer test-token' }), + } as CloudDataSource mockCfg = { projectId: 'abc123', @@ -61,23 +121,21 @@ 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) - getCaptureProtocolScriptStub = sinon.stub(api, 'getCaptureProtocolScript').resolves('console.log("hello")') prepareProtocolStub = sinon.stub(ProtocolManager.prototype, 'prepareProtocol').resolves() - reportStudioErrorStub = sinon.stub(reportStudioErrorPath, 'reportStudioError') + reportStudioErrorStub = sinon.stub(reportStudioErrorPath, 'reportStudioError').resolves() }) afterEach(() => { sinon.restore() + + delete process.env.CYPRESS_LOCAL_STUDIO_PATH }) describe('cloudStudioRequested', () => { @@ -111,13 +169,19 @@ describe('StudioLifecycleManager', () => { }) describe('initializeStudioManager', () => { - it('initializes the studio manager and registers it in the data context', async () => { + it('initializes the studio manager and registers it in the data context and does not set up protocol when studio is initialized', async () => { + studioManagerSetupStub.callsFake((args) => { + mockStudioManager.status = 'INITIALIZED' + + return Promise.resolve() + }) + studioLifecycleManager.initializeStudioManager({ projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, + ctx: mockCtx, cfg: mockCfg, debugData: {}, - ctx: mockCtx, }) const studioReadyPromise = new Promise((resolve) => { @@ -129,18 +193,50 @@ describe('StudioLifecycleManager', () => { await studioReadyPromise expect(mockCtx.update).to.be.calledOnce - expect(studioLifecycleManager.isStudioReady()).to.be.true + expect(ensureStudioBundleStub).to.be.calledWith({ + studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'), + studioUrl: 'https://cloud.cypress.io/studio/bundle/abc.tgz', + projectId: 'test-project-id', + }) + + expect(studioManagerSetupStub).to.be.calledWith({ + script: 'console.log("studio script")', + studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'), + studioHash: 'abc', + projectSlug: 'test-project-id', + cloudApi: { + cloudUrl: 'https://cloud.cypress.io', + cloudHeaders: { 'Authorization': 'Bearer test-token' }, + CloudRequest, + isRetryableError, + asyncRetry, + }, + shouldEnableStudio: false, + }) + + expect(postStudioSessionStub).to.be.calledWith({ + projectId: 'test-project-id', + }) + + expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8') + + expect(getCaptureProtocolScriptStub).not.to.be.called + expect(prepareProtocolStub).not.to.be.called }) - it('sets up protocol if studio is enabled', async () => { - mockStudioManager.status = 'ENABLED' + it('initializes the studio manager and registers it in the data context and sets up protocol when studio is enabled', async () => { + studioManagerSetupStub.callsFake((args) => { + mockStudioManager.status = 'ENABLED' + + return Promise.resolve() + }) studioLifecycleManager.initializeStudioManager({ - projectId: 'abc123', + projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, + ctx: mockCtx, cfg: mockCfg, debugData: {}, - ctx: mockCtx, }) const studioReadyPromise = new Promise((resolve) => { @@ -151,10 +247,105 @@ describe('StudioLifecycleManager', () => { await studioReadyPromise + expect(mockCtx.update).to.be.calledOnce + expect(ensureStudioBundleStub).to.be.calledWith({ + studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'), + studioUrl: 'https://cloud.cypress.io/studio/bundle/abc.tgz', + projectId: 'test-project-id', + }) + + expect(studioManagerSetupStub).to.be.calledWith({ + script: 'console.log("studio script")', + studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'), + studioHash: 'abc', + projectSlug: 'test-project-id', + cloudApi: { + cloudUrl: 'https://cloud.cypress.io', + cloudHeaders: { 'Authorization': 'Bearer test-token' }, + CloudRequest, + isRetryableError, + asyncRetry, + }, + shouldEnableStudio: false, + }) + expect(postStudioSessionStub).to.be.calledWith({ + projectId: 'test-project-id', + }) + + expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8') + + 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('initializes the studio manager in watch mode when CYPRESS_LOCAL_STUDIO_PATH is set', async () => { + process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio' + + studioManagerSetupStub.callsFake((args) => { + mockStudioManager.status = 'ENABLED' + + return Promise.resolve() }) + studioLifecycleManager.initializeStudioManager({ + projectId: 'test-project-id', + cloudDataSource: mockCloudDataSource, + ctx: mockCtx, + cfg: mockCfg, + debugData: {}, + }) + + const studioReadyPromise = new Promise((resolve) => { + studioLifecycleManager?.registerStudioReadyListener((studioManager) => { + resolve(studioManager) + }) + }) + + await studioReadyPromise + + expect(mockCtx.update).to.be.calledOnce + expect(ensureStudioBundleStub).not.to.be.called + + expect(studioManagerSetupStub).to.be.calledWith({ + script: 'console.log("studio script")', + studioPath: '/path/to/studio', + studioHash: 'local', + projectSlug: 'test-project-id', + cloudApi: { + cloudUrl: 'https://cloud.cypress.io', + cloudHeaders: { 'Authorization': 'Bearer test-token' }, + CloudRequest, + isRetryableError, + asyncRetry, + }, + shouldEnableStudio: true, + }) + + expect(postStudioSessionStub).to.be.calledWith({ + projectId: 'test-project-id', + }) + + expect(readFileStub).to.be.calledWith(path.join('/path', 'to', 'studio', 'server', 'index.js'), 'utf8') + expect(getCaptureProtocolScriptStub).to.be.calledWith('https://cloud.cypress.io/capture-protocol/script/def.js') expect(prepareProtocolStub).to.be.calledWith('console.log("hello")', { runId: 'studio', @@ -175,37 +366,66 @@ describe('StudioLifecycleManager', () => { debugData: {}, mode: 'studio', }) + + expect(StudioLifecycleManager['watcher']).to.be.present + expect(watcherStub).to.be.calledWith(path.join('/path', 'to', 'studio', 'server', 'index.js'), { + awaitWriteFinish: true, + }) + + expect(watcherOnStub).to.be.calledWith('change') + + const onCallback = watcherOnStub.args[0][1] + + let mockStudioManagerPromise: Promise + const updatedStudioManager = { + status: 'ENABLED', + destroy: studioManagerDestroyStub, + } as unknown as StudioManager + + studioLifecycleManager['createStudioManager'] = sinon.stub().callsFake(() => { + mockStudioManagerPromise = new Promise((resolve) => { + resolve(updatedStudioManager) + }) + + return mockStudioManagerPromise + }) + + await onCallback() + + expect(studioManagerDestroyStub).to.be.called + + expect(mockStudioManagerPromise).to.be.present + expect(await mockStudioManagerPromise).to.equal(updatedStudioManager) }) - it('handles errors during initialization and reports them', async () => { + it('handles errors when initializing the studio manager 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 + // Register listerns 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) + ensureStudioBundleStub.rejects(error) const reportErrorPromise = new Promise((resolve) => { - reportStudioErrorStub.callsFake(() => { + reportStudioErrorStub.callsFake((err) => { resolve() return undefined }) }) - // Should not throw - studioLifecycleManager.initializeStudioManager({ + await studioLifecycleManager.initializeStudioManager({ projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, + ctx: mockCtx, cfg: mockCfg, debugData: {}, - ctx: mockCtx, }) await reportErrorPromise @@ -293,15 +513,28 @@ describe('StudioLifecycleManager', () => { // @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('calls listener immediately and adds to the list of listeners when CYPRESS_LOCAL_STUDIO_PATH is set', async () => { + process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio' + + const listener = sinon.stub() + + // @ts-expect-error - accessing private property + studioLifecycleManager.studioManager = mockStudioManager + + // @ts-expect-error - accessing non-existent property + studioLifecycleManager.studioReady = true + + studioLifecycleManager.registerStudioReadyListener(listener) expect(listener).to.be.calledWith(mockStudioManager) + + // @ts-expect-error - accessing private property + expect(studioLifecycleManager.listeners).to.include(listener) }) it('does not call listener if studio manager is null', async () => { @@ -315,11 +548,6 @@ describe('StudioLifecycleManager', () => { 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 }) @@ -358,21 +586,57 @@ describe('StudioLifecycleManager', () => { studioLifecycleManager.initializeStudioManager({ projectId: 'test-project-id', cloudDataSource: mockCloudDataSource, + ctx: mockCtx, 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) }) + + it('does not clean up listeners when CYPRESS_LOCAL_STUDIO_PATH is set', async () => { + process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio' + + 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, + ctx: mockCtx, + cfg: mockCfg, + debugData: {}, + }) + + await listenersCalledPromise + + expect(listener1).to.be.calledWith(mockStudioManager) + expect(listener2).to.be.calledWith(mockStudioManager) + + // @ts-expect-error - accessing private property + expect(studioLifecycleManager.listeners.length).to.equal(2) + }) }) describe('status tracking', () => { @@ -443,7 +707,7 @@ describe('StudioLifecycleManager', () => { }) it('updates status to IN_ERROR when initialization fails', async () => { - getAndInitializeStudioManagerStub.rejects(new Error('Test error')) + ensureStudioBundleStub.rejects(new Error('Test error')) const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus') diff --git a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts new file mode 100644 index 000000000000..3d3124a8a8c0 --- /dev/null +++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts @@ -0,0 +1,66 @@ +import path from 'path' +import os from 'os' +import { proxyquire, sinon } from '../../../spec_helper' + +describe('ensureStudioBundle', () => { + let ensureStudioBundle: typeof import('../../../../lib/cloud/studio/ensure_studio_bundle').ensureStudioBundle + 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 getStudioBundleStub: sinon.SinonStub = sinon.stub() + + beforeEach(() => { + rmStub = sinon.stub() + ensureStub = sinon.stub() + copyStub = sinon.stub() + readFileStub = sinon.stub() + extractStub = sinon.stub() + getStudioBundleStub = sinon.stub() + + ensureStudioBundle = (proxyquire('../lib/cloud/studio/ensure_studio_bundle', { + os: { + tmpdir: () => tmpdir, + platform: () => 'linux', + }, + 'fs-extra': { + remove: rmStub.resolves(), + ensureDir: ensureStub.resolves(), + copy: copyStub.resolves(), + readFile: readFileStub.resolves('console.log("studio bundle")'), + }, + tar: { + extract: extractStub.resolves(), + }, + '../api/studio/get_studio_bundle': { + getStudioBundle: getStudioBundleStub.resolves(), + }, + })).ensureStudioBundle + }) + + it('should ensure the studio bundle', async () => { + const studioPath = path.join(os.tmpdir(), 'cypress', 'studio', '123') + const bundlePath = path.join(studioPath, 'bundle.tar') + + await ensureStudioBundle({ + studioPath, + studioUrl: 'https://cypress.io/studio', + projectId: '123', + }) + + expect(rmStub).to.be.calledWith(studioPath) + expect(ensureStub).to.be.calledWith(studioPath) + expect(getStudioBundleStub).to.be.calledWith({ + studioUrl: 'https://cypress.io/studio', + projectId: '123', + bundlePath, + }) + + expect(extractStub).to.be.calledWith({ + file: bundlePath, + cwd: studioPath, + }) + }) +}) diff --git a/packages/server/test/unit/cloud/studio_spec.ts b/packages/server/test/unit/cloud/studio/studio_spec.ts similarity index 92% rename from packages/server/test/unit/cloud/studio_spec.ts rename to packages/server/test/unit/cloud/studio/studio_spec.ts index 162bcf922577..0261119d46b4 100644 --- a/packages/server/test/unit/cloud/studio_spec.ts +++ b/packages/server/test/unit/cloud/studio/studio_spec.ts @@ -1,13 +1,13 @@ -import { proxyquire, sinon } from '../../spec_helper' +import { proxyquire, sinon } from '../../../spec_helper' import path from 'path' import type { StudioServerShape } from '@packages/types' import { expect } from 'chai' import esbuild from 'esbuild' -import type { StudioManager as StudioManagerShape } from '@packages/server/lib/cloud/studio' +import type { StudioManager as StudioManagerShape } from '@packages/server/lib/cloud/studio/studio' import os from 'os' const { outputFiles: [{ contents: stubStudioRaw }] } = esbuild.buildSync({ - entryPoints: [path.join(__dirname, '..', '..', 'support', 'fixtures', 'cloud', 'studio', 'test-studio.ts')], + entryPoints: [path.join(__dirname, '..', '..', '..', 'support', 'fixtures', 'cloud', 'studio', 'test-studio.ts')], bundle: true, format: 'cjs', write: false, @@ -18,16 +18,16 @@ const stubStudio = new TextDecoder('utf-8').decode(stubStudioRaw) describe('lib/cloud/studio', () => { let studioManager: StudioManagerShape let studio: StudioServerShape - let StudioManager: typeof import('@packages/server/lib/cloud/studio').StudioManager + let StudioManager: typeof import('@packages/server/lib/cloud/studio/studio').StudioManager let reportStudioError: sinon.SinonStub beforeEach(async () => { reportStudioError = sinon.stub() - StudioManager = (proxyquire('../lib/cloud/studio', { - './api/studio/report_studio_error': { + StudioManager = (proxyquire('../lib/cloud/studio/studio', { + '../api/studio/report_studio_error': { reportStudioError, }, - }) as typeof import('@packages/server/lib/cloud/studio')).StudioManager + }) as typeof import('@packages/server/lib/cloud/studio/studio')).StudioManager studioManager = new StudioManager() await studioManager.setup({ diff --git a/packages/server/test/unit/project_spec.js b/packages/server/test/unit/project_spec.js index 569e025b9182..a855032e4786 100644 --- a/packages/server/test/unit/project_spec.js +++ b/packages/server/test/unit/project_spec.js @@ -14,10 +14,9 @@ const savedState = require(`../../lib/saved_state`) const runEvents = require(`../../lib/plugins/run_events`) const system = require(`../../lib/util/system`) const { getCtx } = require(`../../lib/makeDataContext`) -const studio = require('../../lib/cloud/api/studio/get_and_initialize_studio_manager') const browsers = require('../../lib/browsers') -const { StudioLifecycleManager } = require('../../lib/StudioLifecycleManager') -const { StudioManager } = require('../../lib/cloud/studio') +const { StudioLifecycleManager } = require('../../lib/cloud/studio/StudioLifecycleManager') +const { StudioManager } = require('../../lib/cloud/studio/studio') let ctx @@ -47,8 +46,6 @@ describe('lib/project-base', () => { destroy: () => Promise.resolve(), } - sinon.stub(studio, 'getAndInitializeStudioManager').resolves(this.testStudioManager) - await ctx.actions.project.setCurrentProjectAndTestingTypeForTestSetup(this.todosPath) this.config = await ctx.project.getConfig() diff --git a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts index f00c1b1a46ae..f9985076eabd 100644 --- a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts +++ b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts @@ -2,21 +2,55 @@ process.env.CYPRESS_INTERNAL_ENV = process.env.CYPRESS_INTERNAL_ENV ?? 'producti import path from 'path' import fs from 'fs-extra' -import { retrieveAndExtractStudioBundle, studioPath } from '@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager' import { postStudioSession } from '@packages/server/lib/cloud/api/studio/post_studio_session' +import os from 'os' +import chokidar from 'chokidar' +import { ensureStudioBundle } from '@packages/server/lib/cloud/studio/ensure_studio_bundle' export const downloadStudioTypes = async (): Promise => { - const studioSession = await postStudioSession({ projectId: 'ypt4pf' }) + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) { + const studioSession = await postStudioSession({ projectId: 'ypt4pf' }) + // The studio hash is the last part of the studio URL, after the last slash and before the extension + const studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0] + const studioPath = path.join(os.tmpdir(), 'cypress', 'studio', studioHash) - await retrieveAndExtractStudioBundle({ studioUrl: studioSession.studioUrl, projectId: 'ypt4pf' }) + await ensureStudioBundle({ studioUrl: studioSession.studioUrl, studioPath, projectId: 'ypt4pf' }) - await fs.copyFile( - path.join(studioPath, 'app', 'types.ts'), - path.join(__dirname, '..', '..', '..', 'packages', 'app', 'src', 'studio', 'studio-app-types.ts'), - ) + await fs.copyFile( + path.join(studioPath, 'app', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'app', 'src', 'studio', 'studio-app-types.ts'), + ) - await fs.copyFile( - path.join(studioPath, 'server', 'types.ts'), - path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'studio', 'studio-server-types.ts'), - ) + await fs.copyFile( + path.join(studioPath, 'server', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'studio', 'studio-server-types.ts'), + ) + } else { + const copyAppTypes = async () => { + await fs.copyFile( + path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH!, 'app', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'app', 'src', 'studio', 'studio-app-types.ts'), + ) + } + const copyServerTypes = async () => { + await fs.copyFile( + path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH!, 'server', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'studio', 'studio-server-types.ts'), + ) + } + + const appWatcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'app', 'types.ts'), { + awaitWriteFinish: true, + }) + + appWatcher.on('ready', copyAppTypes) + appWatcher.on('change', copyAppTypes) + + const serverWatcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'server', 'types.ts'), { + awaitWriteFinish: true, + }) + + serverWatcher.on('ready', copyServerTypes) + serverWatcher.on('change', copyServerTypes) + } } From 472bbb2b0df01f9b162790b99b682d4a67c33c6f Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 16:41:08 -0500 Subject: [PATCH 02/15] fix types --- packages/server/lib/cloud/get_cloud_metadata.ts | 2 +- packages/server/lib/cloud/studio/StudioLifecycleManager.ts | 4 +--- packages/server/lib/project-base.ts | 5 ++--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/server/lib/cloud/get_cloud_metadata.ts b/packages/server/lib/cloud/get_cloud_metadata.ts index 805ce5ca5b71..9bf3fa3cd7ff 100644 --- a/packages/server/lib/cloud/get_cloud_metadata.ts +++ b/packages/server/lib/cloud/get_cloud_metadata.ts @@ -1,4 +1,4 @@ -import { CloudDataSource } from '@packages/data-context/src/sources' +import type { CloudDataSource } from '@packages/data-context/src/sources' export const getCloudMetadata = async (cloudDataSource: CloudDataSource) => { const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production' diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index c813fda2f9fb..ffe7f9516a9a 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -76,9 +76,7 @@ export class StudioLifecycleManager { }).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() + const { cloudUrl, cloudHeaders } = await getCloudMetadata(cloudDataSource) reportStudioError({ cloudApi: { diff --git a/packages/server/lib/project-base.ts b/packages/server/lib/project-base.ts index 66590adb74c3..67c6ec14175d 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -29,6 +29,7 @@ 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 { getCloudMetadata } from './cloud/get_cloud_metadata' export interface Cfg extends ReceivedCypressOptions { projectId?: string @@ -407,9 +408,7 @@ export class ProjectBase extends EE { if (!isStudioReady) { debug('User entered studio mode before cloud studio was initialized') - const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production' - const cloudUrl = this.ctx.cloud.getCloudUrl(cloudEnv) - const cloudHeaders = await this.ctx.cloud.additionalHeaders() + const { cloudUrl, cloudHeaders } = await getCloudMetadata(this.ctx.cloud) reportStudioError({ cloudApi: { From 75c2bb03b3c6430b81e2478d3abe3de2cdef6610 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 17:00:11 -0500 Subject: [PATCH 03/15] floating promise --- packages/server/lib/cloud/studio/StudioLifecycleManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index ffe7f9516a9a..25e2bebaeba0 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -108,7 +108,8 @@ export class StudioLifecycleManager { if (process.env.CYPRESS_LOCAL_STUDIO_PATH) { // Close the watcher if it already exists if (StudioLifecycleManager.watcher) { - StudioLifecycleManager.watcher.close() + // Nothing really to do if this fails and it's only in development + StudioLifecycleManager.watcher.close().catch(() => {}) } // Watch for changes to the cy prompt From b6a913725882c3dd95569bba1d05a7de09a51655 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 19:47:44 -0500 Subject: [PATCH 04/15] Update packages/server/test/unit/cloud/get_cloud_metadata_spec.ts --- packages/server/test/unit/cloud/get_cloud_metadata_spec.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts b/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts index 14c9d163a2fb..212a6369f753 100644 --- a/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts +++ b/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts @@ -45,8 +45,6 @@ describe('getCloudMetadata', () => { }) it('should return the cloud metadata based on the default environment', async () => { - process.env.CYPRESS_CONFIG_ENV = 'production' - process.env.CYPRESS_INTERNAL_ENV = 'production' const cloudMetadata = await getCloudMetadata(mockCloudDataSource) From 888f27cc99d5f4379ae7dd71b0b18d426f4c925d Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 20:12:31 -0500 Subject: [PATCH 05/15] fix tests --- .../get_and_initialize_studio_manager_spec.ts | 523 ------------------ .../studio/StudioLifecycleManager_spec.ts | 3 +- 2 files changed, 2 insertions(+), 524 deletions(-) delete mode 100644 packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts diff --git a/packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts b/packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts deleted file mode 100644 index 5866ef9c3d06..000000000000 --- a/packages/server/test/unit/cloud/api/studio/get_and_initialize_studio_manager_spec.ts +++ /dev/null @@ -1,523 +0,0 @@ -import { Readable, Writable } from 'stream' -import { proxyquire, sinon } from '../../../../spec_helper' -import { HttpError } from '../../../../../lib/cloud/network/http_error' -import { CloudRequest } from '../../../../../lib/cloud/api/cloud_request' -import { isRetryableError } from '../../../../../lib/cloud/network/is_retryable_error' -import { asyncRetry } from '../../../../../lib/util/async_retry' -import { CloudDataSource } from '@packages/data-context/src/sources' - -describe('getAndInitializeStudioManager', () => { - let getAndInitializeStudioManager: typeof import('@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager').getAndInitializeStudioManager - 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 crossFetchStub: sinon.SinonStub = sinon.stub() - let createReadStreamStub: sinon.SinonStub = sinon.stub() - let createWriteStreamStub: sinon.SinonStub = sinon.stub() - let verifySignatureFromFileStub: sinon.SinonStub = sinon.stub() - let extractStub: sinon.SinonStub = sinon.stub() - let createInErrorManagerStub: sinon.SinonStub = sinon.stub() - let tmpdir: string = '/tmp' - let studioManagerSetupStub: sinon.SinonStub = sinon.stub() - let originalEnv: NodeJS.ProcessEnv - - beforeEach(() => { - originalEnv = { ...process.env } - rmStub = sinon.stub() - ensureStub = sinon.stub() - copyStub = sinon.stub() - readFileStub = sinon.stub() - crossFetchStub = sinon.stub().resolves({ - ok: true, - statusText: 'OK', - }) - - createReadStreamStub = sinon.stub() - createWriteStreamStub = sinon.stub() - verifySignatureFromFileStub = sinon.stub() - extractStub = sinon.stub() - createInErrorManagerStub = sinon.stub() - studioManagerSetupStub = sinon.stub() - - getAndInitializeStudioManager = (proxyquire('../lib/cloud/api/studio/get_and_initialize_studio_manager', { - fs: { - createReadStream: createReadStreamStub, - createWriteStream: createWriteStreamStub, - }, - os: { - tmpdir: () => tmpdir, - platform: () => 'linux', - }, - 'fs-extra': { - remove: rmStub.resolves(), - ensureDir: ensureStub.resolves(), - copy: copyStub.resolves(), - readFile: readFileStub.resolves('console.log("studio script")'), - }, - tar: { - extract: extractStub.resolves(), - }, - '../../encryption': { - verifySignatureFromFile: verifySignatureFromFileStub, - }, - '../../studio': { - StudioManager: class StudioManager { - static createInErrorManager = createInErrorManagerStub - setup = (...options) => studioManagerSetupStub(...options) - }, - }, - 'cross-fetch': crossFetchStub, - '@packages/root': { - version: '1.2.3', - }, - }) as typeof import('@packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager')).getAndInitializeStudioManager - }) - - afterEach(() => { - process.env = originalEnv - sinon.restore() - }) - - describe('CYPRESS_LOCAL_STUDIO_PATH is set', () => { - beforeEach(() => { - process.env.CYPRESS_LOCAL_STUDIO_PATH = '/path/to/studio' - }) - - it('gets the studio bundle from the path specified in the environment variable', async () => { - const mockGetCloudUrl = sinon.stub() - const mockAdditionalHeaders = sinon.stub() - const cloud = { - getCloudUrl: mockGetCloudUrl, - additionalHeaders: mockAdditionalHeaders, - } as unknown as CloudDataSource - - mockGetCloudUrl.returns('http://localhost:1234') - mockAdditionalHeaders.resolves({ - a: 'b', - c: 'd', - }) - - await getAndInitializeStudioManager({ - studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', - projectId: '12345', - cloudDataSource: cloud, - shouldEnableStudio: true, - }) - - expect(rmStub).to.be.calledWith('/tmp/cypress/studio') - expect(ensureStub).to.be.calledWith('/tmp/cypress/studio') - expect(copyStub).to.be.calledWith('/path/to/studio/app', '/tmp/cypress/studio/app') - expect(copyStub).to.be.calledWith('/path/to/studio/server', '/tmp/cypress/studio/server') - expect(readFileStub).to.be.calledWith('/tmp/cypress/studio/server/index.js', 'utf8') - expect(studioManagerSetupStub).to.be.calledWithMatch({ - script: 'console.log("studio script")', - studioPath: '/tmp/cypress/studio', - studioHash: undefined, - projectSlug: '12345', - cloudApi: { - cloudUrl: 'http://localhost:1234', - cloudHeaders: { - a: 'b', - c: 'd', - }, - CloudRequest, - isRetryableError, - asyncRetry, - }, - }) - }) - }) - - describe('CYPRESS_LOCAL_STUDIO_PATH not set', () => { - let writeResult: string - let readStream: Readable - - beforeEach(() => { - readStream = Readable.from('console.log("studio script")') - - writeResult = '' - const writeStream = new Writable({ - write: (chunk, encoding, callback) => { - writeResult += chunk.toString() - callback() - }, - }) - - createWriteStreamStub.returns(writeStream) - createReadStreamStub.returns(Readable.from('tar contents')) - }) - - it('downloads the studio bundle and extracts it', async () => { - const mockGetCloudUrl = sinon.stub() - const mockAdditionalHeaders = sinon.stub() - const cloud = { - getCloudUrl: mockGetCloudUrl, - additionalHeaders: mockAdditionalHeaders, - } as unknown as CloudDataSource - - mockGetCloudUrl.returns('http://localhost:1234') - mockAdditionalHeaders.resolves({ - a: 'b', - c: 'd', - }) - - crossFetchStub.resolves({ - ok: true, - statusText: 'OK', - body: readStream, - headers: { - get: (header) => { - if (header === 'x-cypress-signature') { - return '159' - } - }, - }, - }) - - verifySignatureFromFileStub.resolves(true) - - const projectId = '12345' - - await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true }) - - expect(rmStub).to.be.calledWith('/tmp/cypress/studio') - expect(ensureStub).to.be.calledWith('/tmp/cypress/studio') - - expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { - agent: sinon.match.any, - method: 'GET', - headers: { - 'x-route-version': '1', - 'x-cypress-signature': '1', - 'x-cypress-project-slug': '12345', - 'x-cypress-studio-mount-version': '1', - 'x-os-name': 'linux', - 'x-cypress-version': '1.2.3', - }, - encrypt: 'signed', - }) - - expect(writeResult).to.eq('console.log("studio script")') - - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/bundle.tar', '159') - - expect(extractStub).to.be.calledWith({ - file: '/tmp/cypress/studio/bundle.tar', - cwd: '/tmp/cypress/studio', - }) - - expect(readFileStub).to.be.calledWith('/tmp/cypress/studio/server/index.js', 'utf8') - - expect(studioManagerSetupStub).to.be.calledWithMatch({ - script: 'console.log("studio script")', - studioPath: '/tmp/cypress/studio', - studioHash: 'abc', - }) - }) - - it('downloads the studio bundle and extracts it after 1 fetch failure', async () => { - const mockGetCloudUrl = sinon.stub() - const mockAdditionalHeaders = sinon.stub() - const cloud = { - getCloudUrl: mockGetCloudUrl, - additionalHeaders: mockAdditionalHeaders, - } as unknown as CloudDataSource - - mockGetCloudUrl.returns('http://localhost:1234') - mockAdditionalHeaders.resolves({ - a: 'b', - c: 'd', - }) - - crossFetchStub.onFirstCall().rejects(new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub())) - crossFetchStub.onSecondCall().resolves({ - ok: true, - statusText: 'OK', - body: readStream, - headers: { - get: (header) => { - if (header === 'x-cypress-signature') { - return '159' - } - }, - }, - }) - - verifySignatureFromFileStub.resolves(true) - - const projectId = '12345' - - await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true }) - - expect(rmStub).to.be.calledWith('/tmp/cypress/studio') - expect(ensureStub).to.be.calledWith('/tmp/cypress/studio') - - expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { - agent: sinon.match.any, - method: 'GET', - headers: { - 'x-route-version': '1', - 'x-cypress-signature': '1', - 'x-cypress-project-slug': '12345', - 'x-cypress-studio-mount-version': '1', - 'x-os-name': 'linux', - 'x-cypress-version': '1.2.3', - }, - encrypt: 'signed', - }) - - expect(writeResult).to.eq('console.log("studio script")') - - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/bundle.tar', '159') - - expect(extractStub).to.be.calledWith({ - file: '/tmp/cypress/studio/bundle.tar', - cwd: '/tmp/cypress/studio', - }) - - expect(readFileStub).to.be.calledWith('/tmp/cypress/studio/server/index.js', 'utf8') - - expect(studioManagerSetupStub).to.be.calledWithMatch({ - script: 'console.log("studio script")', - studioPath: '/tmp/cypress/studio', - studioHash: 'abc', - }) - }) - - it('throws an error and returns a studio manager in error state if the fetch fails more than twice', async () => { - const mockGetCloudUrl = sinon.stub() - const mockAdditionalHeaders = sinon.stub() - const cloud = { - getCloudUrl: mockGetCloudUrl, - additionalHeaders: mockAdditionalHeaders, - } as unknown as CloudDataSource - - mockGetCloudUrl.returns('http://localhost:1234') - mockAdditionalHeaders.resolves({ - a: 'b', - c: 'd', - }) - - const error = new HttpError('Failed to fetch', 'url', 502, 'Bad Gateway', 'Bad Gateway', sinon.stub()) - - crossFetchStub.rejects(error) - - const projectId = '12345' - - await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true }) - - expect(rmStub).to.be.calledWith('/tmp/cypress/studio') - expect(ensureStub).to.be.calledWith('/tmp/cypress/studio') - - expect(crossFetchStub).to.be.calledThrice - expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { - agent: sinon.match.any, - method: 'GET', - headers: { - 'x-route-version': '1', - 'x-cypress-signature': '1', - 'x-cypress-project-slug': '12345', - 'x-cypress-studio-mount-version': '1', - 'x-os-name': 'linux', - 'x-cypress-version': '1.2.3', - }, - encrypt: 'signed', - }) - - expect(createInErrorManagerStub).to.be.calledWithMatch({ - error: sinon.match.instanceOf(AggregateError), - cloudApi: { - cloudUrl: 'http://localhost:1234', - cloudHeaders: { - a: 'b', - c: 'd', - }, - }, - studioHash: undefined, - projectSlug: '12345', - studioMethod: 'getAndInitializeStudioManager', - }) - }) - - it('throws an error and returns a studio manager in error state if the response status is not ok', async () => { - const mockGetCloudUrl = sinon.stub() - const mockAdditionalHeaders = sinon.stub() - const cloud = { - getCloudUrl: mockGetCloudUrl, - additionalHeaders: mockAdditionalHeaders, - } as unknown as CloudDataSource - - mockGetCloudUrl.returns('http://localhost:1234') - mockAdditionalHeaders.resolves({ - a: 'b', - c: 'd', - }) - - crossFetchStub.resolves({ - ok: false, - statusText: 'Some failure', - }) - - const projectId = '12345' - - await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true }) - - expect(rmStub).to.be.calledWith('/tmp/cypress/studio') - expect(ensureStub).to.be.calledWith('/tmp/cypress/studio') - expect(createInErrorManagerStub).to.be.calledWithMatch({ - error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Failed to download studio bundle: Some failure')), - cloudApi: { - cloudUrl: 'http://localhost:1234', - cloudHeaders: { a: 'b', c: 'd' }, - }, - studioHash: undefined, - projectSlug: '12345', - studioMethod: 'getAndInitializeStudioManager', - }) - }) - - it('throws an error and returns a studio manager in error state if the signature verification fails', async () => { - const mockGetCloudUrl = sinon.stub() - const mockAdditionalHeaders = sinon.stub() - const cloud = { - getCloudUrl: mockGetCloudUrl, - additionalHeaders: mockAdditionalHeaders, - } as unknown as CloudDataSource - - mockGetCloudUrl.returns('http://localhost:1234') - mockAdditionalHeaders.resolves({ - a: 'b', - c: 'd', - }) - - crossFetchStub.resolves({ - ok: true, - statusText: 'OK', - body: readStream, - headers: { - get: (header) => { - if (header === 'x-cypress-signature') { - return '159' - } - }, - }, - }) - - verifySignatureFromFileStub.resolves(false) - - const projectId = '12345' - - await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true }) - - expect(rmStub).to.be.calledWith('/tmp/cypress/studio') - expect(ensureStub).to.be.calledWith('/tmp/cypress/studio') - expect(writeResult).to.eq('console.log("studio script")') - - expect(crossFetchStub).to.be.calledWith('http://localhost:1234/studio/bundle/abc.tgz', { - agent: sinon.match.any, - method: 'GET', - headers: { - 'x-route-version': '1', - 'x-cypress-signature': '1', - 'x-cypress-project-slug': '12345', - 'x-cypress-studio-mount-version': '1', - 'x-os-name': 'linux', - 'x-cypress-version': '1.2.3', - }, - encrypt: 'signed', - }) - - expect(verifySignatureFromFileStub).to.be.calledWith('/tmp/cypress/studio/bundle.tar', '159') - expect(createInErrorManagerStub).to.be.calledWithMatch({ - error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Unable to verify studio signature')), - cloudApi: { - cloudUrl: 'http://localhost:1234', - cloudHeaders: { a: 'b', c: 'd' }, - }, - studioHash: undefined, - projectSlug: '12345', - studioMethod: 'getAndInitializeStudioManager', - }) - }) - - it('throws an error if there is no signature in the response headers', async () => { - const mockGetCloudUrl = sinon.stub() - const mockAdditionalHeaders = sinon.stub() - const cloud = { - getCloudUrl: mockGetCloudUrl, - additionalHeaders: mockAdditionalHeaders, - } as unknown as CloudDataSource - - mockGetCloudUrl.returns('http://localhost:1234') - mockAdditionalHeaders.resolves({ - a: 'b', - c: 'd', - }) - - crossFetchStub.resolves({ - ok: true, - statusText: 'OK', - body: readStream, - headers: { - get: () => null, - }, - }) - - const projectId = '12345' - - await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true }) - - expect(rmStub).to.be.calledWith('/tmp/cypress/studio') - expect(ensureStub).to.be.calledWith('/tmp/cypress/studio') - expect(createInErrorManagerStub).to.be.calledWithMatch({ - error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Unable to get studio signature')), - cloudApi: { - cloudUrl: 'http://localhost:1234', - cloudHeaders: { a: 'b', c: 'd' }, - }, - studioHash: undefined, - projectSlug: '12345', - studioMethod: 'getAndInitializeStudioManager', - }) - }) - - it('throws an error if downloading the studio bundle takes too long', async () => { - const mockGetCloudUrl = sinon.stub() - const mockAdditionalHeaders = sinon.stub() - const cloud = { - getCloudUrl: mockGetCloudUrl, - additionalHeaders: mockAdditionalHeaders, - } as unknown as CloudDataSource - - mockGetCloudUrl.returns('http://localhost:1234') - mockAdditionalHeaders.resolves({ - a: 'b', - c: 'd', - }) - - // Create a promise that never resolves to simulate timeout - crossFetchStub.returns(new Promise(() => { - // This promise deliberately never resolves - })) - - const projectId = '12345' - - // pass shorter timeout for testing - await getAndInitializeStudioManager({ studioUrl: 'http://localhost:1234/studio/bundle/abc.tgz', projectId, cloudDataSource: cloud, shouldEnableStudio: true, downloadTimeoutMs: 3000 }) - - expect(rmStub).to.be.calledWith('/tmp/cypress/studio') - expect(ensureStub).to.be.calledWith('/tmp/cypress/studio') - expect(createInErrorManagerStub).to.be.calledWithMatch({ - error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Cloud studio download timed out')), - cloudApi: { - cloudUrl: 'http://localhost:1234', - cloudHeaders: { a: 'b', c: 'd' }, - }, - studioHash: undefined, - projectSlug: '12345', - studioMethod: 'getAndInitializeStudioManager', - }) - }) - }) -}) diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index 102157cefa1f..330c5fe7b950 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -10,10 +10,11 @@ import { CloudRequest } from '../../../../lib/cloud/api/cloud_request' import { isRetryableError } from '../../../../lib/cloud/network/is_retryable_error' import { asyncRetry } from '../../../../lib/util/async_retry' import { Cfg } from '../../../../lib/project-base' -import api from '../../../../lib/cloud/api' import ProtocolManager from '../../../../lib/cloud/protocol' import * as reportStudioErrorPath from '../../../../lib/cloud/api/studio/report_studio_error' +const api = require('../../../../lib/cloud/api').default + // Helper to wait for next tick in event loop const nextTick = () => new Promise((resolve) => process.nextTick(resolve)) From fe9065e9fde7750bde473d916ce21d84c8875145 Mon Sep 17 00:00:00 2001 From: "cypress-bot[bot]" <+cypress-bot[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 01:19:07 +0000 Subject: [PATCH 06/15] index on ryanm/internal/hot-reloading: b6a9137258 Update packages/server/test/unit/cloud/get_cloud_metadata_spec.ts From ed0014062b062a700b4ccd05d09bc43a4bd4e2f5 Mon Sep 17 00:00:00 2001 From: "cypress-bot[bot]" <+cypress-bot[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 01:20:31 +0000 Subject: [PATCH 07/15] index on ryanm/internal/hot-reloading: b6a9137258 Update packages/server/test/unit/cloud/get_cloud_metadata_spec.ts From 629357be871d7e065e491c6f81593503754e90a6 Mon Sep 17 00:00:00 2001 From: "cypress-bot[bot]" <+cypress-bot[bot]@users.noreply.github.com> Date: Thu, 29 May 2025 01:28:37 +0000 Subject: [PATCH 08/15] index on ryanm/internal/hot-reloading: b6a9137258 Update packages/server/test/unit/cloud/get_cloud_metadata_spec.ts From 880a392614b35f10971b4acf5938899852afebdc Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 20:45:27 -0500 Subject: [PATCH 09/15] fix tests --- .../unit/cloud/get_cloud_metadata_spec.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts b/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts index 212a6369f753..1cb640ccc331 100644 --- a/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts +++ b/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts @@ -4,8 +4,13 @@ import { getCloudMetadata } from '../../../lib/cloud/get_cloud_metadata' describe('getCloudMetadata', () => { let mockCloudDataSource: CloudDataSource + let originalCypressConfigEnv: string | undefined + let originalCypressInternalEnv: string | undefined beforeEach(() => { + originalCypressConfigEnv = process.env.CYPRESS_CONFIG_ENV + originalCypressInternalEnv = process.env.CYPRESS_INTERNAL_ENV + mockCloudDataSource = { getCloudUrl: sinon.stub().returns('https://cloud.cypress.io'), additionalHeaders: sinon.stub().resolves({ 'x-cypress-cloud-header': 'test' }), @@ -13,8 +18,17 @@ describe('getCloudMetadata', () => { }) afterEach(() => { - delete process.env.CYPRESS_CONFIG_ENV - delete process.env.CYPRESS_INTERNAL_ENV + if (originalCypressConfigEnv) { + process.env.CYPRESS_CONFIG_ENV = originalCypressConfigEnv + } else { + delete process.env.CYPRESS_CONFIG_ENV + } + + if (originalCypressInternalEnv) { + process.env.CYPRESS_INTERNAL_ENV = originalCypressInternalEnv as 'development' | 'staging' | 'production' + } else { + delete process.env.CYPRESS_INTERNAL_ENV + } }) it('should return the cloud metadata based on the cypress cloud config', async () => { @@ -45,7 +59,6 @@ describe('getCloudMetadata', () => { }) it('should return the cloud metadata based on the default environment', async () => { - const cloudMetadata = await getCloudMetadata(mockCloudDataSource) expect(mockCloudDataSource.getCloudUrl).to.have.been.calledWith('production') From bce544b3e7b545a4d458a57588afb00f0711ed32 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 20:48:34 -0500 Subject: [PATCH 10/15] fix binary --- scripts/after-pack-hook.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/after-pack-hook.js b/scripts/after-pack-hook.js index b0c5a503ace1..ea0e365c283a 100644 --- a/scripts/after-pack-hook.js +++ b/scripts/after-pack-hook.js @@ -95,15 +95,18 @@ module.exports = async function (params) { const cloudApiFileSource = await getProtocolFileSource(cloudApiFilePath) const cloudProtocolFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/protocol.ts') const cloudProtocolFileSource = await getProtocolFileSource(cloudProtocolFilePath) - const getAndInitializeStudioManagerFilePath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts') - const getAndInitializeStudioManagerFileSource = await getStudioFileSource(getAndInitializeStudioManagerFilePath) + const reportStudioErrorPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/api/studio/report_studio_error.ts') + const reportStudioErrorFileSource = await getStudioFileSource(reportStudioErrorPath) + const StudioLifecycleManagerPath = path.join(CY_ROOT_DIR, 'packages/server/lib/cloud/studio/StudioLifecycleManager.ts') + const StudioLifecycleManagerFileSource = await getStudioFileSource(StudioLifecycleManagerPath) await Promise.all([ fs.writeFile(encryptionFilePath, encryptionFileSource), fs.writeFile(cloudEnvironmentFilePath, cloudEnvironmentFileSource), fs.writeFile(cloudApiFilePath, cloudApiFileSource), fs.writeFile(cloudProtocolFilePath, cloudProtocolFileSource), - fs.writeFile(getAndInitializeStudioManagerFilePath, getAndInitializeStudioManagerFileSource), + fs.writeFile(reportStudioErrorPath, reportStudioErrorFileSource), + fs.writeFile(StudioLifecycleManagerPath, StudioLifecycleManagerFileSource), fs.writeFile(path.join(outputFolder, 'index.js'), binaryEntryPointSource), ]) @@ -116,7 +119,8 @@ module.exports = async function (params) { validateCloudEnvironmentFile(cloudEnvironmentFilePath), validateProtocolFile(cloudApiFilePath), validateProtocolFile(cloudProtocolFilePath), - validateStudioFile(getAndInitializeStudioManagerFilePath), + validateStudioFile(reportStudioErrorPath), + validateStudioFile(StudioLifecycleManagerPath), ]) await flipFuses( From 6f3c0f2aa23bc7e57fbd3ab0b77ff5cda95410b0 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 22:00:40 -0500 Subject: [PATCH 11/15] fix tests --- packages/server/test/unit/cloud/api/api_spec.js | 16 ++++++++++++++++ .../test/unit/cloud/get_cloud_metadata_spec.ts | 10 +++++----- packages/server/test/unit/cloud/routes_spec.js | 6 +++++- .../cloud/studio/StudioLifecycleManager_spec.ts | 3 +++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/server/test/unit/cloud/api/api_spec.js b/packages/server/test/unit/cloud/api/api_spec.js index f470070343a6..3c8ff9bb2264 100644 --- a/packages/server/test/unit/cloud/api/api_spec.js +++ b/packages/server/test/unit/cloud/api/api_spec.js @@ -228,6 +228,8 @@ describe('lib/cloud/api', () => { context('.sendPreflight', () => { let prodApi + let originalCypressConfigEnv = process.env.CYPRESS_CONFIG_ENV + let originalCypressAPIUrl = process.env.CYPRESS_API_URL beforeEach(function () { this.timeout(30000) @@ -250,6 +252,20 @@ describe('lib/cloud/api', () => { prodApi.resetPreflightResult() }) + afterEach(() => { + if (originalCypressConfigEnv) { + process.env.CYPRESS_CONFIG_ENV = originalCypressConfigEnv + } else { + delete process.env.CYPRESS_CONFIG_ENV + } + + if (originalCypressAPIUrl) { + process.env.CYPRESS_API_URL = originalCypressAPIUrl + } else { + delete process.env.CYPRESS_API_URL + } + }) + it('POST /preflight to proxy. returns encryption', () => { preflightNock(API_PROD_PROXY_BASEURL) .reply(200, decryptReqBodyAndRespond({ diff --git a/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts b/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts index 1cb640ccc331..8b747f526ac5 100644 --- a/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts +++ b/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts @@ -4,13 +4,10 @@ import { getCloudMetadata } from '../../../lib/cloud/get_cloud_metadata' describe('getCloudMetadata', () => { let mockCloudDataSource: CloudDataSource - let originalCypressConfigEnv: string | undefined - let originalCypressInternalEnv: string | undefined + let originalCypressConfigEnv: string | undefined = process.env.CYPRESS_CONFIG_ENV + let originalCypressInternalEnv: string | undefined = process.env.CYPRESS_INTERNAL_ENV beforeEach(() => { - originalCypressConfigEnv = process.env.CYPRESS_CONFIG_ENV - originalCypressInternalEnv = process.env.CYPRESS_INTERNAL_ENV - mockCloudDataSource = { getCloudUrl: sinon.stub().returns('https://cloud.cypress.io'), additionalHeaders: sinon.stub().resolves({ 'x-cypress-cloud-header': 'test' }), @@ -59,6 +56,9 @@ describe('getCloudMetadata', () => { }) it('should return the cloud metadata based on the default environment', async () => { + delete process.env.CYPRESS_CONFIG_ENV + delete process.env.CYPRESS_INTERNAL_ENV + const cloudMetadata = await getCloudMetadata(mockCloudDataSource) expect(mockCloudDataSource.getCloudUrl).to.have.been.calledWith('production') diff --git a/packages/server/test/unit/cloud/routes_spec.js b/packages/server/test/unit/cloud/routes_spec.js index 208020f588a1..367d1037825c 100644 --- a/packages/server/test/unit/cloud/routes_spec.js +++ b/packages/server/test/unit/cloud/routes_spec.js @@ -51,7 +51,11 @@ describe('lib/cloud/routes', () => { }) afterEach(() => { - process.env.CYPRESS_INTERNAL_ENV = oldCypressInternalEnv + if (oldCypressInternalEnv) { + process.env.CYPRESS_INTERNAL_ENV = oldCypressInternalEnv + } else { + delete process.env.CYPRESS_INTERNAL_ENV + } }) it('supports development environment', () => { diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index 330c5fe7b950..46f27d0d709f 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -89,6 +89,9 @@ describe('StudioLifecycleManager', () => { close: watcherCloseStub, }), }, + '../routes': { + apiUrl: 'http://localhost:1234/', + }, }).StudioLifecycleManager studioLifecycleManager = new StudioLifecycleManager() From f7d96559ba316dd9c856b5d74ed17e640db03009 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 22:15:12 -0500 Subject: [PATCH 12/15] minor tweaks --- packages/app/src/studio/studio-app-types.ts | 23 +++++++++++-- scripts/gulp/tasks/gulpCloudDeliveredTypes.ts | 34 +++++-------------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/app/src/studio/studio-app-types.ts b/packages/app/src/studio/studio-app-types.ts index 9f050b260d23..e82182690a96 100644 --- a/packages/app/src/studio/studio-app-types.ts +++ b/packages/app/src/studio/studio-app-types.ts @@ -49,13 +49,18 @@ export type RunnerStatusShape = (props: RunnerStatusProps) => { export interface StudioAIStreamProps { canAccessStudioAI: boolean - AIOutputRef: { current: HTMLTextAreaElement | null } runnerStatus: RunnerStatus testCode?: string isCreatingNewTest: boolean } -export type StudioAIStreamShape = (props: StudioAIStreamProps) => void +export interface StudioAIStream { + recommendation: string + isStreaming: boolean + generationId: string | null +} + +export type StudioAIStreamShape = (props: StudioAIStreamProps) => StudioAIStream export interface TestContentRetrieverProps { Cypress: CypressInternal @@ -66,3 +71,17 @@ export type TestContentRetrieverShape = (props: TestContentRetrieverProps) => { testBlock: TestBlock | null 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 +} diff --git a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts index f9985076eabd..8c466f5dac06 100644 --- a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts +++ b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts @@ -4,7 +4,6 @@ import path from 'path' import fs from 'fs-extra' import { postStudioSession } from '@packages/server/lib/cloud/api/studio/post_studio_session' import os from 'os' -import chokidar from 'chokidar' import { ensureStudioBundle } from '@packages/server/lib/cloud/studio/ensure_studio_bundle' export const downloadStudioTypes = async (): Promise => { @@ -26,31 +25,14 @@ export const downloadStudioTypes = async (): Promise => { path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'studio', 'studio-server-types.ts'), ) } else { - const copyAppTypes = async () => { - await fs.copyFile( - path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH!, 'app', 'types.ts'), - path.join(__dirname, '..', '..', '..', 'packages', 'app', 'src', 'studio', 'studio-app-types.ts'), - ) - } - const copyServerTypes = async () => { - await fs.copyFile( - path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH!, 'server', 'types.ts'), - path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'studio', 'studio-server-types.ts'), - ) - } - - const appWatcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'app', 'types.ts'), { - awaitWriteFinish: true, - }) - - appWatcher.on('ready', copyAppTypes) - appWatcher.on('change', copyAppTypes) - - const serverWatcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'server', 'types.ts'), { - awaitWriteFinish: true, - }) + await fs.copyFile( + path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH!, 'app', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'app', 'src', 'studio', 'studio-app-types.ts'), + ) - serverWatcher.on('ready', copyServerTypes) - serverWatcher.on('change', copyServerTypes) + await fs.copyFile( + path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH!, 'server', 'types.ts'), + path.join(__dirname, '..', '..', '..', 'packages', 'types', 'src', 'studio', 'studio-server-types.ts'), + ) } } From 9351fa12764698fc38ee3b4cd7bbdfa27cafb792 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 22:36:28 -0500 Subject: [PATCH 13/15] code cleanup --- .../cloud/studio/StudioLifecycleManager.ts | 73 ++++++++++++------- .../lib/cloud/studio/ensure_studio_bundle.ts | 15 +++- .../studio/StudioLifecycleManager_spec.ts | 2 +- .../cloud/studio/ensure_studio_bundle_spec.ts | 19 +++++ 4 files changed, 80 insertions(+), 29 deletions(-) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index 25e2bebaeba0..73364999ac1d 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -103,33 +103,12 @@ export class StudioLifecycleManager { this.studioManagerPromise = studioManagerPromise - // If the studio bundle is local, we need to watch for changes to the bundle - // and reload the manager on file changes - if (process.env.CYPRESS_LOCAL_STUDIO_PATH) { - // Close the watcher if it already exists - if (StudioLifecycleManager.watcher) { - // Nothing really to do if this fails and it's only in development - StudioLifecycleManager.watcher.close().catch(() => {}) - } - - // Watch for changes to the cy prompt - StudioLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'server', 'index.js'), { - awaitWriteFinish: true, - }).on('change', async () => { - await this.studioManager?.destroy() - this.studioManager = undefined - this.studioManagerPromise = this.createStudioManager({ - projectId, - cloudDataSource, - cfg, - debugData, - }).catch((error) => { - debug('Error during reload of studio manager: %o', error) - - return null - }) - }) - } + this.setupWatcher({ + projectId, + cloudDataSource, + cfg, + debugData, + }) } isStudioReady (): boolean { @@ -263,6 +242,46 @@ export class StudioLifecycleManager { } } + private setupWatcher ({ + projectId, + cloudDataSource, + cfg, + debugData, + }: { + projectId?: string + cloudDataSource: CloudDataSource + cfg: Cfg + debugData: any + }) { + // Don't setup a watcher if the studio bundle is local + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) { + return + } + + // Close the watcher if a previous watcher exists + if (StudioLifecycleManager.watcher) { + StudioLifecycleManager.watcher.close().catch(() => {}) + } + + // Watch for changes to the cy prompt + StudioLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'server', 'index.js'), { + awaitWriteFinish: true, + }).on('change', async () => { + await this.studioManager?.destroy() + this.studioManager = undefined + this.studioManagerPromise = this.createStudioManager({ + projectId, + cloudDataSource, + cfg, + debugData, + }).catch((error) => { + debug('Error during reload of studio manager: %o', error) + + return null + }) + }) + } + /** * Register a listener that will be called when the studio is ready * @param listener Function to call when studio is ready diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index eb0c7ff8c15c..7f0bc7cd425b 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -13,7 +13,20 @@ interface EnsureStudioBundleOptions { const DOWNLOAD_TIMEOUT = 30000 -export const ensureStudioBundle = async ({ studioUrl, projectId, studioPath, downloadTimeoutMs = DOWNLOAD_TIMEOUT }: EnsureStudioBundleOptions) => { +/** + * Ensures that the studio bundle is downloaded and extracted into the given path + * @param options - The options for the ensure studio bundle operation + * @param options.studioUrl - The URL of the studio bundle + * @param options.projectId - The project ID of the studio bundle + * @param options.studioPath - The path to extract the studio bundle to + * @param options.downloadTimeoutMs - The timeout for the download operation + */ +export const ensureStudioBundle = async ({ + studioUrl, + projectId, + studioPath, + downloadTimeoutMs = DOWNLOAD_TIMEOUT, +}: EnsureStudioBundleOptions) => { const bundlePath = path.join(studioPath, 'bundle.tar') // First remove cyPromptPath to ensure we have a clean slate diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index 46f27d0d709f..b0609e3428f6 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -407,7 +407,7 @@ describe('StudioLifecycleManager', () => { const listener1 = sinon.stub() const listener2 = sinon.stub() - // Register listerns that should be cleaned up + // Register listeners that should be cleaned up studioLifecycleManager.registerStudioReadyListener(listener1) studioLifecycleManager.registerStudioReadyListener(listener2) diff --git a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts index 3d3124a8a8c0..2fbb9dc63d00 100644 --- a/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts +++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts @@ -63,4 +63,23 @@ describe('ensureStudioBundle', () => { cwd: studioPath, }) }) + + it('should throw an error if the studio bundle download times out', async () => { + getStudioBundleStub.callsFake(() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(new Error('Studio bundle download timed out')) + }, 3000) + }) + }) + + const ensureStudioBundlePromise = ensureStudioBundle({ + studioPath: '/tmp/cypress/studio/123', + studioUrl: 'https://cypress.io/studio', + projectId: '123', + downloadTimeoutMs: 500, + }) + + await expect(ensureStudioBundlePromise).to.be.rejectedWith('Studio bundle download timed out') + }) }) From 31255176af7cefbd9963a867d07a673e68591827 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Wed, 28 May 2025 22:38:42 -0500 Subject: [PATCH 14/15] update guide --- guides/studio-development.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guides/studio-development.md b/guides/studio-development.md index 70197b3564c1..b1eb664f5e4d 100644 --- a/guides/studio-development.md +++ b/guides/studio-development.md @@ -29,6 +29,8 @@ Regardless of running against local or deployed studio: Note: When using the `CYPRESS_LOCAL_STUDIO_PATH` environment variable or when running the Cypress app via the locally cloned repository, we bypass our error reporting and instead log errors to the browser or node console. +Note: When using the `CYPRESS_LOCAL_STUDIO_PATH` the cloud studio code will be watched for changes so that you do not have to stop the app to incoprorate any new changes. + ## Types The studio bundle provides the types for the `app` and `server` interfaces that are used within the Cypress code. To incorporate the types into the code base, run: From 7b14bf8fe187b0e496f7e5c826a0320b75b7a409 Mon Sep 17 00:00:00 2001 From: Ryan Manuel Date: Thu, 29 May 2025 21:16:51 -0500 Subject: [PATCH 15/15] PR comments --- packages/server/lib/cloud/studio/StudioLifecycleManager.ts | 7 +++++-- packages/server/lib/cloud/studio/ensure_studio_bundle.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index 73364999ac1d..ed96964c6577 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -253,17 +253,18 @@ export class StudioLifecycleManager { cfg: Cfg debugData: any }) { - // Don't setup a watcher if the studio bundle is local + // Don't setup a watcher if the studio bundle is NOT local if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) { return } // Close the watcher if a previous watcher exists if (StudioLifecycleManager.watcher) { + StudioLifecycleManager.watcher.removeAllListeners() StudioLifecycleManager.watcher.close().catch(() => {}) } - // Watch for changes to the cy prompt + // Watch for changes to the studio bundle StudioLifecycleManager.watcher = chokidar.watch(path.join(process.env.CYPRESS_LOCAL_STUDIO_PATH, 'server', 'index.js'), { awaitWriteFinish: true, }).on('change', async () => { @@ -292,6 +293,8 @@ export class StudioLifecycleManager { debug('Studio ready - calling listener immediately') listener(this.studioManager) + // If the studio bundle is local, we need to register the listener + // so that we can reload the studio when the bundle changes if (process.env.CYPRESS_LOCAL_STUDIO_PATH) { this.listeners.push(listener) } diff --git a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts index 7f0bc7cd425b..5b4e09ca39b1 100644 --- a/packages/server/lib/cloud/studio/ensure_studio_bundle.ts +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -29,7 +29,7 @@ export const ensureStudioBundle = async ({ }: EnsureStudioBundleOptions) => { const bundlePath = path.join(studioPath, 'bundle.tar') - // First remove cyPromptPath to ensure we have a clean slate + // First remove studioPath to ensure we have a clean slate await remove(studioPath) await ensureDir(studioPath)