Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions packages/data-context/src/data/ProjectConfigIpc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable no-dupe-class-members */
import { CypressError, getError } from '@packages/errors'
import type { DebugData } from '@packages/types/src/debug'
import type { FullConfig, TestingType } from '@packages/types'
import { ChildProcess, fork, ForkOptions, spawn } from 'child_process'
import EventEmitter from 'events'
Expand Down Expand Up @@ -50,10 +51,6 @@ interface SerializedLoadConfigReply {
requires: string[]
}

export interface DebugData {
filePreprocessorHandlerText?: string
}

/**
* The ProjectConfigIpc is an EventEmitter wrapping the childProcess,
* adding a "send" method for sending events from the parent process into the childProcess,
Expand Down
3 changes: 2 additions & 1 deletion packages/data-context/src/data/ProjectConfigManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CypressError, getError } from '@packages/errors'
import { DebugData, PluginIpcHandler, LoadConfigReply, ProjectConfigIpc, SetupNodeEventsReply } from './ProjectConfigIpc'
import { PluginIpcHandler, LoadConfigReply, ProjectConfigIpc, SetupNodeEventsReply } from './ProjectConfigIpc'
import assert from 'assert'
import type { DebugData } from '@packages/types/src/debug'
import type { AllModeOptions, FullConfig, TestingType } from '@packages/types'
import debugLib from 'debug'
import path from 'path'
Expand Down
6 changes: 4 additions & 2 deletions packages/server/lib/cloud/studio/StudioLifecycleManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { INITIALIZATION_TELEMETRY_GROUP_NAMES } from './telemetry/constants/init
import crypto from 'crypto'
import { logError } from '@packages/stderr-filtering'
import { isNonRetriableCertErrorCode } from '../network/non_retriable_cert_error_codes'
import type { DebugData } from '@packages/types/src/debug'

const debug = Debug('cypress:server:studio-lifecycle-manager')
const routes = require('../routes')
Expand Down Expand Up @@ -179,7 +180,7 @@ export class StudioLifecycleManager {
}: {
cloudDataSource: CloudDataSource
cfg: Cfg
debugData: any
debugData?: DebugData
getProjectOptions: Required<StudioServerOptions>['getProjectOptions']
}): Promise<StudioManager> {
let studioPath: string
Expand Down Expand Up @@ -277,6 +278,7 @@ export class StudioLifecycleManager {
},
manifest,
getProjectOptions,
debugData,
})

telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_END)
Expand Down Expand Up @@ -349,7 +351,7 @@ export class StudioLifecycleManager {
}: {
cloudDataSource: CloudDataSource
cfg: Cfg
debugData: any
debugData?: DebugData
getProjectOptions: Required<StudioServerOptions>['getProjectOptions']
}) {
// Don't setup a watcher if the studio bundle is NOT local
Expand Down
5 changes: 4 additions & 1 deletion packages/server/lib/cloud/studio/studio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from 'path'
import crypto, { BinaryLike } from 'crypto'
import { StudioElectron } from './StudioElectron'
import exception from '../exception'
import type { DebugData } from '@packages/types/src/debug'

interface StudioServer { default: StudioServerDefaultShape }

Expand All @@ -16,6 +17,7 @@ interface SetupOptions {
cloudApi: StudioCloudApi
manifest: Record<string, string>
getProjectOptions: StudioServerOptions['getProjectOptions']
debugData?: DebugData
}

const debug = Debug('cypress:server:studio')
Expand All @@ -26,7 +28,7 @@ export class StudioManager implements StudioManagerShape {
private _studioServer: StudioServerShape | undefined
private _studioElectron: StudioElectron | undefined

async setup ({ script, studioPath, studioHash, cloudApi, manifest, getProjectOptions }: SetupOptions): Promise<void> {
async setup ({ script, studioPath, studioHash, cloudApi, manifest, getProjectOptions, debugData }: SetupOptions): Promise<void> {
const { createStudioServer } = requireScript<StudioServer>(script).default

this._studioServer = await createStudioServer({
Expand All @@ -47,6 +49,7 @@ export class StudioManager implements StudioManagerShape {
return actualHash === expectedHash
},
getProjectOptions,
debugData,
})

this.status = 'ENABLED'
Expand Down
107 changes: 107 additions & 0 deletions packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ describe('StudioLifecycleManager', () => {
asyncRetry,
},
manifest: mockManifest,
debugData: {},
})

expect(postStudioSessionStub).to.be.calledWith({
Expand Down Expand Up @@ -332,6 +333,7 @@ describe('StudioLifecycleManager', () => {
asyncRetry,
},
manifest: {},
debugData: {},
})

expect(postStudioSessionStub).to.be.calledWith({
Expand Down Expand Up @@ -392,6 +394,111 @@ describe('StudioLifecycleManager', () => {
expect(await mockStudioManagerPromise).to.equal(updatedStudioManager)
})

it('initializes the studio manager and sends debug data to the studio server', async () => {
const debugData = {
filePreprocessorHandlerText: 'handler text',
}

studioManagerSetupStub.callsFake((args) => {
mockStudioManager.status = 'ENABLED'

return Promise.resolve()
})

studioLifecycleManager.initializeStudioManager({
cloudDataSource: mockCloudDataSource,
ctx: mockCtx,
cfg: mockCfg,
debugData,
})

const studioReadyPromise = new Promise((resolve) => {
studioLifecycleManager?.registerStudioReadyListener((studioManager) => {
resolve(studioManager)
})
})

const mockManifest = {
'server/index.js': 'e1ed3dc8ba9eb8ece23914004b99ad97bba37e80a25d8b47c009e1e4948a6159',
}

ensureStudioBundleStub.resolves(mockManifest)

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: 'abc123',
})

expect(studioManagerSetupStub).to.be.calledWith({
script: 'console.log("studio script")',
studioPath: path.join(os.tmpdir(), 'cypress', 'studio', 'abc'),
studioHash: 'abc',
getProjectOptions: sinon.match.func,
cloudApi: {
cloudUrl: 'https://cloud.cypress.io',
cloudHeaders: { 'Authorization': 'Bearer test-token' },
CloudRequest,
isRetryableError,
asyncRetry,
},
manifest: mockManifest,
debugData,
})

expect(postStudioSessionStub).to.be.calledWith({
projectId: 'abc123',
})

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',
})

expect(initializeTelemetryReporterStub).to.be.calledWith({
projectSlug: 'abc123',
cloudDataSource: mockCloudDataSource,
})

expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.BUNDLE_LIFECYCLE_START)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.BUNDLE_LIFECYCLE_END)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.POST_STUDIO_SESSION_START)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.POST_STUDIO_SESSION_END)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_START)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_END)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_START)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_MANAGER_SETUP_END)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_PROTOCOL_GET_START)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_PROTOCOL_GET_END)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_PROTOCOL_PREPARE_START)
expect(markStub).to.be.calledWith(BUNDLE_LIFECYCLE_MARK_NAMES.STUDIO_PROTOCOL_PREPARE_END)

expect(reportTelemetryStub).to.be.calledWith(BUNDLE_LIFECYCLE_TELEMETRY_GROUP_NAMES.COMPLETE_BUNDLE_LIFECYCLE, {
success: true,
})
})

it('throws an error when the studio server script is not found in the manifest', async () => {
studioManagerSetupStub.callsFake((args) => {
mockStudioManager.status = 'ENABLED'
Expand Down
83 changes: 83 additions & 0 deletions packages/server/test/unit/cloud/studio/studio_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,89 @@ describe('lib/cloud/studio', () => {
sinon.restore()
})

describe('setup', () => {
it('passes debugData to createStudioServer when provided', async () => {
const createStudioServerStub = sinon.stub().resolves({
initializeRoutes: sinon.stub(),
canAccessStudioAI: sinon.stub().resolves(true),
initializeStudioAI: sinon.stub().resolves(),
reportError: sinon.stub(),
destroy: sinon.stub().resolves(),
addSocketListeners: sinon.stub(),
captureStudioEvent: sinon.stub().resolves(),
updateSessionId: sinon.stub(),
connectToBrowser: sinon.stub(),
})

const StubbedStudioManager = (proxyquire('../lib/cloud/studio/studio', {
'../api/studio/report_studio_error': { reportStudioError: sinon.stub() },
'./StudioElectron': { StudioElectron: class {} },
'../require_script': {
requireScript: () => ({
default: { createStudioServer: createStudioServerStub },
}),
},
}) as typeof import('@packages/server/lib/cloud/studio/studio')).StudioManager

const manager = new StubbedStudioManager()
const debugData = { filePreprocessorHandlerText: 'handler text' }

await manager.setup({
script: 'script',
studioPath: 'path',
studioHash: 'abcdefg',
getProjectOptions: sinon.stub().resolves({ projectSlug: '1234' }),
cloudApi: {} as any,
manifest: { 'server/index.js': 'abcdefg' },
debugData,
})

expect(createStudioServerStub).to.have.been.calledOnce
expect(createStudioServerStub.firstCall.args[0].debugData).to.deep.equal(debugData)
})

it('passes undefined debugData to createStudioServer when not provided', async () => {
const createStudioServerStub = sinon.stub().resolves({
initializeRoutes: sinon.stub(),
canAccessStudioAI: sinon.stub().resolves(true),
initializeStudioAI: sinon.stub().resolves(),
reportError: sinon.stub(),
destroy: sinon.stub().resolves(),
addSocketListeners: sinon.stub(),
captureStudioEvent: sinon.stub().resolves(),
updateSessionId: sinon.stub(),
connectToBrowser: sinon.stub(),
})

const StubbedStudioManager = (proxyquire('../lib/cloud/studio/studio', {
'../api/studio/report_studio_error': { reportStudioError: sinon.stub() },
'./StudioElectron': { StudioElectron: class {} },
'../require_script': {
requireScript: () => ({
default: { createStudioServer: createStudioServerStub },
}),
},
}) as typeof import('@packages/server/lib/cloud/studio/studio')).StudioManager

const manager = new StubbedStudioManager()

await manager.setup({
script: 'script',
studioPath: 'path',
studioHash: 'abcdefg',
getProjectOptions: sinon.stub().resolves({ projectSlug: '1234' }),
cloudApi: {} as any,
manifest: { 'server/index.js': 'abcdefg' },
})

expect(createStudioServerStub).to.have.been.calledOnce
const options = createStudioServerStub.firstCall.args[0]

expect(options).to.have.property('debugData')
expect(options.debugData).to.be.undefined
})
})

describe('synchronous method invocation', () => {
it('reports an error when a synchronous method fails', () => {
const error = new Error('foo')
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Debug data shared by config loading, protocol manager, and studio.
* Used to surface diagnostic information (e.g. file preprocessor handler text).
*/
export interface DebugData {
filePreprocessorHandlerText?: string
}
2 changes: 2 additions & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export * from './reporter'

