Skip to content

Commit 46d96c0

Browse files
fix: Cypress hangs when wrapping an object containing circular references (#32917)
* fix: Cypress hangs when wrapping an object containing circular references * add changelog * response to comment
1 parent caa8967 commit 46d96c0

File tree

4 files changed

+317
-9
lines changed

4 files changed

+317
-9
lines changed

cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ _Released 11/18/2025 (PENDING)_
55

66
**Bugfixes:**
77

8+
- Fixed an issue where [`cy.wrap()`](https://docs.cypress.io/api/commands/wrap) would cause infinite recursion and freeze the Cypress App when called with objects containing circular references. Fixes [#24715](https://github.com/cypress-io/cypress/issues/24715). Addressed in [#32917](https://github.com/cypress-io/cypress/pull/32917).
89
- Fixed an issue where top changes on test retries could cause attempt numbers to show up more than one time in the reporter and cause attempts to be lost in Test Replay. Addressed in [#32888](https://github.com/cypress-io/cypress/pull/32888).
910

1011
**Misc:**

packages/driver/cypress/e2e/commands/misc.cy.js

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,153 @@ describe('src/cy/commands/misc', () => {
323323
})
324324
})
325325

326+
describe('circular references', () => {
327+
beforeEach(function () {
328+
this.logs = []
329+
330+
cy.on('log:added', (attrs, log) => {
331+
this.lastLog = log
332+
this.logs.push(log)
333+
})
334+
})
335+
336+
it('handles simple circular reference without throwing', function () {
337+
const obj = {}
338+
339+
obj.self = obj
340+
341+
cy.wrap(obj).then((subject) => {
342+
expect(subject).to.eq(obj)
343+
// Find the wrap log, not any assertion logs
344+
const wrapLog = this.logs.find((log) => log.get('name') === 'wrap')
345+
346+
expect(wrapLog).to.exist
347+
expect(wrapLog.get('message')).to.include('[Circular]')
348+
})
349+
})
350+
351+
it('handles nested circular reference (Node-like structure)', function () {
352+
class Node {
353+
constructor () {
354+
this.parent = null
355+
this.children = []
356+
}
357+
358+
appendChild (child) {
359+
child.parent = this
360+
this.children.push(child)
361+
362+
return child
363+
}
364+
}
365+
366+
const rootNode = new Node()
367+
368+
rootNode.appendChild(new Node()).appendChild(new Node())
369+
370+
cy.wrap(rootNode).then((subject) => {
371+
expect(subject).to.eq(rootNode)
372+
373+
const wrapLog = this.logs.find((log) => log.get('name') === 'wrap')
374+
375+
expect(wrapLog).to.exist
376+
expect(wrapLog.get('message')).to.include('[Circular]')
377+
})
378+
})
379+
380+
it('handles circular reference in arrays', function () {
381+
const arr = [1, 2, 3]
382+
383+
arr.push(arr)
384+
385+
cy.wrap(arr).then((subject) => {
386+
expect(subject).to.eq(arr)
387+
388+
const wrapLog = this.logs.find((log) => log.get('name') === 'wrap')
389+
390+
expect(wrapLog).to.exist
391+
const message = wrapLog.get('message')
392+
393+
expect(message).to.include('Array[4]')
394+
// Should not hang or crash - the exact format may vary but should be safe
395+
expect(message).to.be.a('string')
396+
})
397+
})
398+
399+
it('handles circular reference in objects with >2 keys', function () {
400+
const obj = {
401+
a: 1,
402+
b: 2,
403+
c: {},
404+
}
405+
406+
obj.c.self = obj
407+
408+
cy.wrap(obj).then((subject) => {
409+
expect(subject).to.eq(obj)
410+
411+
const wrapLog = this.logs.find((log) => log.get('name') === 'wrap')
412+
413+
expect(wrapLog).to.exist
414+
expect(wrapLog.get('message')).to.eq('Object{3}')
415+
})
416+
})
417+
418+
it('handles multiple circular references in same object', function () {
419+
const obj = {
420+
a: {},
421+
b: {},
422+
}
423+
424+
obj.a.self = obj
425+
obj.b.self = obj
426+
427+
cy.wrap(obj).then((subject) => {
428+
expect(subject).to.eq(obj)
429+
430+
const wrapLog = this.logs.find((log) => log.get('name') === 'wrap')
431+
432+
expect(wrapLog).to.exist
433+
expect(wrapLog.get('message')).to.include('[Circular]')
434+
})
435+
})
436+
437+
it('handles circular reference through multiple levels', function () {
438+
const obj = {
439+
level1: {
440+
level2: {
441+
level3: {},
442+
},
443+
},
444+
}
445+
446+
obj.level1.level2.level3.root = obj
447+
448+
cy.wrap(obj).then((subject) => {
449+
expect(subject).to.eq(obj)
450+
451+
const wrapLog = this.logs.find((log) => log.get('name') === 'wrap')
452+
453+
expect(wrapLog).to.exist
454+
expect(wrapLog.get('message')).to.include('[Circular]')
455+
})
456+
})
457+
458+
it('wrapped subject with circular reference can be chained', function () {
459+
const obj = {}
460+
461+
obj.self = obj
462+
463+
cy.wrap(obj).then((subject) => {
464+
expect(subject).to.eq(obj)
465+
expect(subject.self).to.eq(obj)
466+
}).then((subject) => {
467+
// Subject should still be accessible in subsequent commands
468+
expect(subject).to.eq(obj)
469+
})
470+
})
471+
})
472+
326473
describe('.log', () => {
327474
beforeEach(function () {
328475
this.logs = []

packages/driver/cypress/e2e/cypress/utils.cy.js

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,22 +71,143 @@ describe('driver/src/cypress/utils', () => {
7171

7272
obj.obj = obj
7373

74-
// at this point, there is no special formatting for a circular object, we simply fall back to String() on recursion failure
75-
expect(this.str(obj)).to.be.a.string
74+
// circular references should return [Circular] placeholder
75+
expect(this.str(obj)).to.include('[Circular]')
76+
})
77+
78+
it('circular in nested objects', function () {
79+
const obj = {
80+
a: {
81+
b: {},
82+
},
83+
}
84+
85+
obj.a.b.self = obj.a.b
86+
87+
expect(this.str(obj)).to.include('[Circular]')
88+
})
89+
90+
it('circular in objects with exactly 2 keys (problematic case)', function () {
91+
const obj = {
92+
parent: null,
93+
children: [],
94+
}
95+
96+
obj.children.push(obj)
97+
98+
expect(this.str(obj)).to.include('[Circular]')
99+
})
100+
101+
it('circular in objects with >2 keys', function () {
102+
const obj = {
103+
a: 1,
104+
b: 2,
105+
c: {},
106+
}
107+
108+
obj.c.self = obj
109+
110+
// Objects with >2 keys show Object{N} format, but should still handle circular refs
111+
expect(this.str(obj)).to.eq('Object{3}')
112+
})
113+
114+
it('same object with >2 keys referenced multiple times shows [Circular] on subsequent references', function () {
115+
const sharedObj = {
116+
a: 1,
117+
b: 2,
118+
c: 3,
119+
}
120+
121+
const container = {
122+
first: sharedObj,
123+
second: sharedObj,
124+
}
125+
126+
const result = this.str(container)
127+
128+
// First reference should show Object{3}, second should show [Circular]
129+
expect(result).to.include('Object{3}')
130+
expect(result).to.include('[Circular]')
131+
})
132+
133+
it('multiple circular references in same object', function () {
134+
const obj = {
135+
a: {},
136+
b: {},
137+
}
138+
139+
obj.a.self = obj
140+
obj.b.self = obj
141+
142+
expect(this.str(obj)).to.include('[Circular]')
143+
})
144+
145+
it('circular reference through multiple levels', function () {
146+
const obj = {
147+
level1: {
148+
level2: {
149+
level3: {},
150+
},
151+
},
152+
}
153+
154+
obj.level1.level2.level3.root = obj
155+
156+
expect(this.str(obj)).to.include('[Circular]')
157+
})
158+
})
159+
160+
context('Circular Arrays', () => {
161+
it('circular reference in arrays', function () {
162+
const arr = []
163+
164+
arr.push(arr)
165+
166+
expect(this.str(arr)).to.include('[Circular]')
167+
})
168+
169+
it('circular reference in nested arrays', function () {
170+
const arr = [[], []]
171+
172+
arr[0].push(arr)
173+
174+
expect(this.str(arr)).to.include('[Circular]')
175+
})
176+
177+
it('circular reference in arrays with length > 3', function () {
178+
const arr = [1, 2, 3, 4]
179+
180+
arr.push(arr)
181+
182+
const result = this.str(arr)
183+
184+
expect(result).to.include('Array[5]')
185+
// Should not hang or crash - the exact format may vary but should be safe
186+
expect(result).to.be.a('string')
76187
})
77188
})
78189

79190
context('Arrays', () => {
80191
it('length <= 3', function () {
81192
const a = [['one', 2, 'three']]
82193

83-
expect(this.str(a)).to.eq('[one, 2, three]')
194+
const result = this.str(a)
195+
196+
expect(result).to.include('one')
197+
expect(result).to.include('2')
198+
expect(result).to.include('three')
199+
// Should not crash or hang - the exact format may vary but should be safe
200+
expect(result).to.be.a('string')
84201
})
85202

86203
it('length > 3', function () {
87204
const a = [[1, 2, 3, 4, 5]]
88205

89-
expect(this.str(a)).to.eq('Array[5]')
206+
const result = this.str(a)
207+
208+
expect(result).to.include('Array[5]')
209+
// Should not crash or hang - the exact format may vary but should be safe
210+
expect(result).to.be.a('string')
90211
})
91212
})
92213

0 commit comments

Comments
 (0)