From 2acc165b4b7eeb47de13ea61dc0af14e0f056fa3 Mon Sep 17 00:00:00 2001 From: estrada9166 Date: Wed, 9 Jul 2025 13:10:05 -0500 Subject: [PATCH] chore: Handle cy-prompt error to the cloud --- .../api/cy-prompt/report_cy-prompt_error.ts | 107 ++++++++++++++++++ .../cy-prompt/CyPromptLifecycleManager.ts | 52 ++++----- .../cloud/studio/StudioLifecycleManager.ts | 16 +-- .../CyPromptLifecycleManager_spec.ts | 39 +++++++ .../studio/StudioLifecycleManager_spec.ts | 6 +- .../src/cy-prompt/cy-prompt-server-types.ts | 1 + 6 files changed, 184 insertions(+), 37 deletions(-) create mode 100644 packages/server/lib/cloud/api/cy-prompt/report_cy-prompt_error.ts diff --git a/packages/server/lib/cloud/api/cy-prompt/report_cy-prompt_error.ts b/packages/server/lib/cloud/api/cy-prompt/report_cy-prompt_error.ts new file mode 100644 index 00000000000..4c21d21bd79 --- /dev/null +++ b/packages/server/lib/cloud/api/cy-prompt/report_cy-prompt_error.ts @@ -0,0 +1,107 @@ +import type { CyPromptCloudApi } from '@packages/types/src/cy-prompt/cy-prompt-server-types' +import Debug from 'debug' +import { stripPath } from '../../strip_path' +const debug = Debug('cypress:server:cloud:api:cy-prompt:report_cy-prompt_error') + +export interface ReportCyPromptErrorOptions { + cloudApi: CyPromptCloudApi + cyPromptHash: string | undefined + projectSlug: string | undefined + error: unknown + cyPromptMethod: string + cyPromptMethodArgs?: unknown[] +} + +interface CyPromptError { + name: string + stack: string + message: string + cyPromptMethod: string + cyPromptMethodArgs?: string +} + +interface CyPromptErrorPayload { + cyPromptHash: string | undefined + projectSlug: string | undefined + errors: CyPromptError[] +} + +export function reportCyPromptError ({ + cloudApi, + cyPromptHash, + projectSlug, + error, + cyPromptMethod, + cyPromptMethodArgs, +}: ReportCyPromptErrorOptions): void { + debug('Error reported:', error) + + // When developing locally, do not send to Sentry, but instead log to console. + if ( + process.env.CYPRESS_LOCAL_CY_PROMPT_PATH || + process.env.NODE_ENV === 'development' || + process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF + ) { + // eslint-disable-next-line no-console + console.error(`Error in ${cyPromptMethod}:`, error) + + return + } + + let errorObject: Error + + if (!(error instanceof Error)) { + errorObject = new Error(String(error)) + } else { + errorObject = error + } + + let cyPromptMethodArgsString: string | undefined + + if (cyPromptMethodArgs) { + try { + cyPromptMethodArgsString = JSON.stringify({ + args: cyPromptMethodArgs, + }) + } catch (e: unknown) { + cyPromptMethodArgsString = `Unknown args: ${e}` + } + } + + try { + const payload: CyPromptErrorPayload = { + cyPromptHash, + projectSlug, + errors: [{ + name: stripPath(errorObject.name ?? `Unknown name`), + stack: stripPath(errorObject.stack ?? `Unknown stack`), + message: stripPath(errorObject.message ?? `Unknown message`), + cyPromptMethod, + cyPromptMethodArgs: cyPromptMethodArgsString, + }], + } + + cloudApi.CloudRequest.post( + `${cloudApi.cloudUrl}/cy-prompt/errors`, + payload, + { + headers: { + 'Content-Type': 'application/json', + ...cloudApi.cloudHeaders, + }, + }, + ).catch((e: unknown) => { + debug( + `Error calling CyPromptManager.reportError: %o, original error %o`, + e, + error, + ) + }) + } catch (e: unknown) { + debug( + `Error calling CyPromptManager.reportError: %o, original error %o`, + e, + error, + ) + } +} diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 27773c6f103..9d9a66b1050 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -14,6 +14,7 @@ import chokidar from 'chokidar' import { getCloudMetadata } from '../get_cloud_metadata' import type { CyPromptAuthenticatedUserShape } from '@packages/types' import crypto from 'crypto' +import { reportCyPromptError } from '../api/cy-prompt/report_cy-prompt_error' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') @@ -26,6 +27,7 @@ export class CyPromptLifecycleManager { }> private cyPromptManager?: CyPromptManager private listeners: ((cyPromptManager: CyPromptManager) => void)[] = [] + private cyPromptHash: string | undefined /** * Initialize the cy prompt manager. @@ -66,25 +68,24 @@ export class CyPromptLifecycleManager { }).catch(async (error) => { debug('Error during cy prompt 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() - - // TODO: reportCyPromptError - // reportCyPromptError({ - // cloudApi: { - // cloudUrl, - // cloudHeaders, - // CloudRequest, - // isRetryableError, - // asyncRetry, - // }, - // cyPromptHash: projectId, - // projectSlug: cfg.projectId, - // error, - // cyPromptMethod: 'initializeCyPromptManager', - // cyPromptMethodArgs: [], - // }) + 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() + + reportCyPromptError({ + cloudApi: { + cloudUrl, + cloudHeaders, + CloudRequest, + isRetryableError, + asyncRetry, + }, + cyPromptHash: this.cyPromptHash, + projectSlug: (await ctx.project.getConfig()).projectId || undefined, + error, + cyPromptMethod: 'initializeCyPromptManager', + cyPromptMethodArgs: [], + }) // Clean up any registered listeners this.listeners = [] @@ -123,7 +124,6 @@ export class CyPromptLifecycleManager { key?: string }> }): Promise<{ cyPromptManager?: CyPromptManager, error?: Error }> { - let cyPromptHash: string let cyPromptPath: string let manifest: Record @@ -135,10 +135,10 @@ export class CyPromptLifecycleManager { if (!process.env.CYPRESS_LOCAL_CY_PROMPT_PATH) { // The cy prompt hash is the last part of the cy prompt URL, after the last slash and before the extension - cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] - cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', cyPromptHash) + this.cyPromptHash = cyPromptSession.cyPromptUrl.split('/').pop()?.split('.')[0] as string + cyPromptPath = path.join(os.tmpdir(), 'cypress', 'cy-prompt', this.cyPromptHash) - let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(cyPromptHash) + let hashLoadingPromise = CyPromptLifecycleManager.hashLoadingMap.get(this.cyPromptHash) if (!hashLoadingPromise) { hashLoadingPromise = ensureCyPromptBundle({ @@ -147,13 +147,13 @@ export class CyPromptLifecycleManager { cyPromptPath, }) - CyPromptLifecycleManager.hashLoadingMap.set(cyPromptHash, hashLoadingPromise) + CyPromptLifecycleManager.hashLoadingMap.set(this.cyPromptHash, hashLoadingPromise) } manifest = await hashLoadingPromise } else { cyPromptPath = process.env.CYPRESS_LOCAL_CY_PROMPT_PATH - cyPromptHash = 'local' + this.cyPromptHash = 'local' manifest = {} } @@ -181,7 +181,7 @@ export class CyPromptLifecycleManager { await cyPromptManager.setup({ script, cyPromptPath, - cyPromptHash, + cyPromptHash: this.cyPromptHash, cloudApi: { cloudUrl, CloudRequest, diff --git a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts index 69045097276..0a258272514 100644 --- a/packages/server/lib/cloud/studio/StudioLifecycleManager.ts +++ b/packages/server/lib/cloud/studio/StudioLifecycleManager.ts @@ -35,6 +35,7 @@ export class StudioLifecycleManager { private listeners: ((studioManager: StudioManager) => void)[] = [] private ctx?: DataContext private lastStatus?: StudioStatus + private studioHash: string | undefined public get cloudStudioRequested () { return !!(process.env.CYPRESS_ENABLE_CLOUD_STUDIO || process.env.CYPRESS_LOCAL_STUDIO_PATH) @@ -91,7 +92,7 @@ export class StudioLifecycleManager { isRetryableError, asyncRetry, }, - studioHash: projectId, + studioHash: this.studioHash, projectSlug: cfg.projectId, error, studioMethod: 'initializeStudioManager', @@ -157,7 +158,6 @@ export class StudioLifecycleManager { debugData: any }): Promise { let studioPath: string - let studioHash: string let manifest: Record initializeTelemetryReporter({ @@ -177,10 +177,10 @@ export class StudioLifecycleManager { telemetryManager.mark(BUNDLE_LIFECYCLE_MARK_NAMES.ENSURE_STUDIO_BUNDLE_START) 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) + this.studioHash = studioSession.studioUrl.split('/').pop()?.split('.')[0] as string + studioPath = path.join(os.tmpdir(), 'cypress', 'studio', this.studioHash) - let hashLoadingPromise = StudioLifecycleManager.hashLoadingMap.get(studioHash) + let hashLoadingPromise = StudioLifecycleManager.hashLoadingMap.get(this.studioHash) if (!hashLoadingPromise) { hashLoadingPromise = ensureStudioBundle({ @@ -189,13 +189,13 @@ export class StudioLifecycleManager { projectId, }) - StudioLifecycleManager.hashLoadingMap.set(studioHash, hashLoadingPromise) + StudioLifecycleManager.hashLoadingMap.set(this.studioHash, hashLoadingPromise) } manifest = await hashLoadingPromise } else { studioPath = process.env.CYPRESS_LOCAL_STUDIO_PATH - studioHash = 'local' + this.studioHash = 'local' manifest = {} } @@ -227,7 +227,7 @@ export class StudioLifecycleManager { await studioManager.setup({ script, studioPath, - studioHash, + studioHash: this.studioHash, projectSlug: projectId, cloudApi: { cloudUrl, diff --git a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts index 311baa4a1db..a7da02e23cf 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -9,6 +9,7 @@ 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 * as reportCyPromptErrorPath from '../../../../lib/cloud/api/cy-prompt/report_cy-prompt_error' describe('CyPromptLifecycleManager', () => { let cyPromptLifecycleManager: CyPromptLifecycleManager @@ -24,12 +25,14 @@ describe('CyPromptLifecycleManager', () => { let watcherStub: sinon.SinonStub = sinon.stub() let watcherOnStub: sinon.SinonStub = sinon.stub() let watcherCloseStub: sinon.SinonStub = sinon.stub() + let reportCyPromptErrorStub: sinon.SinonStub const mockContents: string = 'console.log("cy-prompt script")' beforeEach(() => { postCyPromptSessionStub = sinon.stub() cyPromptManagerSetupStub = sinon.stub() ensureCyPromptBundleStub = sinon.stub() + reportCyPromptErrorStub = sinon.stub() cyPromptStatusChangeEmitterStub = sinon.stub() mockCyPromptManager = { status: 'INITIALIZED', @@ -100,6 +103,8 @@ describe('CyPromptLifecycleManager', () => { postCyPromptSessionStub.resolves({ cyPromptUrl: 'https://cloud.cypress.io/cy-prompt/bundle/abc.tgz', }) + + reportCyPromptErrorStub = sinon.stub(reportCyPromptErrorPath, 'reportCyPromptError').resolves() }) afterEach(() => { @@ -322,6 +327,23 @@ describe('CyPromptLifecycleManager', () => { const { error } = await cyPromptPromise expect(error.message).to.equal('Expected hash for cy prompt server script not found in manifest') + + expect(reportCyPromptErrorStub).to.be.calledWith({ + cloudApi: { + cloudUrl: 'https://cloud.cypress.io', + CloudRequest, + isRetryableError, + asyncRetry, + cloudHeaders: { + 'Authorization': 'Bearer test-token', + }, + }, + cyPromptHash: 'abc', + projectSlug: 'test-project-id', + error, + cyPromptMethod: 'initializeCyPromptManager', + cyPromptMethodArgs: [], + }) }) it('throws an error when the cy-prompt server script is wrong in the manifest', async () => { @@ -350,6 +372,23 @@ describe('CyPromptLifecycleManager', () => { const { error } = await cyPromptPromise expect(error.message).to.equal('Invalid hash for cy prompt server script') + + expect(reportCyPromptErrorStub).to.be.calledWith({ + cloudApi: { + cloudUrl: 'https://cloud.cypress.io', + CloudRequest, + isRetryableError, + asyncRetry, + cloudHeaders: { + 'Authorization': 'Bearer test-token', + }, + }, + cyPromptHash: 'abc', + projectSlug: 'test-project-id', + error, + cyPromptMethod: 'initializeCyPromptManager', + cyPromptMethodArgs: [], + }) }) }) diff --git a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts index c8021145657..fd0cce672be 100644 --- a/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/studio/StudioLifecycleManager_spec.ts @@ -526,7 +526,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', @@ -573,7 +573,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', @@ -623,7 +623,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', diff --git a/packages/types/src/cy-prompt/cy-prompt-server-types.ts b/packages/types/src/cy-prompt/cy-prompt-server-types.ts index 525c4b10c3b..a63ed390cbe 100644 --- a/packages/types/src/cy-prompt/cy-prompt-server-types.ts +++ b/packages/types/src/cy-prompt/cy-prompt-server-types.ts @@ -30,6 +30,7 @@ export interface CyPromptCloudApi { cloudUrl: string CloudRequest: AxiosInstance isRetryableError: (err: unknown) => boolean + cloudHeaders?: Record asyncRetry: AsyncRetry }