Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 5 additions & 1 deletion packages/server/lib/cloud/api/studio/report_studio_error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Debug from 'debug'
import { stripPath } from '../../strip_path'
const debug = Debug('cypress:server:cloud:api:studio:report_studio_errors')
import { logError } from '@packages/stderr-filtering'
import { safeErrorSerialize } from '../../studio/utils'

export interface ReportStudioErrorOptions {
cloudApi: StudioCloudApi
Expand Down Expand Up @@ -57,7 +58,10 @@ export function reportStudioError ({
let errorObject: Error

if (!(error instanceof Error)) {
errorObject = new Error(String(error))
// Use safe serialization that handles circular references and other edge cases
const message = safeErrorSerialize(error)

errorObject = new Error(message)
} else {
errorObject = error
}
Expand Down
11 changes: 9 additions & 2 deletions 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 { reportStudioError, ReportStudioErrorOptions } from '../api/studio/report_studio_error'
import crypto, { BinaryLike } from 'crypto'
import { StudioElectron } from './StudioElectron'
import { safeErrorSerialize } from './utils'

interface StudioServer { default: StudioServerDefaultShape }

Expand Down Expand Up @@ -124,7 +125,10 @@ export class StudioManager implements StudioManagerShape {
let actualError: Error

if (!(error instanceof Error)) {
actualError = new Error(String(error))
// Use safe serialization that handles circular references and other edge cases
const message = safeErrorSerialize(error)

actualError = new Error(message)
} else {
actualError = error
}
Expand Down Expand Up @@ -154,7 +158,10 @@ export class StudioManager implements StudioManagerShape {
let actualError: Error

if (!(error instanceof Error)) {
actualError = new Error(String(error))
// Use safe serialization that handles circular references and other edge cases
const message = safeErrorSerialize(error)

actualError = new Error(message)
} else {
actualError = error
}
Expand Down
41 changes: 41 additions & 0 deletions packages/server/lib/cloud/studio/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Safely serializes an error object to a string, handling circular references
* and other non-serializable values that would cause JSON.stringify to throw.
*/
export function safeErrorSerialize (error: unknown): string {
if (typeof error === 'string') {
return error
}

if (typeof error === 'object' && error !== null) {
try {
// Try JSON.stringify first, but catch any errors
return JSON.stringify(error)
} catch (e) {
// If JSON.stringify fails (e.g., circular reference), fall back to a safer approach
try {
// Try to extract meaningful properties
const errorObj = error as Record<string, unknown>
const safeObj: Record<string, unknown> = {}

// Common error properties
const commonProps = ['name', 'message', 'code', 'errno', 'stack']

for (const prop of commonProps) {
if (prop in errorObj && typeof errorObj[prop] === 'string') {
safeObj[prop] = errorObj[prop]
}
}

// Try to stringify the safe object
return JSON.stringify(safeObj)
} catch (e2) {
// If even that fails, use a generic fallback
return `[Non-serializable object: ${error.constructor?.name || 'Object'}]`
}
}
}

// For primitives and other types, use String() as fallback
return String(error)
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,89 @@ describe('lib/cloud/api/studio/report_studio_error', () => {
)
})

it('serializes object errors properly instead of showing [object Object]', () => {
const objectError = {
additionalData: { type: 'studio:panel:opened' },
message: 'Something went wrong',
code: 'TELEMETRY_ERROR',
}

reportStudioError({
cloudApi,
studioHash: 'abc123',
projectSlug: 'test-project',
error: objectError,
studioMethod: 'telemetryService',
})

expect(cloudRequestStub).to.be.calledWithMatch(
'http://localhost:1234/studio/errors',
{
studioHash: 'abc123',
projectSlug: 'test-project',
errors: [{
name: 'Error',
message: JSON.stringify(objectError),
stack: sinon.match((stack) => stack.includes('<stripped-path>report_studio_error_spec.ts')),
code: undefined,
errno: undefined,
studioMethod: 'telemetryService',
studioMethodArgs: undefined,
}],
},
{
headers: {
'Content-Type': 'application/json',
'x-cypress-version': '1.2.3',
},
},
)
})

it('handles circular reference objects safely without throwing', () => {
// Create an object with circular reference
const circularError: any = {
message: 'Circular reference error',
code: 'CIRCULAR_ERROR',
}

circularError.self = circularError // Create circular reference

reportStudioError({
cloudApi,
studioHash: 'abc123',
projectSlug: 'test-project',
error: circularError,
studioMethod: 'circularTest',
})

expect(cloudRequestStub).to.be.calledWithMatch(
'http://localhost:1234/studio/errors',
{
studioHash: 'abc123',
projectSlug: 'test-project',
errors: [{
name: 'Error',
message: JSON.stringify({
message: 'Circular reference error',
code: 'CIRCULAR_ERROR',
}),
stack: sinon.match((stack) => stack.includes('<stripped-path>report_studio_error_spec.ts')),
code: undefined,
errno: undefined,
studioMethod: 'circularTest',
studioMethodArgs: undefined,
}],
},
{
headers: {
'Content-Type': 'application/json',
'x-cypress-version': '1.2.3',
},
},
)
})

it('handles Error objects correctly', () => {
const error = new Error('test error')

Expand Down
150 changes: 150 additions & 0 deletions packages/server/test/unit/cloud/studio/studio_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,56 @@ describe('lib/cloud/studio', () => {
expect(studioManager.status).to.eq('IN_ERROR')
expect(studio.reportError).to.be.calledWithMatch(error, 'initializeRoutes', {})
})

it('serializes object errors properly instead of showing [object Object]', () => {
const objectError = {
additionalData: { type: 'studio:panel:opened' },
message: 'Something went wrong',
}

sinon.stub(studio, 'initializeRoutes').throws(objectError)
sinon.stub(studio, 'reportError')

studioManager.initializeRoutes({} as any)

expect(studioManager.status).to.eq('IN_ERROR')
expect(studio.reportError).to.be.calledWithMatch(
sinon.match((error) => {
return error instanceof Error &&
error.message === JSON.stringify(objectError)
}),
'initializeRoutes',
{},
)
})

it('handles circular reference objects safely in sync methods without throwing', () => {
// Create an object with circular reference
const circularError: any = {
message: 'Circular reference error',
code: 'CIRCULAR_ERROR',
}

circularError.self = circularError // Create circular reference

sinon.stub(studio, 'initializeRoutes').throws(circularError)
sinon.stub(studio, 'reportError')

studioManager.initializeRoutes({} as any)

expect(studioManager.status).to.eq('IN_ERROR')
expect(studio.reportError).to.be.calledWithMatch(
sinon.match((error) => {
return error instanceof Error &&
error.message === JSON.stringify({
message: 'Circular reference error',
code: 'CIRCULAR_ERROR',
})
}),
'initializeRoutes',
{},
)
})
})

describe('asynchronous method invocation', () => {
Expand All @@ -92,6 +142,56 @@ describe('lib/cloud/studio', () => {
expect(studio.reportError).to.be.calledWithMatch(error, 'initializeStudioAI', {})
})

it('serializes object errors properly in async methods instead of showing [object Object]', async () => {
const objectError = {
additionalData: { type: 'studio:panel:opened' },
message: 'Async error occurred',
}

sinon.stub(studio, 'initializeStudioAI').throws(objectError)
sinon.stub(studio, 'reportError')

await studioManager.initializeStudioAI({} as any)

expect(studioManager.status).to.eq('IN_ERROR')
expect(studio.reportError).to.be.calledWithMatch(
sinon.match((error) => {
return error instanceof Error &&
error.message === JSON.stringify(objectError)
}),
'initializeStudioAI',
{},
)
})

it('handles circular reference objects safely in async methods without throwing', async () => {
// Create an object with circular reference
const circularError: any = {
message: 'Async circular reference error',
code: 'ASYNC_CIRCULAR_ERROR',
}

circularError.self = circularError // Create circular reference

sinon.stub(studio, 'initializeStudioAI').throws(circularError)
sinon.stub(studio, 'reportError')

await studioManager.initializeStudioAI({} as any)

expect(studioManager.status).to.eq('IN_ERROR')
expect(studio.reportError).to.be.calledWithMatch(
sinon.match((error) => {
return error instanceof Error &&
error.message === JSON.stringify({
message: 'Async circular reference error',
code: 'ASYNC_CIRCULAR_ERROR',
})
}),
'initializeStudioAI',
{},
)
})

it('does not set state IN_ERROR when a non-essential async method fails', async () => {
const error = new Error('foo')

Expand All @@ -101,6 +201,56 @@ describe('lib/cloud/studio', () => {

expect(studioManager.status).to.eq('ENABLED')
})

it('serializes object errors properly in non-essential async methods', async () => {
const objectError = {
additionalData: { type: 'studio:panel:opened' },
message: 'Non-essential error occurred',
}

sinon.stub(studio, 'captureStudioEvent').throws(objectError)
sinon.stub(studio, 'reportError')

await studioManager.captureStudioEvent({} as any)

expect(studioManager.status).to.eq('ENABLED')
expect(studio.reportError).to.be.calledWithMatch(
sinon.match((error) => {
return error instanceof Error &&
error.message === JSON.stringify(objectError)
}),
'captureStudioEvent',
{},
)
})

it('handles circular reference objects safely in non-essential async methods without throwing', async () => {
// Create an object with circular reference
const circularError: any = {
message: 'Non-essential circular reference error',
code: 'NON_ESSENTIAL_CIRCULAR_ERROR',
}

circularError.self = circularError // Create circular reference

sinon.stub(studio, 'captureStudioEvent').throws(circularError)
sinon.stub(studio, 'reportError')

await studioManager.captureStudioEvent({} as any)

expect(studioManager.status).to.eq('ENABLED')
expect(studio.reportError).to.be.calledWithMatch(
sinon.match((error) => {
return error instanceof Error &&
error.message === JSON.stringify({
message: 'Non-essential circular reference error',
code: 'NON_ESSENTIAL_CIRCULAR_ERROR',
})
}),
'captureStudioEvent',
{},
)
})
})

describe('initializeRoutes', () => {
Expand Down
Loading