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 similarity index 93% rename from packages/server/lib/cloud/api/cy-prompt/report_cy-prompt_error.ts rename to packages/server/lib/cloud/api/cy-prompt/report_cy_prompt_error.ts index e13843bdf7d..eb32ed0c788 100644 --- 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 @@ -1,7 +1,7 @@ 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') +const debug = Debug('cypress:server:cloud:api:cy-prompt:report_cy_prompt_error') export interface ReportCyPromptErrorOptions { cloudApi: CyPromptCloudApi @@ -38,6 +38,10 @@ export function reportCyPromptError ({ }: ReportCyPromptErrorOptions): void { debug('Error reported:', error) + if (process.env.CYPRESS_CRASH_REPORTS === '0') { + return + } + // When developing locally, do not send to Sentry, but instead log to console. if ( process.env.CYPRESS_LOCAL_CY_PROMPT_PATH || @@ -79,7 +83,7 @@ export function reportCyPromptError ({ stack: stripPath(errorObject.stack ?? `Unknown stack`), message: stripPath(errorObject.message ?? `Unknown message`), cyPromptMethod, - cyPromptMethodArgs: cyPromptMethodArgsString, + cyPromptMethodArgs: cyPromptMethodArgsString ? stripPath(cyPromptMethodArgsString) : undefined, }], } diff --git a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts index 58dc2f5ef15..58b8dfe1de0 100644 --- a/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts +++ b/packages/server/lib/cloud/cy-prompt/CyPromptLifecycleManager.ts @@ -14,7 +14,7 @@ import chokidar from 'chokidar' import { getCloudMetadata } from '../get_cloud_metadata' import type { CyPromptAuthenticatedUserShape, CyPromptServerOptions } from '@packages/types' import crypto from 'crypto' -import { reportCyPromptError } from '../api/cy-prompt/report_cy-prompt_error' +import { reportCyPromptError } from '../api/cy-prompt/report_cy_prompt_error' const debug = Debug('cypress:server:cy-prompt-lifecycle-manager') diff --git a/packages/server/test/unit/cloud/api/cy-prompt/report_cy_prompt_error_spec.ts b/packages/server/test/unit/cloud/api/cy-prompt/report_cy_prompt_error_spec.ts new file mode 100644 index 00000000000..e98ff6eb954 --- /dev/null +++ b/packages/server/test/unit/cloud/api/cy-prompt/report_cy_prompt_error_spec.ts @@ -0,0 +1,281 @@ +import { expect } from 'chai' +import { sinon } from '../../../../spec_helper' +import { reportCyPromptError } from '@packages/server/lib/cloud/api/cy-prompt/report_cy_prompt_error' + +describe('lib/cloud/api/cy-prompt/report_cy_prompt_error', () => { + let cloudRequestStub: sinon.SinonStub + let cloudApi: any + let oldNodeEnv: string | undefined + + beforeEach(() => { + oldNodeEnv = process.env.NODE_ENV + cloudRequestStub = sinon.stub() + cloudApi = { + cloudUrl: 'http://localhost:1234', + cloudHeaders: { 'x-cypress-version': '1.2.3' }, + CloudRequest: { + post: cloudRequestStub, + }, + } + }) + + afterEach(() => { + sinon.restore() + delete process.env.CYPRESS_CRASH_REPORTS + delete process.env.CYPRESS_LOCAL_CY_PROMPT_PATH + delete process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF + if (oldNodeEnv) { + process.env.NODE_ENV = oldNodeEnv + } else { + delete process.env.NODE_ENV + } + }) + + describe('reportCyPromptError', () => { + it('logs error when CYPRESS_LOCAL_CY_PROMPT_PATH is set', () => { + sinon.stub(console, 'error') + process.env.CYPRESS_LOCAL_CY_PROMPT_PATH = '/path/to/cy-prompt' + const error = new Error('test error') + + reportCyPromptError({ + cloudApi, + cyPromptHash: 'abc123', + projectSlug: 'test-project', + error, + cyPromptMethod: 'testMethod', + }) + + // eslint-disable-next-line no-console + expect(console.error).to.have.been.calledWith( + 'Error in testMethod:', + error, + ) + }) + + it('logs error when NODE_ENV is development', () => { + sinon.stub(console, 'error') + process.env.NODE_ENV = 'development' + const error = new Error('test error') + + reportCyPromptError({ + cloudApi, + cyPromptHash: 'abc123', + projectSlug: 'test-project', + error, + cyPromptMethod: 'testMethod', + }) + + // eslint-disable-next-line no-console + expect(console.error).to.have.been.calledWith( + 'Error in testMethod:', + error, + ) + }) + + it('logs error when CYPRESS_INTERNAL_E2E_TESTING_SELF is set', () => { + sinon.stub(console, 'error') + process.env.CYPRESS_INTERNAL_E2E_TESTING_SELF = 'true' + const error = new Error('test error') + + reportCyPromptError({ + cloudApi, + cyPromptHash: 'abc123', + projectSlug: 'test-project', + error, + cyPromptMethod: 'testMethod', + }) + + // eslint-disable-next-line no-console + expect(console.error).to.have.been.calledWith( + 'Error in testMethod:', + error, + ) + }) + + it('does not report error when CYPRESS_CRASH_REPORTS is 0', () => { + process.env.CYPRESS_CRASH_REPORTS = '0' + const error = new Error('test error') + + reportCyPromptError({ + cloudApi, + cyPromptHash: 'abc123', + projectSlug: 'test-project', + error, + cyPromptMethod: 'testMethod', + }) + + expect(cloudRequestStub).to.not.have.been.called + }) + + it('converts non-Error objects to Error', () => { + const error = 'string error' + + reportCyPromptError({ + cloudApi, + cyPromptHash: 'abc123', + projectSlug: 'test-project', + error, + cyPromptMethod: 'testMethod', + }) + + expect(cloudRequestStub).to.be.calledWithMatch( + 'http://localhost:1234/cy-prompt/errors', + { + cyPromptHash: 'abc123', + projectSlug: 'test-project', + errors: [{ + name: 'Error', + message: 'string error', + stack: sinon.match((stack) => stack.includes('report_cy_prompt_error_spec.ts')), + cyPromptMethod: 'testMethod', + cyPromptMethodArgs: undefined, + }], + }, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) + + it('handles Error objects correctly', () => { + const error = new Error('test error') + + error.stack = 'test stack' + + reportCyPromptError({ + cloudApi, + cyPromptHash: 'abc123', + projectSlug: 'test-project', + error, + cyPromptMethod: 'testMethod', + }) + + expect(cloudRequestStub).to.be.calledWithMatch( + 'http://localhost:1234/cy-prompt/errors', + { + cyPromptHash: 'abc123', + projectSlug: 'test-project', + errors: [{ + name: 'Error', + message: 'test error', + stack: 'test stack', + cyPromptMethod: 'testMethod', + cyPromptMethodArgs: undefined, + }], + }, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) + + it('includes cyPromptMethodArgs when provided', () => { + const error = new Error('test error') + const args = ['arg1', { key: '/path/to/file.js' }] + + reportCyPromptError({ + cloudApi, + cyPromptHash: 'abc123', + projectSlug: 'test-project', + error, + cyPromptMethod: 'testMethod', + cyPromptMethodArgs: args, + }) + + expect(cloudRequestStub).to.be.calledWithMatch( + 'http://localhost:1234/cy-prompt/errors', + { + cyPromptHash: 'abc123', + projectSlug: 'test-project', + errors: [{ + name: 'Error', + message: 'test error', + stack: sinon.match((stack) => stack.includes('report_cy_prompt_error_spec.ts')), + cyPromptMethod: 'testMethod', + cyPromptMethodArgs: JSON.stringify({ args: ['arg1', { key: 'file.js' }] }), + }], + }, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) + + it('handles errors in JSON.stringify for cyPromptMethodArgs', () => { + const error = new Error('test error') + const circularObj: any = {} + + circularObj.self = circularObj + + reportCyPromptError({ + cloudApi, + cyPromptHash: 'abc123', + projectSlug: 'test-project', + error, + cyPromptMethod: 'testMethod', + cyPromptMethodArgs: [circularObj], + }) + + expect(cloudRequestStub).to.be.calledWithMatch( + 'http://localhost:1234/cy-prompt/errors', + { + cyPromptHash: 'abc123', + projectSlug: 'test-project', + errors: [{ + name: 'Error', + message: 'test error', + stack: sinon.match((stack) => stack.includes('report_cy_prompt_error_spec.ts')), + cyPromptMethod: 'testMethod', + cyPromptMethodArgs: sinon.match(/Unknown args/), + }], + }, + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + }) + + it('handles errors in CloudRequest.post', () => { + const error = new Error('test error') + const postError = new Error('post error') + + cloudRequestStub.rejects(postError) + + reportCyPromptError({ + cloudApi, + cyPromptHash: 'abc123', + projectSlug: 'test-project', + error, + cyPromptMethod: 'testMethod', + }) + + // Just verify the post was called, don't check debug output + expect(cloudRequestStub).to.be.called + }) + + it('handles errors in payload construction', () => { + const error = new Error('test error') + + sinon.stub(JSON, 'stringify').throws(new Error('JSON error')) + + reportCyPromptError({ + cloudApi, + cyPromptHash: 'abc123', + projectSlug: 'test-project', + error, + cyPromptMethod: 'testMethod', + }) + + // Just verify the post was called, don't check debug output + expect(cloudRequestStub).to.be.called + }) + }) +}) 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 9a57d82c0aa..21e51b05b7d 100644 --- a/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts +++ b/packages/server/test/unit/cloud/cy-prompt/CyPromptLifecycleManager_spec.ts @@ -9,7 +9,7 @@ import os from 'os' import { CloudRequest, createCloudRequest } 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' +import * as reportCyPromptErrorPath from '../../../../lib/cloud/api/cy-prompt/report_cy_prompt_error' describe('CyPromptLifecycleManager', () => { let cyPromptLifecycleManager: CyPromptLifecycleManager