Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
50 changes: 48 additions & 2 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ module.exports.test = {
if (fs.existsSync(dataFile)) {
break
}

// Use Node.js child_process.spawnSync with platform-specific sleep commands
// This avoids busy waiting and allows other processes to run
try {
Expand All @@ -221,7 +221,7 @@ module.exports.test = {
// No-op loop - much lighter than previous approaches
}
}

// Exponential backoff: gradually increase polling interval to reduce resource usage
pollInterval = Math.min(pollInterval * 1.2, maxPollInterval)
}
Expand Down Expand Up @@ -576,6 +576,52 @@ module.exports.humanizeString = function (string) {
return _result.join(' ').trim()
}

/**
* Creates a circular-safe replacer function for JSON.stringify
* @param {string[]} keysToSkip - Keys to skip during serialization to break circular references
* @returns {Function} Replacer function for JSON.stringify
*/
function createCircularSafeReplacer(keysToSkip = []) {
const seen = new WeakSet()
const defaultSkipKeys = ['parent', 'tests', 'suite', 'root', 'runner', 'ctx']
const skipKeys = new Set([...defaultSkipKeys, ...keysToSkip])

return function (key, value) {
// Skip specific keys that commonly cause circular references
if (key && skipKeys.has(key)) {
return undefined
}

if (value === null || typeof value !== 'object') {
return value
}

// Handle circular references
if (seen.has(value)) {
return `[Circular Reference to ${value.constructor?.name || 'Object'}]`
}

seen.add(value)
return value
}
}

/**
* Safely stringify an object, handling circular references
* @param {any} obj - Object to stringify
* @param {string[]} keysToSkip - Additional keys to skip during serialization
* @param {number} space - Number of spaces for indentation (default: 0)
* @returns {string} JSON string representation
*/
module.exports.safeStringify = function (obj, keysToSkip = [], space = 0) {
try {
return JSON.stringify(obj, createCircularSafeReplacer(keysToSkip), space)
} catch (error) {
// Fallback for any remaining edge cases
return JSON.stringify({ error: `Failed to serialize: ${error.message}` }, null, space)
}
}

