Skip to content

Commit 73960e8

Browse files
authored
[WIP] fix Circular reference in objects (#5123)
1 parent bc1c500 commit 73960e8

File tree

2 files changed

+234
-2
lines changed

2 files changed

+234
-2
lines changed

lib/utils.js

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ module.exports.test = {
203203
if (fs.existsSync(dataFile)) {
204204
break
205205
}
206-
206+
207207
// Use Node.js child_process.spawnSync with platform-specific sleep commands
208208
// This avoids busy waiting and allows other processes to run
209209
try {
@@ -221,7 +221,7 @@ module.exports.test = {
221221
// No-op loop - much lighter than previous approaches
222222
}
223223
}
224-
224+
225225
// Exponential backoff: gradually increase polling interval to reduce resource usage
226226
pollInterval = Math.min(pollInterval * 1.2, maxPollInterval)
227227
}
@@ -576,6 +576,52 @@ module.exports.humanizeString = function (string) {
576576
return _result.join(' ').trim()
577577
}
578578

579+
/**
580+
* Creates a circular-safe replacer function for JSON.stringify
581+
* @param {string[]} keysToSkip - Keys to skip during serialization to break circular references
582+
* @returns {Function} Replacer function for JSON.stringify
583+
*/
584+
function createCircularSafeReplacer(keysToSkip = []) {
585+
const seen = new WeakSet()
586+
const defaultSkipKeys = ['parent', 'tests', 'suite', 'root', 'runner', 'ctx']
587+
const skipKeys = new Set([...defaultSkipKeys, ...keysToSkip])
588+
589+
return function (key, value) {
590+
// Skip specific keys that commonly cause circular references
591+
if (key && skipKeys.has(key)) {
592+
return undefined
593+
}
594+
595+
if (value === null || typeof value !== 'object') {
596+
return value
597+
}
598+
599+
// Handle circular references
600+
if (seen.has(value)) {
601+
return `[Circular Reference to ${value.constructor?.name || 'Object'}]`
602+
}
603+
604+
seen.add(value)
605+
return value
606+
}
607+
}
608+
609+
/**
610+
* Safely stringify an object, handling circular references
611+
* @param {any} obj - Object to stringify
612+
* @param {string[]} keysToSkip - Additional keys to skip during serialization
613+
* @param {number} space - Number of spaces for indentation (default: 0)
614+
* @returns {string} JSON string representation
615+
*/
616+
module.exports.safeStringify = function (obj, keysToSkip = [], space = 0) {
617+
try {
618+
return JSON.stringify(obj, createCircularSafeReplacer(keysToSkip), space)
619+
} catch (error) {
620+
// Fallback for any remaining edge cases
621+
return JSON.stringify({ error: `Failed to serialize: ${error.message}` }, null, space)
622+
}
623+
}
624+
579625
module.exports.serializeError = function (error) {
580626
if (error) {
581627
const { stack, uncaught, message, actual, expected } = error

test/unit/circular_reference_test.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
const { expect } = require('chai')
2+
const { safeStringify } = require('../../lib/utils')
3+
const { createTest } = require('../../lib/mocha/test')
4+
const { createSuite } = require('../../lib/mocha/suite')
5+
const MochaSuite = require('mocha/lib/suite')
6+
7+
describe('Circular Reference Handling', function () {
8+
describe('safeStringify utility', function () {
9+
it('should handle objects without circular references normally', function () {
10+
const obj = {
11+
name: 'test',
12+
value: 42,
13+
nested: { prop: 'value' },
14+
}
15+
16+
const result = safeStringify(obj)
17+
const parsed = JSON.parse(result)
18+
19+
expect(parsed.name).to.equal('test')
20+
expect(parsed.value).to.equal(42)
21+
expect(parsed.nested.prop).to.equal('value')
22+
})
23+
24+
it('should handle simple circular references', function () {
25+
const obj = { name: 'test' }
26+
obj.self = obj
27+
28+
const result = safeStringify(obj)
29+
expect(result).to.not.throw
30+
expect(result).to.contain('test')
31+
expect(result).to.contain('Circular Reference')
32+
})
33+
34+
it('should skip default problematic keys', function () {
35+
const obj = {
36+
name: 'test',
37+
parent: { title: 'parent' },
38+
tests: [{ title: 'test1' }],
39+
suite: { title: 'suite' },
40+
root: { title: 'root' },
41+
ctx: { title: 'context' },
42+
}
43+
44+
const result = safeStringify(obj)
45+
const parsed = JSON.parse(result)
46+
47+
expect(parsed.name).to.equal('test')
48+
expect(parsed.parent).to.be.undefined
49+
expect(parsed.tests).to.be.undefined
50+
expect(parsed.suite).to.be.undefined
51+
expect(parsed.root).to.be.undefined
52+
expect(parsed.ctx).to.be.undefined
53+
})
54+
55+
it('should skip custom keys when specified', function () {
56+
const obj = {
57+
name: 'test',
58+
customKey: 'should be skipped',
59+
keepThis: 'should be kept',
60+
}
61+
62+
const result = safeStringify(obj, ['customKey'])
63+
const parsed = JSON.parse(result)
64+
65+
expect(parsed.name).to.equal('test')
66+
expect(parsed.customKey).to.be.undefined
67+
expect(parsed.keepThis).to.equal('should be kept')
68+
})
69+
70+
it('should handle complex nested circular references', function () {
71+
const parent = { name: 'parent', children: [] }
72+
const child1 = { name: 'child1', parent: parent }
73+
const child2 = { name: 'child2', parent: parent }
74+
75+
parent.children.push(child1, child2)
76+
77+
const result = safeStringify(parent)
78+
expect(result).to.not.throw
79+
expect(result).to.contain('parent')
80+
expect(result).to.contain('children')
81+
})
82+
})
83+
84+
describe('CodeceptJS objects circular reference handling', function () {
85+
let rootSuite, suite, test
86+
87+
beforeEach(function () {
88+
rootSuite = new MochaSuite('', null, true)
89+
suite = createSuite(rootSuite, 'Test Suite')
90+
test = createTest('Test 1', () => {})
91+
test.addToSuite(suite)
92+
})
93+
94+
it('should handle Test objects with circular references', function () {
95+
// Before fix: JSON.stringify(test) would throw
96+
const result = safeStringify(test)
97+
expect(result).to.not.throw
98+
99+
const parsed = JSON.parse(result)
100+
expect(parsed.title).to.equal('Test 1')
101+
expect(parsed.tags).to.be.an('array')
102+
expect(parsed.codeceptjs).to.be.true
103+
// parent should be skipped to break circular reference
104+
expect(parsed.parent).to.be.undefined
105+
})
106+
107+
it('should handle Suite objects with circular references', function () {
108+
// Before fix: JSON.stringify(suite) would throw
109+
const result = safeStringify(suite)
110+
expect(result).to.not.throw
111+
112+
const parsed = JSON.parse(result)
113+
expect(parsed.title).to.equal('Test Suite')
114+
expect(parsed.codeceptjs).to.be.true
115+
// tests should be skipped to break circular reference
116+
expect(parsed.tests).to.be.undefined
117+
})
118+
119+
it('should preserve essential Test properties while avoiding circular references', function () {
120+
test.opts = { timeout: 5000 }
121+
test.tags = ['@smoke']
122+
test.meta = { feature: 'login' }
123+
test.notes = [{ type: 'info', text: 'test note' }]
124+
test.artifacts = ['screenshot.png']
125+
126+
const result = safeStringify(test)
127+
const parsed = JSON.parse(result)
128+
129+
expect(parsed.opts).to.deep.equal({ timeout: 5000 })
130+
expect(parsed.tags).to.deep.equal(['@smoke'])
131+
expect(parsed.meta).to.deep.equal({ feature: 'login' })
132+
expect(parsed.notes).to.deep.equal([{ type: 'info', text: 'test note' }])
133+
expect(parsed.artifacts).to.deep.equal(['screenshot.png'])
134+
expect(parsed.parent).to.be.undefined // Circular reference broken
135+
})
136+
137+
it('should preserve essential Suite properties while avoiding circular references', function () {
138+
suite.opts = { retries: 3 }
139+
suite.tags = ['@feature']
140+
141+
const result = safeStringify(suite)
142+
const parsed = JSON.parse(result)
143+
144+
expect(parsed.opts).to.deep.equal({ retries: 3 })
145+
expect(parsed.tags).to.deep.equal(['@feature'])
146+
expect(parsed.tests).to.be.undefined // Circular reference broken
147+
})
148+
149+
it('should handle deeply nested objects with multiple circular references', function () {
150+
// Create a more complex structure
151+
const childSuite = createSuite(suite, 'Child Suite')
152+
const childTest = createTest('Child Test', () => {})
153+
childTest.addToSuite(childSuite)
154+
155+
const result = safeStringify(suite)
156+
expect(result).to.not.throw
157+
158+
const parsed = JSON.parse(result)
159+
expect(parsed.title).to.equal('Test Suite')
160+
})
161+
})
162+
163+
describe('Integration with existing serialization', function () {
164+
it('should work with serializeTest function', function () {
165+
const test = createTest('Integration Test', () => {})
166+
const suite = createSuite(new MochaSuite('', null, true), 'Integration Suite')
167+
test.addToSuite(suite)
168+
169+
// The existing serializeTest should continue to work
170+
const serialized = test.simplify()
171+
expect(serialized).to.be.an('object')
172+
expect(serialized.title).to.equal('Integration Test')
173+
expect(serialized.parent).to.be.an('object')
174+
expect(serialized.parent.title).to.equal('Integration Suite')
175+
})
176+
177+
it('should work with serializeSuite function', function () {
178+
const suite = createSuite(new MochaSuite('', null, true), 'Integration Suite')
179+
180+
// The existing serializeSuite should continue to work
181+
const serialized = suite.simplify()
182+
expect(serialized).to.be.an('object')
183+
expect(serialized.title).to.equal('Integration Suite')
184+
})
185+
})
186+
})

0 commit comments

Comments
 (0)