Skip to content

Commit a84b9c9

Browse files
authored
Merge branch 'develop' into electron-34
2 parents d93bae8 + 5a1ffb3 commit a84b9c9

24 files changed

+474
-122
lines changed

packages/app/src/studio/studio-app-types.ts

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
export interface StudioPanelProps {
22
canAccessStudioAI: boolean
3-
onStudioPanelClose: () => void
4-
useStudioEventManager?: StudioEventManagerShape
3+
onStudioPanelClose?: () => void
4+
useRunnerStatus?: RunnerStatusShape
5+
useTestContentRetriever?: TestContentRetrieverShape
56
useStudioAIStream?: StudioAIStreamShape
7+
useCypress?: CypressShape
68
}
79

810
export type StudioPanelShape = (props: StudioPanelProps) => JSX.Element
@@ -18,21 +20,49 @@ CyEventEmitter & {
1820
state: (key: string) => any
1921
}
2022

21-
export interface StudioEventManagerProps {
22-
Cypress: CypressInternal
23+
export interface TestBlock {
24+
content: string
25+
testBodyPosition: {
26+
contentStart: number
27+
contentEnd: number
28+
indentation: number
29+
}
2330
}
2431

2532
export type RunnerStatus = 'running' | 'finished'
2633

27-
export type StudioEventManagerShape = (props: StudioEventManagerProps) => {
34+
export interface RunnerStatusProps {
35+
Cypress: CypressInternal
36+
}
37+
38+
export interface CypressProps {
39+
Cypress: CypressInternal
40+
}
41+
42+
export type CypressShape = (props: CypressProps) => {
43+
currentCypress: CypressInternal
44+
}
45+
46+
export type RunnerStatusShape = (props: RunnerStatusProps) => {
2847
runnerStatus: RunnerStatus
29-
testBlock: string | null
3048
}
3149

3250
export interface StudioAIStreamProps {
3351
canAccessStudioAI: boolean
3452
AIOutputRef: { current: HTMLTextAreaElement | null }
3553
runnerStatus: RunnerStatus
54+
testCode?: string
55+
isCreatingNewTest: boolean
3656
}
3757

3858
export type StudioAIStreamShape = (props: StudioAIStreamProps) => void
59+
60+
export interface TestContentRetrieverProps {
61+
Cypress: CypressInternal
62+
}
63+
64+
export type TestContentRetrieverShape = (props: TestContentRetrieverProps) => {
65+
isLoading: boolean
66+
testBlock: TestBlock | null
67+
isCreatingNewTest: boolean
68+
}

packages/example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
},
1616
"devDependencies": {
1717
"cross-env": "6.0.3",
18-
"cypress-example-kitchensink": "3.1.2",
18+
"cypress-example-kitchensink": "4.0.0",
1919
"gh-pages": "5.0.0",
2020
"gulp": "4.0.2",
2121
"gulp-clean": "0.4.0",

packages/server/lib/StudioLifecycleManager.ts

Lines changed: 58 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { reportStudioError } from './cloud/api/studio/report_studio_error'
1111
import { CloudRequest } from './cloud/api/cloud_request'
1212
import { isRetryableError } from './cloud/network/is_retryable_error'
1313
import { asyncRetry } from './util/async_retry'
14+
import { postStudioSession } from './cloud/api/studio/post_studio_session'
15+
1416
const debug = Debug('cypress:server:studio-lifecycle-manager')
1517
const routes = require('./cloud/routes')
1618

@@ -42,41 +44,11 @@ export class StudioLifecycleManager {
4244
}): void {
4345
debug('Initializing studio manager')
4446

45-
const studioManagerPromise = getAndInitializeStudioManager({
47+
const studioManagerPromise = this.createStudioManager({
4648
projectId,
4749
cloudDataSource,
48-
}).then(async (studioManager) => {
49-
if (studioManager.status === 'ENABLED') {
50-
debug('Cloud studio is enabled - setting up protocol')
51-
const protocolManager = new ProtocolManager()
52-
const protocolUrl = routes.apiRoutes.captureProtocolCurrent()
53-
const script = await api.getCaptureProtocolScript(protocolUrl)
54-
55-
await protocolManager.prepareProtocol(script, {
56-
runId: 'studio',
57-
projectId: cfg.projectId,
58-
testingType: cfg.testingType,
59-
cloudApi: {
60-
url: routes.apiUrl,
61-
retryWithBackoff: api.retryWithBackoff,
62-
requestPromise: api.rp,
63-
},
64-
projectConfig: _.pick(cfg, ['devServerPublicPathRoute', 'port', 'proxyUrl', 'namespace']),
65-
mountVersion: api.runnerCapabilities.protocolMountVersion,
66-
debugData,
67-
mode: 'studio',
68-
})
69-
70-
studioManager.protocolManager = protocolManager
71-
} else {
72-
debug('Cloud studio is not enabled - skipping protocol setup')
73-
}
74-
75-
debug('Studio is ready')
76-
this.studioManager = studioManager
77-
this.callRegisteredListeners()
78-
79-
return studioManager
50+
cfg,
51+
debugData,
8052
}).catch(async (error) => {
8153
debug('Error during studio manager setup: %o', error)
8254

@@ -125,6 +97,59 @@ export class StudioLifecycleManager {
12597
return await this.studioManagerPromise
12698
}
12799

100+
private async createStudioManager ({
101+
projectId,
102+
cloudDataSource,
103+
cfg,
104+
debugData,
105+
}: {
106+
projectId?: string
107+
cloudDataSource: CloudDataSource
108+
cfg: Cfg
109+
debugData: any
110+
}): Promise<StudioManager> {
111+
const studioSession = await postStudioSession({
112+
projectId,
113+
})
114+
115+
const studioManager = await getAndInitializeStudioManager({
116+
studioUrl: studioSession.studioUrl,
117+
projectId,
118+
cloudDataSource,
119+
})
120+
121+
if (studioManager.status === 'ENABLED') {
122+
debug('Cloud studio is enabled - setting up protocol')
123+
const protocolManager = new ProtocolManager()
124+
const script = await api.getCaptureProtocolScript(studioSession.protocolUrl)
125+
126+
await protocolManager.prepareProtocol(script, {
127+
runId: 'studio',
128+
projectId: cfg.projectId,
129+
testingType: cfg.testingType,
130+
cloudApi: {
131+
url: routes.apiUrl,
132+
retryWithBackoff: api.retryWithBackoff,
133+
requestPromise: api.rp,
134+
},
135+
projectConfig: _.pick(cfg, ['devServerPublicPathRoute', 'port', 'proxyUrl', 'namespace']),
136+
mountVersion: api.runnerCapabilities.protocolMountVersion,
137+
debugData,
138+
mode: 'studio',
139+
})
140+
141+
studioManager.protocolManager = protocolManager
142+
} else {
143+
debug('Cloud studio is not enabled - skipping protocol setup')
144+
}
145+
146+
debug('Studio is ready')
147+
this.studioManager = studioManager
148+
this.callRegisteredListeners()
149+
150+
return studioManager
151+
}
152+
128153
private callRegisteredListeners () {
129154
if (!this.studioManager) {
130155
throw new Error('Studio manager has not been initialized')

packages/server/lib/cloud/api/studio/get_and_initialize_studio_manager.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ import { PUBLIC_KEY_VERSION } from '../../constants'
1414
import { CloudRequest } from '../cloud_request'
1515
import type { CloudDataSource } from '@packages/data-context/src/sources'
1616

17+
interface Options {
18+
studioUrl: string
19+
projectId?: string
20+
}
21+
1722
const pkg = require('@packages/root')
18-
const routes = require('../../routes')
1923

2024
const _delay = linearDelay(500)
2125

@@ -24,11 +28,11 @@ export const studioPath = path.join(os.tmpdir(), 'cypress', 'studio')
2428
const bundlePath = path.join(studioPath, 'bundle.tar')
2529
const serverFilePath = path.join(studioPath, 'server', 'index.js')
2630

27-
const downloadStudioBundleToTempDirectory = async (projectId?: string): Promise<void> => {
31+
const downloadStudioBundleToTempDirectory = async ({ studioUrl, projectId }: Options): Promise<void> => {
2832
let responseSignature: string | null = null
2933

3034
await (asyncRetry(async () => {
31-
const response = await fetch(routes.apiRoutes.studio() as string, {
35+
const response = await fetch(studioUrl, {
3236
// @ts-expect-error - this is supported
3337
agent,
3438
method: 'GET',
@@ -90,7 +94,7 @@ const getTarHash = (): Promise<string> => {
9094
})
9195
}
9296

93-
export const retrieveAndExtractStudioBundle = async ({ projectId }: { projectId?: string } = {}): Promise<{ studioHash: string | undefined }> => {
97+
export const retrieveAndExtractStudioBundle = async ({ studioUrl, projectId }: Options): Promise<{ studioHash: string | undefined }> => {
9498
// First remove studioPath to ensure we have a clean slate
9599
await fs.promises.rm(studioPath, { recursive: true, force: true })
96100
await ensureDir(studioPath)
@@ -106,7 +110,7 @@ export const retrieveAndExtractStudioBundle = async ({ projectId }: { projectId?
106110
return { studioHash: undefined }
107111
}
108112

109-
await downloadStudioBundleToTempDirectory(projectId)
113+
await downloadStudioBundleToTempDirectory({ studioUrl, projectId })
110114

111115
const studioHash = await getTarHash()
112116

@@ -118,7 +122,7 @@ export const retrieveAndExtractStudioBundle = async ({ projectId }: { projectId?
118122
return { studioHash }
119123
}
120124

121-
export const getAndInitializeStudioManager = async ({ projectId, cloudDataSource }: { projectId?: string, cloudDataSource: CloudDataSource }): Promise<StudioManager> => {
125+
export const getAndInitializeStudioManager = async ({ studioUrl, projectId, cloudDataSource }: { studioUrl: string, projectId?: string, cloudDataSource: CloudDataSource }): Promise<StudioManager> => {
122126
let script: string
123127

124128
const cloudEnv = (process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
@@ -128,7 +132,7 @@ export const getAndInitializeStudioManager = async ({ projectId, cloudDataSource
128132
let studioHash: string | undefined
129133

130134
try {
131-
({ studioHash } = await retrieveAndExtractStudioBundle({ projectId }))
135+
({ studioHash } = await retrieveAndExtractStudioBundle({ studioUrl, projectId }))
132136

133137
script = await readFile(serverFilePath, 'utf8')
134138

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { asyncRetry, linearDelay } from '../../../util/async_retry'
2+
import { isRetryableError } from '../../network/is_retryable_error'
3+
import fetch from 'cross-fetch'
4+
import os from 'os'
5+
import { agent } from '@packages/network'
6+
7+
const pkg = require('@packages/root')
8+
const routes = require('../../routes') as typeof import('../../routes')
9+
10+
interface GetStudioSessionOptions {
11+
projectId?: string
12+
}
13+
14+
const _delay = linearDelay(500)
15+
16+
export const postStudioSession = async ({ projectId }: GetStudioSessionOptions) => {
17+
return await (asyncRetry(async () => {
18+
const response = await fetch(routes.apiRoutes.studioSession(), {
19+
// @ts-expect-error - this is supported
20+
agent,
21+
method: 'POST',
22+
headers: {
23+
'Content-Type': 'application/json',
24+
'x-os-name': os.platform(),
25+
'x-cypress-version': pkg.version,
26+
},
27+
body: JSON.stringify({ projectSlug: projectId, studioMountVersion: 1, protocolMountVersion: 2 }),
28+
})
29+
30+
if (!response.ok) {
31+
throw new Error('Failed to create studio session')
32+
}
33+
34+
const data = await response.json()
35+
36+
return {
37+
studioUrl: data.studioUrl,
38+
protocolUrl: data.protocolUrl,
39+
}
40+
}, {
41+
maxAttempts: 3,
42+
retryDelay: _delay,
43+
shouldRetry: isRetryableError,
44+
}))()
45+
}

packages/server/lib/cloud/api/studio/report_studio_error.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,15 @@ export function reportStudioError ({
3737
}: ReportStudioErrorOptions): void {
3838
debug('Error reported:', error)
3939

40-
// When developing locally, we want to throw the error so we can see it in the console
41-
if (process.env.CYPRESS_LOCAL_STUDIO_PATH) {
42-
throw error
40+
// When developing locally, do not send to Sentry, but instead log to console.
41+
if (
42+
process.env.CYPRESS_LOCAL_STUDIO_PATH ||
43+
process.env.NODE_ENV === 'development'
44+
) {
45+
// eslint-disable-next-line no-console
46+
console.error(`Error in ${studioMethod}:`, error)
47+
48+
return
4349
}
4450

4551
let errorObject: Error

packages/server/lib/cloud/routes.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ const CLOUD_ENDPOINTS = {
1616
instanceStdout: 'instances/:id/stdout',
1717
instanceArtifacts: 'instances/:id/artifacts',
1818
captureProtocolErrors: 'capture-protocol/errors',
19-
captureProtocolCurrent: 'capture-protocol/script/current.js',
20-
studio: 'studio/bundle/current.tgz',
19+
studioSession: 'studio/session',
2120
studioErrors: 'studio/errors',
2221
exceptions: 'exceptions',
2322
telemetry: 'telemetry',

packages/server/lib/cloud/studio.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions } from '@packages/types'
1+
import type { StudioManagerShape, StudioStatus, StudioServerDefaultShape, StudioServerShape, ProtocolManagerShape, StudioCloudApi, StudioAIInitializeOptions, StudioEvent } from '@packages/types'
22
import type { Router } from 'express'
33
import type { Socket } from 'socket.io'
44
import Debug from 'debug'
@@ -61,6 +61,15 @@ export class StudioManager implements StudioManagerShape {
6161
}
6262
}
6363

64+
async captureStudioEvent (event: StudioEvent): Promise<void> {
65+
if (this._studioServer) {
66+
// this request is not essential - we don't want studio to error out if a telemetry request fails
67+
return (await this.invokeAsync('captureStudioEvent', { isEssential: false }, event))
68+
}
69+
70+
return Promise.resolve()
71+
}
72+
6473
addSocketListeners (socket: Socket): void {
6574
if (this._studioServer) {
6675
this.invokeSync('addSocketListeners', { isEssential: true }, socket)
@@ -119,7 +128,7 @@ export class StudioManager implements StudioManagerShape {
119128
}
120129

121130
/**
122-
* Abstracts invoking a synchronous method on the StudioServer instance, so we can handle
131+
* Abstracts invoking an asynchronous method on the StudioServer instance, so we can handle
123132
* errors in a uniform way
124133
*/
125134
private async invokeAsync <K extends StudioServerAsyncMethods> (method: K, { isEssential }: { isEssential: boolean }, ...args: Parameters<StudioServerShape[K]>): Promise<ReturnType<StudioServerShape[K]> | undefined> {
@@ -139,7 +148,11 @@ export class StudioManager implements StudioManagerShape {
139148
actualError = error
140149
}
141150

142-
this.status = 'IN_ERROR'
151+
// only set error state if this request is essential
152+
if (isEssential) {
153+
this.status = 'IN_ERROR'
154+
}
155+
143156
this.reportError(actualError, method, ...args)
144157

145158
return undefined

0 commit comments

Comments
 (0)