Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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 exception from '../../exception'

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 = exception.safeErrorSerialize(error)

errorObject = new Error(message)
} else {
errorObject = error
}
Expand Down
29 changes: 29 additions & 0 deletions packages/server/lib/cloud/exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,36 @@ import user from './user'
import system from '../util/system'
import { stripPath } from './strip_path'

const { serializeError } = require('serialize-error')

export = {
/**
* Safely serializes an error object to a string, handling circular references
* and other non-serializable values that would cause JSON.stringify to throw.
*/
safeErrorSerialize (error: unknown): string {
if (typeof error === 'string') {
return error
}

try {
// Use serialize-error package to handle complex error objects safely
const serialized = serializeError(error)

const result = JSON.stringify(serialized)

// JSON.stringify returns undefined for undefined input, but we need to return a string
if (result === undefined) {
return 'undefined'
}

return result
} catch (e) {
// If even serialize-error fails, use a generic fallback
return `[Non-serializable object: ${error?.constructor?.name || 'Object'}]`
}
},

getErr (err: Error) {
return {
name: stripPath(err.name),
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 exception from '../exception'

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 = exception.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 = exception.safeErrorSerialize(error)

actualError = new Error(message)
} else {
actualError = error
}
Expand Down
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@
"sanitize-filename": "1.6.3",
"semver": "^7.7.1",
"send": "0.19.0",
"serialize-error": "^7.0.1",
"shell-env": "3.0.1",
"signal-exit": "3.0.7",
"squirrelly": "7.9.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,45 @@ describe('lib/cloud/api/studio/report_studio_error', () => {
)
})

it('converts non-Error objects to Error instances', () => {
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: sinon.match.string,
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 Error objects correctly', () => {
const error = new Error('test error')

Expand Down
112 changes: 112 additions & 0 deletions packages/server/test/unit/cloud/exceptions_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,116 @@ at bar <stripped-path>bar.js:92\
})
})
})

context('.safeErrorSerialize', () => {
it('returns string as-is when error is already a string', () => {
const stringError = 'Simple string error'

expect(exception.safeErrorSerialize(stringError)).to.eq('Simple string error')
})

it('serializes plain objects properly', () => {
const objectError = {
additionalData: { type: 'studio:panel:opened' },
message: 'Something went wrong',
code: 'TELEMETRY_ERROR',
}

const result = exception.safeErrorSerialize(objectError)

expect(result).to.eq(JSON.stringify(objectError))
})

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

circularError.self = circularError // Create circular reference

const result = exception.safeErrorSerialize(circularError)

expect(result).to.eq(JSON.stringify({
message: 'Circular reference error',
code: 'CIRCULAR_ERROR',
self: '[Circular]',
}))
})

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

error.code = 'TEST_CODE'
error.errno = 123

const result = exception.safeErrorSerialize(error)

// serializeError should preserve Error properties
const parsed = JSON.parse(result)

expect(parsed.message).to.eq('test error')
expect(parsed.name).to.eq('Error')
expect(parsed.code).to.eq('TEST_CODE')
expect(parsed.errno).to.eq(123)
})

it('handles null and undefined gracefully', () => {
expect(exception.safeErrorSerialize(null)).to.eq('null')
expect(exception.safeErrorSerialize(undefined)).to.eq('undefined')
})

it('handles primitive types', () => {
expect(exception.safeErrorSerialize(42)).to.eq('42')
expect(exception.safeErrorSerialize(true)).to.eq('true')
expect(exception.safeErrorSerialize(false)).to.eq('false')
})

it('provides fallback for non-serializable objects', () => {
// Create an object that might cause issues
const problematicObject = {
get value () {
throw new Error('Cannot access value')
},
}

const result = exception.safeErrorSerialize(problematicObject)

expect(result).to.match(/^\[Non-serializable object:/)
})

it('handles deeply nested objects', () => {
const deepObject = {
level1: {
level2: {
level3: {
level4: {
message: 'Deep error',
data: [1, 2, 3, { nested: true }],
},
},
},
},
}

const result = exception.safeErrorSerialize(deepObject)

expect(result).to.eq(JSON.stringify(deepObject))
})

it('handles arrays with mixed content', () => {
const arrayError = [
'string',
42,
{ message: 'object in array' },
null,
undefined,
]

const result = exception.safeErrorSerialize(arrayError)

expect(result).to.eq(JSON.stringify(arrayError))
})
})
})
63 changes: 63 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,27 @@ describe('lib/cloud/studio', () => {
expect(studioManager.status).to.eq('IN_ERROR')
expect(studio.reportError).to.be.calledWithMatch(error, 'initializeRoutes', {})
})

it('handles non-Error objects by converting them to Error instances', () => {
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
}),
'initializeRoutes',
{},
)
})
})

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

it('handles non-Error objects in async methods by converting them to Error instances', 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
}),
'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 +143,27 @@ describe('lib/cloud/studio', () => {

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

it('handles non-Error objects in non-essential async methods without changing status', 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
}),
'captureStudioEvent',
{},
)
})
})

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