Skip to content

Commit ade5d03

Browse files
chore: Handle cy-prompt error to the cloud (#32009)
Co-authored-by: Ryan Manuel <[email protected]>
1 parent 15c0396 commit ade5d03

File tree

6 files changed

+184
-37
lines changed

6 files changed

+184
-37
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import type { CyPromptCloudApi } from '@packages/types/src/cy-prompt/cy-prompt-server-types'
2+
import Debug from 'debug'
3+
import { stripPath } from '../../strip_path'
4+
const debug = Debug('cypress:server:cloud:api:cy-prompt:report_cy-prompt_error')
5+
6+
export interface ReportCyPromptErrorOptions {
7+
cloudApi: CyPromptCloudApi
8+
cyPromptHash: string | undefined
9+
projectSlug: string | undefined
10+
error: unknown
11+
cyPromptMethod: string
12+
cyPromptMethodArgs?: unknown[]
13+
}
14+
15+
interface CyPromptError {
16+
name: string
17+
stack: string
18+
message: string
19+
cyPromptMethod: string
20+
cyPromptMethodArgs?: string
21+
}
22+
23+
interface CyPromptErrorPayload {
24+
cyPromptHash: string | undefined
25+
projectSlug: string | undefined
26+
errors: CyPromptError[]
27+
}
28+
29+
export function reportCyPromptError ({
30+
cloudApi,
31+
cyPromptHash,
32+
projectSlug,
33+
error,
34+
cyPromptMethod,
35+
cyPromptMethodArgs,
36+
}: ReportCyPromptErrorOptions): void {
37+
debug('Error reported:', error)
38+
39+
// When developing locally, do not send to Sentry, but instead log to console.
40+
if (
41+
process.env.CYPRESS_LOCAL_CY_PROMPT_PATH ||
42+
process.env.NODE_ENV === 'development' ||
43+
process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF
44+
) {
45+
// eslint-disable-next-line no-console
46+
console.error(`Error in ${cyPromptMethod}:`, error)
47+
48+
return
49+
}
50+
51+
let errorObject: Error
52+
53+
if (!(error instanceof Error)) {
54+
errorObject = new Error(String(error))
55+
} else {
56+
errorObject = error
57+
}
58+
59+
let cyPromptMethodArgsString: string | undefined
60+
61+
if (cyPromptMethodArgs) {
62+
try {
63+
cyPromptMethodArgsString = JSON.stringify({
64+
args: cyPromptMethodArgs,
65+
})
66+
} catch (e: unknown) {
67+
cyPromptMethodArgsString = `Unknown args: ${e}`
68+
}
69+
}
70+
71+
try {
72+
const payload: CyPromptErrorPayload = {
73+
cyPromptHash,
74+
projectSlug,
75+
errors: [{
76+
name: stripPath(errorObject.name ?? `Unknown name`),
77+
stack: stripPath(errorObject.stack ?? `Unknown stack`),
78+
message: stripPath(errorObject.message ?? `Unknown message`),
79+
cyPromptMethod,
80+
cyPromptMethodArgs: cyPromptMethodArgsString,
81+
}],
82+
}
83+
84+
cloudApi.CloudRequest.post(
85+
`${cloudApi.cloudUrl}/cy-prompt/errors`,
86+
payload,
87+
{
88+
headers: {
89+
'Content-Type': 'application/json',
90+
...cloudApi.cloudHeaders,
91+
},
92+
},
93+
).catch((e: unknown) => {
94+
debug(
95+
`Error calling CyPromptManager.reportError: %o, original error %o`,
96+
e,
97+
error,
98+
)
99+
})
100+
} catch (e: unknown) {
101+
debug(
102+
`Error calling CyPromptManager.reportError: %o, original error %o`,
103+
e,
104+
error,
105+
)
106+
}
107+
}

packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import chokidar from 'chokidar'
1414
import { getCloudMetadata } from '../get_cloud_metadata'
1515
import type { CyPromptAuthenticatedUserShape } from '@packages/types'
1616
import crypto from 'crypto'
17+
import { reportCyPromptError } from '../api/cy-prompt/report_cy-prompt_error'
1718

1819
const debug = Debug('cypress:server:cy-prompt-lifecycle-manager')
1920

@@ -26,6 +27,7 @@ export class CyPromptLifecycleManager {
2627
}>
2728
private cyPromptManager?: CyPromptManager
2829
private listeners: ((cyPromptManager: CyPromptManager) => void)[] = []
30+
private cyPromptHash: string | undefined
2931

3032
/**
3133
* Initialize the cy prompt manager.
@@ -66,25 +68,24 @@ export class CyPromptLifecycleManager {
6668
}).catch(async (error) => {
6769
debug('Error during cy prompt manager setup: %o', error)
6870

69-
// const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
70-
// const cloudUrl = ctx.cloud.getCloudUrl(cloudEnv)
71-
// const cloudHeaders = await ctx.cloud.additionalHeaders()
72-
73-
// TODO: reportCyPromptError
74-
// reportCyPromptError({
75-
// cloudApi: {
76-
// cloudUrl,
77-
// cloudHeaders,
78-
// CloudRequest,
79-
// isRetryableError,
80-
// asyncRetry,
81-
// },
82-
// cyPromptHash: projectId,
83-
// projectSlug: cfg.projectId,
84-
// error,
85-
// cyPromptMethod: 'initializeCyPromptManager',
86-
// cyPromptMethodArgs: [],
87-
// })
71+
const cloudEnv = (process.env.CYPRESS_CONFIG_ENV || process.env.CYPRESS_INTERNAL_ENV || 'production') as 'development' | 'staging' | 'production'
72+
const cloudUrl = ctx.cloud.getCloudUrl(cloudEnv)
73+
const cloudHeaders = await ctx.cloud.additionalHeaders()
74+
75+
reportCyPromptError({
76+
cloudApi: {
77+
cloudUrl,
78+
cloudHeaders,
79+
CloudRequest,
80+
isRetryableError,
81+
asyncRetry,
82+
},
83+
cyPromptHash: this.cyPromptHash,
84+
projectSlug: (await ctx.project.getConfig()).projectId || undefined,
85+
error,
86+
cyPromptMethod: 'initializeCyPromptManager',
87+
cyPromptMethodArgs: [],
88+
})
8889

8990
// Clean up any registered listeners
9091
this.listeners = []
@@ -123,7 +124,6 @@ export class CyPromptLifecycleManager {
123124
key?: string
124125
}>
125126
}): Promise<{ cyPromptManager?: CyPromptManager, error?: Error }> {
126-
let cyPromptHash: string
127127
let cyPromptPath: string
128128
let manifest: Record<string, string>
129129

@@ -135,10 +135,10 @@ export class CyPromptLifecycleManager {
135135

136136
if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) {
137137
// The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension
138-
cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0]
139-
cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash)
138+
this.cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] as string
139+
cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', this.cyPromptHash)
140140

141-
let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(cyPromptHash)
141+
let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(this.cyPromptHash)
142142

143143
if (!hashLoadingPromise) {
144144
hashLoadingPromise = ensureCyPromptBundle({
@@ -147,13 +147,13 @@ export class CyPromptLifecycleManager {
147147
cyPromptPath,
148148
})
149149

150-
CyPromptLifecycleManager.hashLoadingMap.set(cyPromptHash, hashLoadingPromise)
150+
CyPromptLifecycleManager.hashLoadingMap.set(this.cyPromptHash, hashLoadingPromise)
151151
}
152152

153153
manifest = await hashLoadingPromise
154154
} else {
155155
cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH
156-
cyPromptHash = 'local'
156+
this.cyPromptHash = 'local'
157157
manifest = {}
158158
}
159159

@@ -181,7 +181,7 @@ export class CyPromptLifecycleManager {
181181
await cyPromptManager.setup({
182182
script,
183183
cyPromptPath,
184-
cyPromptHash,
184+
cyPromptHash: this.cyPromptHash,
185185
cloudApi: {
186186
cloudUrl,
187187
CloudRequest,

packages/server/lib/cloud/studio/StudioLifecycleManager.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export class StudioLifecycleManager {
3535
private listeners: ((studioManager: StudioManager) => void)[] = []
3636
private ctx?: DataContext
3737
private lastStatus?: StudioStatus
38+
private studioHash: string | undefined
3839

3940
public get cloudStudioRequested () {
4041
return !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH)
@@ -91,7 +92,7 @@ export class StudioLifecycleManager {
9192
isRetryableError,
9293
asyncRetry,
9394
},
94-
studioHash: projectId,
95+
studioHash: this.studioHash,
9596
projectSlug: cfg.projectId,
9697
error,
9798
studioMethod: 'initializeStudioManager',
@@ -157,7 +158,6 @@ export class StudioLifecycleManager {
157158
debugData: any
158159
}): Promise<StudioManager> {
159160
let studioPath: string
160-
let studioHash: string
161161
let manifest: Record<string, string>
162162

163163
initializeTelemetryReporter({
@@ -177,10 +177,10 @@ export class StudioLifecycleManager {
177177
telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_START)
178178
if (!process.env.CYPRESS_LOCAL_STUDIO_PATH) {
179179
// The studio hash is the last part of the studio URL, after the last slash and before the extension
180-
studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0]
181-
studioPath = path.join(os.tmpdir(), 'cypress', 'studio', studioHash)
180+
this.studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0] as string
181+
studioPath = path.join(os.tmpdir(), 'cypress', 'studio', this.studioHash)
182182

183-
let hashLoadingPromise = StudioLifecycleManager.hashLoadingMap.get(studioHash)
183+
let hashLoadingPromise = StudioLifecycleManager.hashLoadingMap.get(this.studioHash)
184184

185185
if (!hashLoadingPromise) {
186186
hashLoadingPromise = ensureStudioBundle({
@@ -189,13 +189,13 @@ export class StudioLifecycleManager {
189189
projectId,
190190
})
191191

192-
StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise)
192+
StudioLifecycleManager.hashLoadingMap.set(this.studioHash, hashLoadingPromise)
193193
}
194194

195195
manifest = await hashLoadingPromise
196196
} else {
197197
studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH
198-
studioHash = 'local'
198+
this.studioHash = 'local'
199199
manifest = {}
200200
}
201201

@@ -227,7 +227,7 @@ export class StudioLifecycleManager {
227227
await studioManager.setup({
228228
script,
229229
studioPath,
230-
studioHash,
230+
studioHash: this.studioHash,
231231
projectSlug: projectId,
232232
cloudApi: {
233233
cloudUrl,

packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import os from 'os'
99
import { CloudRequest } from '../../../../lib/cloud/api/cloud_request'
1010
import { isRetryableError } from '../../../../lib/cloud/network/is_retryable_error'
1111
import { asyncRetry } from '../../../../lib/util/async_retry'
12+
import * as reportCyPromptErrorPath from '../../../../lib/cloud/api/cy-prompt/report_cy-prompt_error'
1213

1314
describe('CyPromptLifecycleManager', () => {
1415
let cyPromptLifecycleManager: CyPromptLifecycleManager
@@ -24,12 +25,14 @@ describe('CyPromptLifecycleManager', () => {
2425
let watcherStub: sinon.SinonStub = sinon.stub()
2526
let watcherOnStub: sinon.SinonStub = sinon.stub()
2627
let watcherCloseStub: sinon.SinonStub = sinon.stub()
28+
let reportCyPromptErrorStub: sinon.SinonStub
2729
const mockContents: string = 'console.log("cy-prompt script")'
2830

2931
beforeEach(() => {
3032
postCyPromptSessionStub = sinon.stub()
3133
cyPromptManagerSetupStub = sinon.stub()
3234
ensureCyPromptBundleStub = sinon.stub()
35+
reportCyPromptErrorStub = sinon.stub()
3336
cyPromptStatusChangeEmitterStub = sinon.stub()
3437
mockCyPromptManager = {
3538
status: 'INITIALIZED',
@@ -100,6 +103,8 @@ describe('CyPromptLifecycleManager', () => {
100103
postCyPromptSessionStub.resolves({
101104
cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz',
102105
})
106+
107+
reportCyPromptErrorStub = sinon.stub(reportCyPromptErrorPath, 'reportCyPromptError').resolves()
103108
})
104109

105110
afterEach(() => {
@@ -322,6 +327,23 @@ describe('CyPromptLifecycleManager', () => {
322327
const { error } = await cyPromptPromise
323328

324329
expect(error.message).to.equal('Expected hash for cy prompt server script not found in manifest')
330+
331+
expect(reportCyPromptErrorStub).to.be.calledWith({
332+
cloudApi: {
333+
cloudUrl: 'https://cloud.cypress.io',
334+
CloudRequest,
335+
isRetryableError,
336+
asyncRetry,
337+
cloudHeaders: {
338+
'Authorization': 'Bearer test-token',
339+
},
340+
},
341+
cyPromptHash: 'abc',
342+
projectSlug: 'test-project-id',
343+
error,
344+
cyPromptMethod: 'initializeCyPromptManager',
345+
cyPromptMethodArgs: [],
346+
})
325347
})
326348

327349
it('throws an error when the cy-prompt server script is wrong in the manifest', async () => {
@@ -350,6 +372,23 @@ describe('CyPromptLifecycleManager', () => {
350372
const { error } = await cyPromptPromise
351373

352374
expect(error.message).to.equal('Invalid hash for cy prompt server script')
375+
376+
expect(reportCyPromptErrorStub).to.be.calledWith({
377+
cloudApi: {
378+
cloudUrl: 'https://cloud.cypress.io',
379+
CloudRequest,
380+
isRetryableError,
381+
asyncRetry,
382+
cloudHeaders: {
383+
'Authorization': 'Bearer test-token',
384+
},
385+
},
386+
cyPromptHash: 'abc',
387+
projectSlug: 'test-project-id',
388+
error,
389+
cyPromptMethod: 'initializeCyPromptManager',
390+
cyPromptMethodArgs: [],
391+
})
353392
})
354393
})
355394

packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ describe('StudioLifecycleManager', () => {
526526
expect(reportStudioErrorStub).to.be.calledOnce
527527
expect(reportStudioErrorStub).to.be.calledWithMatch({
528528
cloudApi: sinon.match.object,
529-
studioHash: 'test-project-id',
529+
studioHash: 'abc',
530530
projectSlug: 'abc123',
531531
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Expected hash for studio server script not found in manifest')),
532532
studioMethod: 'initializeStudioManager',
@@ -573,7 +573,7 @@ describe('StudioLifecycleManager', () => {
573573
expect(reportStudioErrorStub).to.be.calledOnce
574574
expect(reportStudioErrorStub).to.be.calledWithMatch({
575575
cloudApi: sinon.match.object,
576-
studioHash: 'test-project-id',
576+
studioHash: 'abc',
577577
projectSlug: 'abc123',
578578
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Invalid hash for studio server script')),
579579
studioMethod: 'initializeStudioManager',
@@ -623,7 +623,7 @@ describe('StudioLifecycleManager', () => {
623623
expect(reportStudioErrorStub).to.be.calledOnce
624624
expect(reportStudioErrorStub).to.be.calledWithMatch({
625625
cloudApi: sinon.match.object,
626-
studioHash: 'test-project-id',
626+
studioHash: 'abc',
627627
projectSlug: 'abc123',
628628
error: sinon.match.instanceOf(Error).and(sinon.match.has('message', 'Test error')),
629629
studioMethod: 'initializeStudioManager',

packages/types/src/cy-prompt/cy-prompt-server-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface CyPromptCloudApi {
3030
cloudUrl: string
3131
CloudRequest: AxiosInstance
3232
isRetryableError: (err: unknown) => boolean
33+
cloudHeaders?: Record<string, string>
3334
asyncRetry: AsyncRetry
3435
}
3536

0 commit comments

Comments
 (0)