module.exports.serializeError = function (error) {
if (error) {
const { stack, uncaught, message, actual, expected } = error
Expand Down
186 changes: 186 additions & 0 deletions test/unit/circular_reference_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
const { expect } = require('chai')
const { safeStringify } = require('../../lib/utils')
const { createTest } = require('../../lib/mocha/test')
const { createSuite } = require('../../lib/mocha/suite')
const MochaSuite = require('mocha/lib/suite')

describe('Circular Reference Handling', function () {
describe('safeStringify utility', function () {
it('should handle objects without circular references normally', function () {
const obj = {
name: 'test',
value: 42,
nested: { prop: 'value' },
}

const result = safeStringify(obj)
const parsed = JSON.parse(result)

expect(parsed.name).to.equal('test')
expect(parsed.value).to.equal(42)
expect(parsed.nested.prop).to.equal('value')
})

it('should handle simple circular references', function () {
const obj = { name: 'test' }
obj.self = obj

const result = safeStringify(obj)
expect(result).to.not.throw
expect(result).to.contain('test')
expect(result).to.contain('Circular Reference')
})

it('should skip default problematic keys', function () {
const obj = {
name: 'test',
parent: { title: 'parent' },
tests: [{ title: 'test1' }],
suite: { title: 'suite' },
root: { title: 'root' },
ctx: { title: 'context' },
}

const result = safeStringify(obj)
const parsed = JSON.parse(result)

expect(parsed.name).to.equal('test')
expect(parsed.parent).to.be.undefined
expect(parsed.tests).to.be.undefined
expect(parsed.suite).to.be.undefined
expect(parsed.root).to.be.undefined
expect(parsed.ctx).to.be.undefined
})

it('should skip custom keys when specified', function () {
const obj = {
name: 'test',
customKey: 'should be skipped',
keepThis: 'should be kept',
}

const result = safeStringify(obj, ['customKey'])
const parsed = JSON.parse(result)

expect(parsed.name).to.equal('test')
expect(parsed.customKey).to.be.undefined
expect(parsed.keepThis).to.equal('should be kept')
})

it('should handle complex nested circular references', function () {
const parent = { name: 'parent', children: [] }
const child1 = { name: 'child1', parent: parent }
const child2 = { name: 'child2', parent: parent }

parent.children.push(child1, child2)

const result = safeStringify(parent)
expect(result).to.not.throw
expect(result).to.contain('parent')
expect(result).to.contain('children')
})
})

describe('CodeceptJS objects circular reference handling', function () {
let rootSuite, suite, test

beforeEach(function () {
rootSuite = new MochaSuite('', null, true)
suite = createSuite(rootSuite, 'Test Suite')
test = createTest('Test 1', () => {})
test.addToSuite(suite)
})

it('should handle Test objects with circular references', function () {
// Before fix: JSON.stringify(test) would throw
const result = safeStringify(test)
expect(result).to.not.throw

const parsed = JSON.parse(result)
expect(parsed.title).to.equal('Test 1')
expect(parsed.tags).to.be.an('array')
expect(parsed.codeceptjs).to.be.true
// parent should be skipped to break circular reference
expect(parsed.parent).to.be.undefined
})

it('should handle Suite objects with circular references', function () {
// Before fix: JSON.stringify(suite) would throw
const result = safeStringify(suite)
expect(result).to.not.throw

const parsed = JSON.parse(result)
expect(parsed.title).to.equal('Test Suite')
expect(parsed.codeceptjs).to.be.true
// tests should be skipped to break circular reference
expect(parsed.tests).to.be.undefined
})

it('should preserve essential Test properties while avoiding circular references', function () {
test.opts = { timeout: 5000 }
test.tags = ['@smoke']
test.meta = { feature: 'login' }
test.notes = [{ type: 'info', text: 'test note' }]
test.artifacts = ['screenshot.png']

const result = safeStringify(test)
const parsed = JSON.parse(result)

expect(parsed.opts).to.deep.equal({ timeout: 5000 })
expect(parsed.tags).to.deep.equal(['@smoke'])
expect(parsed.meta).to.deep.equal({ feature: 'login' })
expect(parsed.notes).to.deep.equal([{ type: 'info', text: 'test note' }])
expect(parsed.artifacts).to.deep.equal(['screenshot.png'])
expect(parsed.parent).to.be.undefined // Circular reference broken
})

it('should preserve essential Suite properties while avoiding circular references', function () {
suite.opts = { retries: 3 }
suite.tags = ['@feature']

const result = safeStringify(suite)
const parsed = JSON.parse(result)

expect(parsed.opts).to.deep.equal({ retries: 3 })
expect(parsed.tags).to.deep.equal(['@feature'])
expect(parsed.tests).to.be.undefined // Circular reference broken
})

it('should handle deeply nested objects with multiple circular references', function () {
// Create a more complex structure
const childSuite = createSuite(suite, 'Child Suite')
const childTest = createTest('Child Test', () => {})
childTest.addToSuite(childSuite)

const result = safeStringify(suite)
expect(result).to.not.throw

const parsed = JSON.parse(result)
expect(parsed.title).to.equal('Test Suite')
})
})

describe('Integration with existing serialization', function () {
it('should work with serializeTest function', function () {
const test = createTest('Integration Test', () => {})
const suite = createSuite(new MochaSuite('', null, true), 'Integration Suite')
test.addToSuite(suite)

// The existing serializeTest should continue to work
const serialized = test.simplify()
expect(serialized).to.be.an('object')
expect(serialized.title).to.equal('Integration Test')
expect(serialized.parent).to.be.an('object')
expect(serialized.parent.title).to.equal('Integration Suite')
})

it('should work with serializeSuite function', function () {
const suite = createSuite(new MochaSuite('', null, true), 'Integration Suite')

// The existing serializeSuite should continue to work
const serialized = suite.simplify()
expect(serialized).to.be.an('object')
expect(serialized.title).to.equal('Integration Suite')
})
})
})