internal: (studio) pass the getProjectOptions function (#32573)

This commit is contained in:
Matt Schile
2025-10-02 17:37:02 -06:00
committed by GitHub
parent 9837082fa4
commit 43404f1e92
8 changed files with 251 additions and 152 deletions
@@ -13,9 +13,15 @@ export const transformError = (err: AxiosError | Error & { error?: any, statusCo
{ data: err.error, status: err.statusCode }
if (isObject(data)) {
const body = JSON.stringify(data, null, 2)
let body: string | null = null
err.message = [status, body].join('\n\n')
try {
body = JSON.stringify(data, null, 2)
} catch (e) {
// do nothing
}
err.message = body ? [status, body].join('\n\n') : `${status}`
}
err.isApiError = true
@@ -11,7 +11,7 @@ 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 type { StudioServerOptions, StudioStatus } from '@packages/types'
import path from 'path'
import os from 'os'
import { ensureStudioBundle } from './ensure_studio_bundle'
@@ -39,7 +39,6 @@ export class StudioLifecycleManager {
private currentStudioHash?: string
private initializationParams?: {
projectId?: string
cloudDataSource: CloudDataSource
cfg: Cfg
debugData: any
@@ -55,20 +54,17 @@ export class StudioLifecycleManager {
/**
* Initialize the studio manager and possibly set up protocol.
* Also registers this instance in the data context.
* @param projectId The project ID
* @param cloudDataSource The cloud data source
* @param cfg The project configuration
* @param debugData Debug data for the configuration
* @param ctx Data context to register this instance with
*/
initializeStudioManager ({
projectId,
cloudDataSource,
cfg,
debugData,
ctx,
}: {
projectId?: string
cloudDataSource: CloudDataSource
cfg: Cfg
debugData: any
@@ -77,7 +73,7 @@ export class StudioLifecycleManager {
debug('Initializing studio manager')
// Store initialization parameters for retry
this.initializationParams = { projectId, cloudDataSource, cfg, debugData, ctx }
this.initializationParams = { cloudDataSource, cfg, debugData, ctx }
// Register this instance in the data context
ctx.update((data) => {
@@ -88,37 +84,53 @@ export class StudioLifecycleManager {
this.updateStatus('INITIALIZING')
const getProjectOptions = async () => {
const [user, config] = await Promise.all([
ctx.actions.auth.authApi.getUser(),
ctx.project.getConfig(),
])
return {
user,
projectSlug: config.projectId || undefined,
}
}
const studioManagerPromise = this.createStudioManager({
projectId,
cloudDataSource,
cfg,
debugData,
getProjectOptions,
}).catch(async (error) => {
debug('Error during studio manager setup: %o', error)
const { cloudUrl, cloudHeaders } = await getCloudMetadata(cloudDataSource)
try {
const { cloudUrl, cloudHeaders } = await getCloudMetadata(cloudDataSource)
reportStudioError({
cloudApi: {
cloudUrl,
cloudHeaders,
CloudRequest,
isRetryableError,
asyncRetry,
},
studioHash: projectId,
projectSlug: cfg.projectId,
error,
studioMethod: 'initializeStudioManager',
studioMethodArgs: [],
})
reportStudioError({
cloudApi: {
cloudUrl,
cloudHeaders,
CloudRequest,
isRetryableError,
asyncRetry,
},
studioHash: this.currentStudioHash,
projectSlug: (await getProjectOptions()).projectSlug,
error,
studioMethod: 'initializeStudioManager',
studioMethodArgs: [],
})
this.updateStatus('IN_ERROR')
this.updateStatus('IN_ERROR')
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.BUNDLE_LIFECYCLE_END)
reportTelemetry(BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES.COMPLETE_BUNDLE_LIFECYCLE, {
success: false,
})
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.BUNDLE_LIFECYCLE_END)
reportTelemetry(BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES.COMPLETE_BUNDLE_LIFECYCLE, {
success: false,
})
} catch (error) {
debug('Error reporting studio error: %o', error)
}
return null
})
@@ -126,10 +138,10 @@ export class StudioLifecycleManager {
this.studioManagerPromise = studioManagerPromise
this.setupWatcher({
projectId,
cloudDataSource,
cfg,
debugData,
getProjectOptions,
})
}
@@ -158,22 +170,24 @@ export class StudioLifecycleManager {
}
private async createStudioManager ({
projectId,
cloudDataSource,
cfg,
debugData,
getProjectOptions,
}: {
projectId?: string
cloudDataSource: CloudDataSource
cfg: Cfg
debugData: any
getProjectOptions: StudioServerOptions['getProjectOptions']
}): Promise<StudioManager> {
let studioPath: string
let studioHash: string
let manifest: Record<string, string>
const currentProjectOptions = await getProjectOptions()
initializeTelemetryReporter({
projectSlug: projectId,
projectSlug: currentProjectOptions.projectSlug,
cloudDataSource,
})
@@ -181,7 +195,7 @@ export class StudioLifecycleManager {
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.POST_STUDIO_SESSION_START)
const studioSession = await postStudioSession({
projectId,
projectId: currentProjectOptions.projectSlug,
})
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.POST_STUDIO_SESSION_END)
@@ -192,22 +206,27 @@ export class StudioLifecycleManager {
studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0] ?? ''
studioPath = path.join(os.tmpdir(), 'cypress', 'studio', studioHash)
debug('Setting current studio hash: %s', studioHash)
// Store the current studio hash so that we can clear the cache entry when retrying
this.currentStudioHash = studioHash
let hashLoadingPromise = StudioLifecycleManager.hashLoadingMap.get(studioHash)
if (!hashLoadingPromise) {
debug('Ensuring studio bundle for hash: %s', studioHash)
hashLoadingPromise = ensureStudioBundle({
studioUrl: studioSession.studioUrl,
studioPath,
projectId,
projectId: currentProjectOptions.projectSlug,
})
StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise)
}
manifest = await hashLoadingPromise
debug('Manifest: %o', manifest)
} else {
studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH
studioHash = 'local'
@@ -226,10 +245,14 @@ export class StudioLifecycleManager {
const actualHash = crypto.createHash('sha256').update(script).digest('hex')
if (!expectedHash) {
debug('Expected hash %s for studio server script not found in manifest: %o', expectedHash, manifest)
throw new Error('Expected hash for studio server script not found in manifest')
}
if (actualHash !== expectedHash) {
debug('Invalid hash for studio server script: %s !== %s', actualHash, expectedHash)
throw new Error('Invalid hash for studio server script')
}
}
@@ -244,7 +267,6 @@ export class StudioLifecycleManager {
script,
studioPath,
studioHash,
projectSlug: projectId,
cloudApi: {
cloudUrl,
cloudHeaders,
@@ -252,8 +274,8 @@ export class StudioLifecycleManager {
isRetryableError,
asyncRetry,
},
shouldEnableStudio: this.cloudStudioRequested,
manifest,
getProjectOptions,
})
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_END)
@@ -270,7 +292,7 @@ export class StudioLifecycleManager {
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_PROTOCOL_PREPARE_START)
await protocolManager.prepareProtocol(script, {
runId: 'studio',
projectId: cfg.projectId,
projectId: currentProjectOptions.projectSlug,
testingType: cfg.testingType,
cloudApi: {
url: routes.apiUrl,
@@ -320,15 +342,15 @@ export class StudioLifecycleManager {
}
private setupWatcher ({
projectId,
cloudDataSource,
cfg,
debugData,
getProjectOptions,
}: {
projectId?: string
cloudDataSource: CloudDataSource
cfg: Cfg
debugData: any
getProjectOptions: StudioServerOptions['getProjectOptions']
}) {
// Don't setup a watcher if the studio bundle is NOT local
if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) {
@@ -348,10 +370,10 @@ export class StudioLifecycleManager {
await this.studioManager?.destroy()
this.studioManager = undefined
this.studioManagerPromise = this.createStudioManager({
projectId,
cloudDataSource,
cfg,
debugData,
getProjectOptions,
}).then((studioManager) => {
// eslint-disable-next-line no-console
console.log('Studio manager reloaded')
+6 -26
View File
@@ -1,4 +1,4 @@
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent, StudioAddSocketListenersOptions } from '@packages/types'
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent, StudioAddSocketListenersOptions, StudioServerOptions } from '@packages/types'
import type { Router } from 'express'
import Debug from 'debug'
import { requireScript } from '../require_script'
@@ -13,10 +13,9 @@ interface SetupOptions {
script: string
studioPath: string
studioHash?: string
projectSlug?: string
cloudApi: StudioCloudApi
shouldEnableStudio: boolean
manifest: Record<string, string>
getProjectOptions: StudioServerOptions['getProjectOptions']
}
const debug = Debug('cypress:server:studio')
@@ -27,30 +26,12 @@ export class StudioManager implements StudioManagerShape {
private _studioServer: StudioServerShape | undefined
private _studioElectron: StudioElectron | undefined
static createInErrorManager ({ cloudApi, studioHash, projectSlug, error, studioMethod, studioMethodArgs }: ReportStudioErrorOptions): StudioManager {
const manager = new StudioManager()
manager.status = 'IN_ERROR'
reportStudioError({
cloudApi,
studioHash,
projectSlug,
error,
studioMethod,
studioMethodArgs,
})
return manager
}
async setup ({ script, studioPath, studioHash, projectSlug, cloudApi, shouldEnableStudio, manifest }: SetupOptions): Promise<void> {
async setup ({ script, studioPath, studioHash, cloudApi, manifest, getProjectOptions }: SetupOptions): Promise<void> {
const { createStudioServer } = requireScript<StudioServer>(script).default
this._studioServer = await createStudioServer({
studioHash,
studioPath,
projectSlug,
cloudApi,
betterSqlite3Path: path.dirname(require.resolve('better-sqlite3/package.json')),
manifest,
@@ -65,9 +46,10 @@ export class StudioManager implements StudioManagerShape {
return actualHash === expectedHash
},
getProjectOptions,
})
this.status = shouldEnableStudio ? 'ENABLED' : 'INITIALIZED'
this.status = 'ENABLED'
}
initializeRoutes (router: Router): void {
@@ -79,10 +61,8 @@ export class StudioManager implements StudioManagerShape {
async captureStudioEvent (event: StudioEvent): Promise<void> {
if (this._studioServer) {
// this request is not essential - we don't want studio to error out if a telemetry request fails
return (await this.invokeAsync('captureStudioEvent', { isEssential: false }, event))
await this.invokeAsync('captureStudioEvent', { isEssential: false }, event)
}
return Promise.resolve()
}
addSocketListeners (options: StudioAddSocketListenersOptions): void {
+12 -22
View File
@@ -16,7 +16,18 @@ import { SocketCt } from './socket-ct'
import { SocketE2E } from './socket-e2e'
import { ensureProp } from './util/class-helpers'
import system from './util/system'
import { BannersState, FoundBrowser, FoundSpec, OpenProjectLaunchOptions, ProtocolManagerShape, ReceivedCypressOptions, ResolvedConfigurationOptions, TestingType, VideoRecording, AutomationCommands, StudioMetricsTypes } from '@packages/types'
import type {
BannersState,
FoundBrowser,
FoundSpec,
OpenProjectLaunchOptions,
ProtocolManagerShape,
ReceivedCypressOptions,
ResolvedConfigurationOptions,
TestingType,
VideoRecording,
AutomationCommands,
} from '@packages/types'
import { DataContext, getCtx } from '@packages/data-context'
import { createHmac } from 'crypto'
import { ServerBase } from './server-base'
@@ -161,7 +172,6 @@ export class ProjectBase extends EE {
const studioLifecycleManager = new StudioLifecycleManager()
studioLifecycleManager.initializeStudioManager({
projectId: cfg.projectId,
cloudDataSource: this.ctx.cloud,
cfg,
debugData: this.configDebugData,
@@ -475,26 +485,6 @@ export class ProjectBase extends EE {
const studio = await this.ctx.coreData.studioLifecycleManager?.getStudio()
// only capture studio started event if the user is accessing legacy studio
if (!this.ctx.coreData.studioLifecycleManager?.cloudStudioRequested) {
try {
studio?.captureStudioEvent({
type: StudioMetricsTypes.STUDIO_STARTED,
machineId: await this.ctx.coreData.machineId ?? '',
projectId: this.cfg.projectId,
browser: this.browser ? {
name: this.browser.name,
family: this.browser.family,
channel: this.browser.channel,
version: this.browser.version,
} : undefined,
cypressVersion: pkg.version,
})
} catch (error) {
debug('Error capturing studio event:', error)
}
}
if (this.spec && studio?.protocolManager) {
telemetryManager.mark(INITIALIZATION_MARK_NAMES.CAN_ACCESS_STUDIO_AI_START)
const canAccessStudioAI = await studio?.canAccessStudioAI(this.browser) ?? false
@@ -1,6 +1,6 @@
/// <reference types="cypress" />
import type { StudioServerShape, StudioServerDefaultShape } from '@packages/types'
import type { StudioServerShape, StudioServerDefaultShape, StudioEvent } from '@packages/types'
import type { Router } from 'express'
import type { Socket } from '@packages/socket'
@@ -32,6 +32,10 @@ class StudioServer implements StudioServerShape {
captureStudioEvent (event: StudioEvent): Promise<void> {
return Promise.resolve()
}
updateSessionId (sessionId: string): void {
// This is a test implementation that does nothing
}
}
const studioServerDefault: StudioServerDefaultShape = {
@@ -1,4 +1,5 @@
import { sinon, proxyquire } from '../../../spec_helper'
import { proxyquire } from '../../../spec_helper'
import sinon from 'sinon'
import { expect } from 'chai'
import { StudioManager } from '../../../../lib/cloud/studio/studio'
import { StudioLifecycleManager } from '../../../../lib/cloud/studio/StudioLifecycleManager'
@@ -130,12 +131,26 @@ describe('StudioLifecycleManager', () => {
emitter: {
studioStatusChange: studioStatusChangeEmitterStub,
},
actions: {
auth: {
authApi: {
getUser: sinon.stub().resolves({
authToken: 'test-token',
}),
},
},
},
project: {
getConfig: sinon.stub().resolves({
projectId: 'abc123',
}),
},
} as unknown as DataContext
mockCloudDataSource = {
getCloudUrl: sinon.stub().returns('https://cloud.cypress.io'),
additionalHeaders: sinon.stub().resolves({ 'Authorization': 'Bearer test-token' }),
} as CloudDataSource
} as unknown as CloudDataSource
mockCfg = {
projectId: 'abc123',
@@ -179,7 +194,6 @@ describe('StudioLifecycleManager', () => {
})
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -204,14 +218,14 @@ describe('StudioLifecycleManager', () => {
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',
projectId: 'abc123',
})
expect(studioManagerSetupStub).to.be.calledWith({
script: 'console.log("studio script")',
studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'),
studioHash: 'abc',
projectSlug: 'test-project-id',
getProjectOptions: sinon.match.func,
cloudApi: {
cloudUrl: 'https://cloud.cypress.io',
cloudHeaders: { 'Authorization': 'Bearer test-token' },
@@ -219,12 +233,11 @@ describe('StudioLifecycleManager', () => {
isRetryableError,
asyncRetry,
},
shouldEnableStudio: true,
manifest: mockManifest,
})
expect(postStudioSessionStub).to.be.calledWith({
projectId: 'test-project-id',
projectId: 'abc123',
})
expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8')
@@ -233,7 +246,7 @@ describe('StudioLifecycleManager', () => {
expect(prepareProtocolStub).not.to.be.called
expect(initializeTelemetryReporterStub).to.be.calledWith({
projectSlug: 'test-project-id',
projectSlug: 'abc123',
cloudDataSource: mockCloudDataSource,
})
@@ -263,7 +276,6 @@ describe('StudioLifecycleManager', () => {
})
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -288,14 +300,14 @@ describe('StudioLifecycleManager', () => {
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',
projectId: 'abc123',
})
expect(studioManagerSetupStub).to.be.calledWith({
script: 'console.log("studio script")',
studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'),
studioHash: 'abc',
projectSlug: 'test-project-id',
getProjectOptions: sinon.match.func,
cloudApi: {
cloudUrl: 'https://cloud.cypress.io',
cloudHeaders: { 'Authorization': 'Bearer test-token' },
@@ -303,12 +315,11 @@ describe('StudioLifecycleManager', () => {
isRetryableError,
asyncRetry,
},
shouldEnableStudio: true,
manifest: mockManifest,
})
expect(postStudioSessionStub).to.be.calledWith({
projectId: 'test-project-id',
projectId: 'abc123',
})
expect(readFileStub).to.be.calledWith(path.join(os.tmpdir(), 'cypress', 'studio', 'abc', 'server', 'index.js'), 'utf8')
@@ -335,7 +346,7 @@ describe('StudioLifecycleManager', () => {
})
expect(initializeTelemetryReporterStub).to.be.calledWith({
projectSlug: 'test-project-id',
projectSlug: 'abc123',
cloudDataSource: mockCloudDataSource,
})
@@ -367,7 +378,6 @@ describe('StudioLifecycleManager', () => {
})
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -395,7 +405,7 @@ describe('StudioLifecycleManager', () => {
script: 'console.log("studio script")',
studioPath: '/path/to/studio',
studioHash: 'local',
projectSlug: 'test-project-id',
getProjectOptions: sinon.match.func,
cloudApi: {
cloudUrl: 'https://cloud.cypress.io',
cloudHeaders: { 'Authorization': 'Bearer test-token' },
@@ -403,12 +413,11 @@ describe('StudioLifecycleManager', () => {
isRetryableError,
asyncRetry,
},
shouldEnableStudio: true,
manifest: {},
})
expect(postStudioSessionStub).to.be.calledWith({
projectId: 'test-project-id',
projectId: 'abc123',
})
expect(readFileStub).to.be.calledWith(path.join('/path', 'to', 'studio', 'server', 'index.js'), 'utf8')
@@ -485,7 +494,6 @@ describe('StudioLifecycleManager', () => {
ensureStudioBundleStub.resolves(mockManifest)
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -502,7 +510,7 @@ describe('StudioLifecycleManager', () => {
expect(reportStudioErrorStub).to.be.calledOnce
expect(reportStudioErrorStub).to.be.calledWithMatch({
cloudApi: sinon.match.object,
studioHash: 'test-project-id',
studioHash: 'abc',
projectSlug: 'abc123',
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Expected hash for studio server script not found in manifest')),
studioMethod: 'initializeStudioManager',
@@ -532,7 +540,6 @@ describe('StudioLifecycleManager', () => {
ensureStudioBundleStub.resolves(mockManifest)
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -549,7 +556,7 @@ describe('StudioLifecycleManager', () => {
expect(reportStudioErrorStub).to.be.calledOnce
expect(reportStudioErrorStub).to.be.calledWithMatch({
cloudApi: sinon.match.object,
studioHash: 'test-project-id',
studioHash: 'abc',
projectSlug: 'abc123',
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Invalid hash for studio server script')),
studioMethod: 'initializeStudioManager',
@@ -579,7 +586,6 @@ describe('StudioLifecycleManager', () => {
})
await studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -598,7 +604,7 @@ describe('StudioLifecycleManager', () => {
expect(reportStudioErrorStub).to.be.calledOnce
expect(reportStudioErrorStub).to.be.calledWithMatch({
cloudApi: sinon.match.object,
studioHash: 'test-project-id',
studioHash: 'abc',
projectSlug: 'abc123',
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Test error')),
studioMethod: 'initializeStudioManager',
@@ -618,7 +624,7 @@ describe('StudioLifecycleManager', () => {
}
expect(initializeTelemetryReporterStub).to.be.calledWith({
projectSlug: 'test-project-id',
projectSlug: 'abc123',
cloudDataSource: mockCloudDataSource,
})
@@ -776,7 +782,6 @@ describe('StudioLifecycleManager', () => {
])
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -848,7 +853,6 @@ describe('StudioLifecycleManager', () => {
const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
cfg: mockCfg,
debugData: {},
@@ -875,7 +879,6 @@ describe('StudioLifecycleManager', () => {
const statusChangesSpy = sinon.spy(studioLifecycleManager as any, 'updateStatus')
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
cfg: mockCfg,
debugData: {},
@@ -924,7 +927,6 @@ describe('StudioLifecycleManager', () => {
// First initialize with some state
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -988,28 +990,39 @@ describe('StudioLifecycleManager', () => {
})
it('clears the current studio hash from cached bundle promises on retry', async () => {
const mockManifest = {
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
}
ensureStudioBundleStub.resolves(mockManifest)
// Add some cached promises to the static map
const dummyPromise = Promise.resolve()
const dummyPromise = Promise.resolve({
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
})
// @ts-expect-error - accessing private static property
StudioLifecycleManager.hashLoadingMap.set('test-hash-1', dummyPromise)
// @ts-expect-error - accessing private static property
StudioLifecycleManager.hashLoadingMap.set('abc', dummyPromise) // This should be the current hash (from studioUrl)
// @ts-expect-error - accessing private static property
expect(StudioLifecycleManager.hashLoadingMap.size).to.equal(2)
// Initialize with ctx so retry will work
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
debugData: {},
})
// @ts-expect-error - accessing private property
studioLifecycleManager.currentStudioHash = 'abc'
// @ts-expect-error - accessing private static property
expect(StudioLifecycleManager.hashLoadingMap.size).to.equal(2)
// Wait for initialization to complete
await new Promise((resolve) => {
studioLifecycleManager.registerStudioReadyListener(() => {
resolve(true)
})
})
studioLifecycleManager.retry()
@@ -1020,6 +1033,13 @@ describe('StudioLifecycleManager', () => {
expect(StudioLifecycleManager.hashLoadingMap.has('abc')).to.be.false
// @ts-expect-error - accessing private static property
expect(StudioLifecycleManager.hashLoadingMap.size).to.equal(1)
// Wait for retry to complete
await new Promise((resolve) => {
studioLifecycleManager.registerStudioReadyListener(() => {
resolve(true)
})
})
})
it('clears the local hash when using local studio path', async () => {
@@ -1038,7 +1058,6 @@ describe('StudioLifecycleManager', () => {
// Initialize with ctx so retry will work
studioLifecycleManager.initializeStudioManager({
projectId: 'test-project-id',
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
@@ -1054,6 +1073,13 @@ describe('StudioLifecycleManager', () => {
studioLifecycleManager.retry()
// Wait for retry to complete
await new Promise((resolve) => {
studioLifecycleManager.registerStudioReadyListener(() => {
resolve(true)
})
})
// Verify only the 'local' hash was cleared
// @ts-expect-error - accessing private static property
expect(StudioLifecycleManager.hashLoadingMap.has('test-hash-1')).to.be.true
@@ -46,9 +46,10 @@ describe('lib/cloud/studio', () => {
script: stubStudio,
studioPath: 'path',
studioHash: 'abcdefg',
projectSlug: '1234',
getProjectOptions: sinon.stub().resolves({
projectSlug: '1234',
}),
cloudApi: {} as any,
shouldEnableStudio: true,
manifest: {
'server/index.js': 'abcdefg',
},
@@ -102,29 +103,6 @@ describe('lib/cloud/studio', () => {
})
})
describe('createInErrorManager', () => {
it('creates a studio manager in error state', () => {
const error = new Error('foo')
const manager = StudioManager.createInErrorManager({
error,
cloudApi: {} as any,
studioHash: 'abcdefg',
projectSlug: '1234',
studioMethod: 'initializeRoutes',
})
expect(manager.status).to.eq('IN_ERROR')
expect(reportStudioError).to.be.calledWithMatch({
error,
cloudApi: {} as any,
studioHash: 'abcdefg',
projectSlug: '1234',
studioMethod: 'initializeRoutes',
studioMethodArgs: undefined,
})
})
})
describe('initializeRoutes', () => {
it('initializes routes', () => {
sinon.stub(studio, 'initializeRoutes')
@@ -233,6 +211,83 @@ describe('lib/cloud/studio', () => {
})
})
describe('captureStudioEvent', () => {
it('captures a studio event', async () => {
sinon.stub(studio, 'captureStudioEvent').resolves()
await studioManager.captureStudioEvent({
type: 'studio:started',
machineId: 'test-machine-id',
})
expect(studio.captureStudioEvent).to.be.calledWith({
type: 'studio:started',
machineId: 'test-machine-id',
})
})
it('does not call captureStudioEvent when studio server is not defined', () => {
// Set _studioServer to undefined
(studioManager as any)._studioServer = undefined
// Create a spy on invokeSync to verify it's not called
const invokeSyncSpy = sinon.spy(studioManager, 'invokeSync')
studioManager.captureStudioEvent({
type: 'studio:started',
machineId: 'test-machine-id',
})
expect(invokeSyncSpy).to.not.be.called
})
})
describe('updateSessionId', () => {
it('updates the session ID', () => {
sinon.stub(studio, 'updateSessionId')
const mockSessionId = 'test-session-id'
studioManager.updateSessionId(mockSessionId)
expect(studio.updateSessionId).to.be.calledWith(mockSessionId)
})
it('does not call updateSessionId when studio server is not defined', () => {
// Set _studioServer to undefined
(studioManager as any)._studioServer = undefined
// Create a spy on invokeSync to verify it's not called
const invokeSyncSpy = sinon.spy(studioManager, 'invokeSync')
studioManager.updateSessionId('test-session-id')
expect(invokeSyncSpy).to.not.be.called
})
it('does not call updateSessionId when _studioServer.updateSessionId is not a function', () => {
// Set _studioServer.updateSessionId to undefined
(studioManager as any)._studioServer.updateSessionId = undefined
// Create a spy on invokeSync to verify it's not called
const invokeSyncSpy = sinon.spy(studioManager, 'invokeSync')
studioManager.updateSessionId('test-session-id')
expect(invokeSyncSpy).to.not.be.called
})
})
describe('reportError', () => {
it('reports an error', () => {
sinon.stub(studio, 'reportError')
const error = new Error('foo')
studioManager.reportError(error, 'reportError', 'test-args')
expect(studio.reportError).to.be.calledWith(error, 'reportError', 'test-args')
})
})
describe('destroy', () => {
it('destroys the studio server', async () => {
sinon.stub(studio, 'destroy').resolves()
@@ -71,10 +71,26 @@ export type StudioElectronApi = {
createBrowserWindow: () => BrowserWindow
}
export interface StudioAuthenticatedUserShape {
id?: string // Cloud user id
name?: string
email?: string
authToken?: string
}
export interface StudioProjectOptions {
user?: StudioAuthenticatedUserShape
projectSlug?: string
}
export interface StudioServerOptions {
studioHash?: string
studioPath: string
/**
* @deprecated use getProjectOptions instead
*/
projectSlug?: string
getProjectOptions: () => Promise<StudioProjectOptions>
cloudApi: StudioCloudApi
betterSqlite3Path: string
sessionId?: string