export * from './server'

export * from './debug'

export * from './util'

export * from './types'
Expand Down
5 changes: 2 additions & 3 deletions packages/types/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type Database from 'better-sqlite3'
import type ProtocolMapping from 'devtools-protocol/types/protocol-mapping'
import type { IncomingHttpHeaders } from 'http'
import type { Readable } from 'stream'
import type { DebugData } from './debug'
import type { ProxyTimings } from './proxy'
import type { FoundSpec } from './spec'

Expand Down Expand Up @@ -105,9 +106,7 @@ export type ProtocolManagerOptions = {
}
projectConfig: ProjectConfig
mountVersion?: number
debugData?: {
filePreprocessorHandlerText?: string
}
debugData?: DebugData
mode?: 'record' | 'studio'
}

Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/studio/studio-server-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { Router } from 'express'
import type { AxiosInstance } from 'axios'
import type { Socket } from 'socket.io'
import type { BinaryLike } from 'crypto'
import type { DebugData } from '../debug'

export const StudioMetricsTypes = {
STUDIO_STARTED: 'studio:started',
Expand Down Expand Up @@ -100,6 +101,7 @@ export interface StudioServerOptions {
manifest?: Record<string, string>
verifyHash: (contents: BinaryLike, expectedHash: string) => boolean
studioElectron?: StudioElectronApi
debugData?: DebugData
}

export interface StudioAIInitializeOptions {
Expand Down
Loading