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: 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/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..9bf3fa3cd7ff --- /dev/null +++ b/packages/server/lib/cloud/get_cloud_metadata.ts @@ -0,0 +1,12 @@ +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' + 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 58% rename from packages/server/lib/StudioLifecycleManager.ts rename to packages/server/lib/cloud/studio/StudioLifecycleManager.ts index fbc1157ff55f..ed96964c6577 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 @@ -67,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: { @@ -95,6 +102,13 @@ export class StudioLifecycleManager { }) this.studioManagerPromise = studioManagerPromise + + this.setupWatcher({ + projectId, + cloudDataSource, + cfg, + debugData, + }) } isStudioReady (): boolean { @@ -126,16 +140,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 +237,50 @@ export class StudioLifecycleManager { listener(studioManager) }) - this.listeners = [] + if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) { + this.listeners = [] + } + } + + private setupWatcher ({ + projectId, + cloudDataSource, + cfg, + debugData, + }: { + projectId?: string + cloudDataSource: CloudDataSource + cfg: Cfg + debugData: any + }) { + // 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 studio bundle + 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 + }) + }) } /** @@ -195,6 +292,12 @@ export class StudioLifecycleManager { if (this.studioManager) { 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) + } } 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..5b4e09ca39b1 --- /dev/null +++ b/packages/server/lib/cloud/studio/ensure_studio_bundle.ts @@ -0,0 +1,57 @@ +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 + +/** + * 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 studioPath 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..67c6ec14175d 100644 --- a/packages/server/lib/project-base.ts +++ b/packages/server/lib/project-base.ts @@ -24,11 +24,12 @@ 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' 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: { 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/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/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..8b747f526ac5 --- /dev/null +++ b/packages/server/test/unit/cloud/get_cloud_metadata_spec.ts @@ -0,0 +1,71 @@ +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 + let originalCypressConfigEnv: string | undefined = process.env.CYPRESS_CONFIG_ENV + let originalCypressInternalEnv: string | undefined = process.env.CYPRESS_INTERNAL_ENV + + beforeEach(() => { + mockCloudDataSource = { + getCloudUrl: sinon.stub().returns('https://cloud.cypress.io'), + additionalHeaders: sinon.stub().resolves({ 'x-cypress-cloud-header': 'test' }), + } as unknown as CloudDataSource + }) + + afterEach(() => { + 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 () => { + 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 () => { + 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') + 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/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/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..b0609e3428f6 100644 --- a/packages/server/test/unit/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -1,15 +1,19 @@ -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 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)) @@ -19,22 +23,79 @@ 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, + }), + }, + '../routes': { + apiUrl: 'http://localhost:1234/', + }, + }).StudioLifecycleManager + + studioLifecycleManager = new StudioLifecycleManager() + studioStatusChangeEmitterStub = sinon.stub() mockCtx = { @@ -49,7 +110,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 +125,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 +173,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 +197,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,9 +251,104 @@ 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")', { @@ -175,9 +370,39 @@ 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() @@ -189,23 +414,22 @@ describe('StudioLifecycleManager', () => { // @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 +517,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 +552,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 +590,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 +711,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..2fbb9dc63d00 --- /dev/null +++ b/packages/server/test/unit/cloud/studio/ensure_studio_bundle_spec.ts @@ -0,0 +1,85 @@ +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, + }) + }) + + 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') + }) +}) 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/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( diff --git a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts index f00c1b1a46ae..8c466f5dac06 100644 --- a/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts +++ b/scripts/gulp/tasks/gulpCloudDeliveredTypes.ts @@ -2,21 +2,37 @@ 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 { 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 { + 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'), + ) + + 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'), + ) + } } diff --git a/tooling/v8-snapshot/cache/darwin/snapshot-meta.json b/tooling/v8-snapshot/cache/darwin/snapshot-meta.json index f465f6f0729f..39dd8c01fafb 100644 --- a/tooling/v8-snapshot/cache/darwin/snapshot-meta.json +++ b/tooling/v8-snapshot/cache/darwin/snapshot-meta.json @@ -733,12 +733,12 @@ "./packages/server/lib/cloud/api/cloud_request.ts", "./packages/server/lib/cloud/api/index.ts", "./packages/server/lib/cloud/api/put_protocol_artifact.ts", - "./packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts", "./packages/server/lib/cloud/artifacts/protocol_artifact.ts", "./packages/server/lib/cloud/artifacts/screenshot_artifact.ts", "./packages/server/lib/cloud/artifacts/video_artifact.ts", "./packages/server/lib/cloud/auth.ts", "./packages/server/lib/cloud/routes.ts", + "./packages/server/lib/cloud/studio/StudioLifecycleManager.ts", "./packages/server/lib/cloud/user.ts", "./packages/server/lib/config.ts", "./packages/server/lib/cypress.ts", @@ -772,6 +772,10 @@ "./packages/server/node_modules/body-parser/index.js", "./packages/server/node_modules/body-parser/node_modules/debug/src/browser.js", "./packages/server/node_modules/body-parser/node_modules/debug/src/index.js", + "./packages/server/node_modules/chokidar/index.js", + "./packages/server/node_modules/chokidar/lib/constants.js", + "./packages/server/node_modules/chokidar/lib/fsevents-handler.js", + "./packages/server/node_modules/chokidar/lib/nodefs-handler.js", "./packages/server/node_modules/chownr/chownr.js", "./packages/server/node_modules/cross-fetch/node_modules/node-fetch/lib/index.js", "./packages/server/node_modules/cross-spawn/node_modules/semver/semver.js", @@ -816,6 +820,7 @@ "./packages/server/node_modules/readable-stream/lib/internal/streams/buffer_list.js", "./packages/server/node_modules/readable-stream/lib/internal/streams/stream.js", "./packages/server/node_modules/readable-stream/readable.js", + "./packages/server/node_modules/readdirp/index.js", "./packages/server/node_modules/supports-color/index.js", "./packages/server/node_modules/tar/lib/header.js", "./packages/server/node_modules/tar/lib/pack.js", @@ -3824,7 +3829,6 @@ "./packages/scaffold-config/src/index.ts", "./packages/scaffold-config/src/supportFile.ts", "./packages/server/config/app.json", - "./packages/server/lib/StudioLifecycleManager.ts", "./packages/server/lib/automation/automation.ts", "./packages/server/lib/automation/automation_not_implemented.ts", "./packages/server/lib/automation/commands/key_press.ts", @@ -3851,6 +3855,7 @@ "./packages/server/lib/cloud/api/axios_middleware/logging.ts", "./packages/server/lib/cloud/api/axios_middleware/transform_error.ts", "./packages/server/lib/cloud/api/scrub_url.ts", + "./packages/server/lib/cloud/api/studio/get_studio_bundle.ts", "./packages/server/lib/cloud/api/studio/post_studio_session.ts", "./packages/server/lib/cloud/api/studio/report_studio_error.ts", "./packages/server/lib/cloud/artifacts/artifact.ts", @@ -3860,6 +3865,7 @@ "./packages/server/lib/cloud/constants.ts", "./packages/server/lib/cloud/encryption.ts", "./packages/server/lib/cloud/environment.ts", + "./packages/server/lib/cloud/get_cloud_metadata.ts", "./packages/server/lib/cloud/machine_id.ts", "./packages/server/lib/cloud/network/http_error.ts", "./packages/server/lib/cloud/network/is_retryable_error.ts", @@ -3869,7 +3875,8 @@ "./packages/server/lib/cloud/protocol.ts", "./packages/server/lib/cloud/require_script.ts", "./packages/server/lib/cloud/strip_path.ts", - "./packages/server/lib/cloud/studio.ts", + "./packages/server/lib/cloud/studio/ensure_studio_bundle.ts", + "./packages/server/lib/cloud/studio/studio.ts", "./packages/server/lib/cloud/upload/send_file.ts", "./packages/server/lib/cloud/upload/stream_activity_monitor.ts", "./packages/server/lib/cloud/upload/stream_stalled_error.ts", diff --git a/tooling/v8-snapshot/cache/linux/snapshot-meta.json b/tooling/v8-snapshot/cache/linux/snapshot-meta.json index e945e19f9372..4e20b55b01f1 100644 --- a/tooling/v8-snapshot/cache/linux/snapshot-meta.json +++ b/tooling/v8-snapshot/cache/linux/snapshot-meta.json @@ -732,12 +732,12 @@ "./packages/server/lib/cloud/api/cloud_request.ts", "./packages/server/lib/cloud/api/index.ts", "./packages/server/lib/cloud/api/put_protocol_artifact.ts", - "./packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts", "./packages/server/lib/cloud/artifacts/protocol_artifact.ts", "./packages/server/lib/cloud/artifacts/screenshot_artifact.ts", "./packages/server/lib/cloud/artifacts/video_artifact.ts", "./packages/server/lib/cloud/auth.ts", "./packages/server/lib/cloud/routes.ts", + "./packages/server/lib/cloud/studio/StudioLifecycleManager.ts", "./packages/server/lib/cloud/user.ts", "./packages/server/lib/config.ts", "./packages/server/lib/cypress.ts", @@ -771,6 +771,10 @@ "./packages/server/node_modules/body-parser/index.js", "./packages/server/node_modules/body-parser/node_modules/debug/src/browser.js", "./packages/server/node_modules/body-parser/node_modules/debug/src/index.js", + "./packages/server/node_modules/chokidar/index.js", + "./packages/server/node_modules/chokidar/lib/constants.js", + "./packages/server/node_modules/chokidar/lib/fsevents-handler.js", + "./packages/server/node_modules/chokidar/lib/nodefs-handler.js", "./packages/server/node_modules/chownr/chownr.js", "./packages/server/node_modules/cross-fetch/node_modules/node-fetch/lib/index.js", "./packages/server/node_modules/cross-spawn/node_modules/semver/semver.js", @@ -815,6 +819,7 @@ "./packages/server/node_modules/readable-stream/lib/internal/streams/buffer_list.js", "./packages/server/node_modules/readable-stream/lib/internal/streams/stream.js", "./packages/server/node_modules/readable-stream/readable.js", + "./packages/server/node_modules/readdirp/index.js", "./packages/server/node_modules/supports-color/index.js", "./packages/server/node_modules/tar/lib/header.js", "./packages/server/node_modules/tar/lib/pack.js", @@ -3827,7 +3832,6 @@ "./packages/scaffold-config/src/index.ts", "./packages/scaffold-config/src/supportFile.ts", "./packages/server/config/app.json", - "./packages/server/lib/StudioLifecycleManager.ts", "./packages/server/lib/automation/automation.ts", "./packages/server/lib/automation/automation_not_implemented.ts", "./packages/server/lib/automation/commands/key_press.ts", @@ -3854,6 +3858,7 @@ "./packages/server/lib/cloud/api/axios_middleware/logging.ts", "./packages/server/lib/cloud/api/axios_middleware/transform_error.ts", "./packages/server/lib/cloud/api/scrub_url.ts", + "./packages/server/lib/cloud/api/studio/get_studio_bundle.ts", "./packages/server/lib/cloud/api/studio/post_studio_session.ts", "./packages/server/lib/cloud/api/studio/report_studio_error.ts", "./packages/server/lib/cloud/artifacts/artifact.ts", @@ -3863,6 +3868,7 @@ "./packages/server/lib/cloud/constants.ts", "./packages/server/lib/cloud/encryption.ts", "./packages/server/lib/cloud/environment.ts", + "./packages/server/lib/cloud/get_cloud_metadata.ts", "./packages/server/lib/cloud/machine_id.ts", "./packages/server/lib/cloud/network/http_error.ts", "./packages/server/lib/cloud/network/is_retryable_error.ts", @@ -3872,7 +3878,8 @@ "./packages/server/lib/cloud/protocol.ts", "./packages/server/lib/cloud/require_script.ts", "./packages/server/lib/cloud/strip_path.ts", - "./packages/server/lib/cloud/studio.ts", + "./packages/server/lib/cloud/studio/ensure_studio_bundle.ts", + "./packages/server/lib/cloud/studio/studio.ts", "./packages/server/lib/cloud/upload/send_file.ts", "./packages/server/lib/cloud/upload/stream_activity_monitor.ts", "./packages/server/lib/cloud/upload/stream_stalled_error.ts", diff --git a/tooling/v8-snapshot/cache/win32/snapshot-meta.json b/tooling/v8-snapshot/cache/win32/snapshot-meta.json index e92b188c06cb..53445a98d9e1 100644 --- a/tooling/v8-snapshot/cache/win32/snapshot-meta.json +++ b/tooling/v8-snapshot/cache/win32/snapshot-meta.json @@ -737,12 +737,12 @@ "./packages/server/lib/cloud/api/cloud_request.ts", "./packages/server/lib/cloud/api/index.ts", "./packages/server/lib/cloud/api/put_protocol_artifact.ts", - "./packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts", "./packages/server/lib/cloud/artifacts/protocol_artifact.ts", "./packages/server/lib/cloud/artifacts/screenshot_artifact.ts", "./packages/server/lib/cloud/artifacts/video_artifact.ts", "./packages/server/lib/cloud/auth.ts", "./packages/server/lib/cloud/routes.ts", + "./packages/server/lib/cloud/studio/StudioLifecycleManager.ts", "./packages/server/lib/cloud/user.ts", "./packages/server/lib/config.ts", "./packages/server/lib/cypress.ts", @@ -776,6 +776,10 @@ "./packages/server/node_modules/body-parser/index.js", "./packages/server/node_modules/body-parser/node_modules/debug/src/browser.js", "./packages/server/node_modules/body-parser/node_modules/debug/src/index.js", + "./packages/server/node_modules/chokidar/index.js", + "./packages/server/node_modules/chokidar/lib/constants.js", + "./packages/server/node_modules/chokidar/lib/fsevents-handler.js", + "./packages/server/node_modules/chokidar/lib/nodefs-handler.js", "./packages/server/node_modules/chownr/chownr.js", "./packages/server/node_modules/cross-fetch/node_modules/node-fetch/lib/index.js", "./packages/server/node_modules/cross-spawn/node_modules/semver/semver.js", @@ -820,6 +824,7 @@ "./packages/server/node_modules/readable-stream/lib/internal/streams/buffer_list.js", "./packages/server/node_modules/readable-stream/lib/internal/streams/stream.js", "./packages/server/node_modules/readable-stream/readable.js", + "./packages/server/node_modules/readdirp/index.js", "./packages/server/node_modules/supports-color/index.js", "./packages/server/node_modules/tar/lib/get-write-flag.js", "./packages/server/node_modules/tar/lib/header.js", @@ -3828,7 +3833,6 @@ "./packages/scaffold-config/src/index.ts", "./packages/scaffold-config/src/supportFile.ts", "./packages/server/config/app.json", - "./packages/server/lib/StudioLifecycleManager.ts", "./packages/server/lib/automation/automation.ts", "./packages/server/lib/automation/automation_not_implemented.ts", "./packages/server/lib/automation/commands/key_press.ts", @@ -3855,6 +3859,7 @@ "./packages/server/lib/cloud/api/axios_middleware/logging.ts", "./packages/server/lib/cloud/api/axios_middleware/transform_error.ts", "./packages/server/lib/cloud/api/scrub_url.ts", + "./packages/server/lib/cloud/api/studio/get_studio_bundle.ts", "./packages/server/lib/cloud/api/studio/post_studio_session.ts", "./packages/server/lib/cloud/api/studio/report_studio_error.ts", "./packages/server/lib/cloud/artifacts/artifact.ts", @@ -3864,6 +3869,7 @@ "./packages/server/lib/cloud/constants.ts", "./packages/server/lib/cloud/encryption.ts", "./packages/server/lib/cloud/environment.ts", + "./packages/server/lib/cloud/get_cloud_metadata.ts", "./packages/server/lib/cloud/machine_id.ts", "./packages/server/lib/cloud/network/http_error.ts", "./packages/server/lib/cloud/network/is_retryable_error.ts", @@ -3873,7 +3879,8 @@ "./packages/server/lib/cloud/protocol.ts", "./packages/server/lib/cloud/require_script.ts", "./packages/server/lib/cloud/strip_path.ts", - "./packages/server/lib/cloud/studio.ts", + "./packages/server/lib/cloud/studio/ensure_studio_bundle.ts", + "./packages/server/lib/cloud/studio/studio.ts", "./packages/server/lib/cloud/upload/send_file.ts", "./packages/server/lib/cloud/upload/stream_activity_monitor.ts", "./packages/server/lib/cloud/upload/stream_stalled_error.ts",