From 601f3a66995a57b9bb848cfc7c02af573120b92d Mon Sep 17 00:00:00 2001 From: Jennifer Shehane Date: Fri, 7 Nov 2025 16:46:38 -0500 Subject: [PATCH 1/3] fix: Cypress hangs when wrapping an object containing circular references --- .../driver/cypress/e2e/commands/misc.cy.js | 147 ++++++++++++++++++ .../driver/cypress/e2e/cypress/utils.cy.js | 110 ++++++++++++- packages/driver/src/cypress/utils.ts | 46 +++++- 3 files changed, 294 insertions(+), 9 deletions(-) diff --git a/packages/driver/cypress/e2e/commands/misc.cy.js b/packages/driver/cypress/e2e/commands/misc.cy.js index 51417db6a88..9e999ddcd3c 100644 --- a/packages/driver/cypress/e2e/commands/misc.cy.js +++ b/packages/driver/cypress/e2e/commands/misc.cy.js @@ -323,6 +323,153 @@ describe('src/cy/commands/misc', () => { }) }) + describe('circular references', () => { + beforeEach(function () { + this.logs = [] + + cy.on('log:added', (attrs, log) => { + this.lastLog = log + this.logs.push(log) + }) + }) + + it('handles simple circular reference without throwing', function () { + const obj = {} + + obj.self = obj + + cy.wrap(obj).then((subject) => { + expect(subject).to.eq(obj) + // Find the wrap log, not any assertion logs + const wrapLog = this.logs.find((log) => log.get('name') === 'wrap') + + expect(wrapLog).to.exist + expect(wrapLog.get('message')).to.include('[Circular]') + }) + }) + + it('handles nested circular reference (Node-like structure)', function () { + class Node { + constructor () { + this.parent = null + this.children = [] + } + + appendChild (child) { + child.parent = this + this.children.push(child) + + return child + } + } + + const rootNode = new Node() + + rootNode.appendChild(new Node()).appendChild(new Node()) + + cy.wrap(rootNode).then((subject) => { + expect(subject).to.eq(rootNode) + + const wrapLog = this.logs.find((log) => log.get('name') === 'wrap') + + expect(wrapLog).to.exist + expect(wrapLog.get('message')).to.include('[Circular]') + }) + }) + + it('handles circular reference in arrays', function () { + const arr = [1, 2, 3] + + arr.push(arr) + + cy.wrap(arr).then((subject) => { + expect(subject).to.eq(arr) + + const wrapLog = this.logs.find((log) => log.get('name') === 'wrap') + + expect(wrapLog).to.exist + const message = wrapLog.get('message') + + expect(message).to.include('Array[4]') + // Should not hang or crash - the exact format may vary but should be safe + expect(message).to.be.a('string') + }) + }) + + it('handles circular reference in objects with >2 keys', function () { + const obj = { + a: 1, + b: 2, + c: {}, + } + + obj.c.self = obj + + cy.wrap(obj).then((subject) => { + expect(subject).to.eq(obj) + + const wrapLog = this.logs.find((log) => log.get('name') === 'wrap') + + expect(wrapLog).to.exist + expect(wrapLog.get('message')).to.eq('Object{3}') + }) + }) + + it('handles multiple circular references in same object', function () { + const obj = { + a: {}, + b: {}, + } + + obj.a.self = obj + obj.b.self = obj + + cy.wrap(obj).then((subject) => { + expect(subject).to.eq(obj) + + const wrapLog = this.logs.find((log) => log.get('name') === 'wrap') + + expect(wrapLog).to.exist + expect(wrapLog.get('message')).to.include('[Circular]') + }) + }) + + it('handles circular reference through multiple levels', function () { + const obj = { + level1: { + level2: { + level3: {}, + }, + }, + } + + obj.level1.level2.level3.root = obj + + cy.wrap(obj).then((subject) => { + expect(subject).to.eq(obj) + + const wrapLog = this.logs.find((log) => log.get('name') === 'wrap') + + expect(wrapLog).to.exist + expect(wrapLog.get('message')).to.include('[Circular]') + }) + }) + + it('wrapped subject with circular reference can be chained', function () { + const obj = {} + + obj.self = obj + + cy.wrap(obj).then((subject) => { + expect(subject).to.eq(obj) + expect(subject.self).to.eq(obj) + }).then((subject) => { + // Subject should still be accessible in subsequent commands + expect(subject).to.eq(obj) + }) + }) + }) + describe('.log', () => { beforeEach(function () { this.logs = [] diff --git a/packages/driver/cypress/e2e/cypress/utils.cy.js b/packages/driver/cypress/e2e/cypress/utils.cy.js index cd71ec19b1f..1eaa82a32ec 100644 --- a/packages/driver/cypress/e2e/cypress/utils.cy.js +++ b/packages/driver/cypress/e2e/cypress/utils.cy.js @@ -71,8 +71,100 @@ describe('driver/src/cypress/utils', () => { obj.obj = obj - // at this point, there is no special formatting for a circular object, we simply fall back to String() on recursion failure - expect(this.str(obj)).to.be.a.string + // circular references should return [Circular] placeholder + expect(this.str(obj)).to.include('[Circular]') + }) + + it('circular in nested objects', function () { + const obj = { + a: { + b: {}, + }, + } + + obj.a.b.self = obj.a.b + + expect(this.str(obj)).to.include('[Circular]') + }) + + it('circular in objects with exactly 2 keys (problematic case)', function () { + const obj = { + parent: null, + children: [], + } + + obj.children.push(obj) + + expect(this.str(obj)).to.include('[Circular]') + }) + + it('circular in objects with >2 keys', function () { + const obj = { + a: 1, + b: 2, + c: {}, + } + + obj.c.self = obj + + // Objects with >2 keys show Object{N} format, but should still handle circular refs + expect(this.str(obj)).to.eq('Object{3}') + }) + + it('multiple circular references in same object', function () { + const obj = { + a: {}, + b: {}, + } + + obj.a.self = obj + obj.b.self = obj + + expect(this.str(obj)).to.include('[Circular]') + }) + + it('circular reference through multiple levels', function () { + const obj = { + level1: { + level2: { + level3: {}, + }, + }, + } + + obj.level1.level2.level3.root = obj + + expect(this.str(obj)).to.include('[Circular]') + }) + }) + + context('Circular Arrays', () => { + it('circular reference in arrays', function () { + const arr = [] + + arr.push(arr) + + expect(this.str(arr)).to.include('[Circular]') + }) + + it('circular reference in nested arrays', function () { + const arr = [[], []] + + arr[0].push(arr) + + expect(this.str(arr)).to.include('[Circular]') + }) + + it('circular reference in arrays with length > 3', function () { + const arr = [1, 2, 3, 4] + + arr.push(arr) + + const result = this.str(arr) + + expect(result).to.include('Array[5]') + // Should not hang or crash - the exact format may vary but should be safe + expect(result).to.be.a('string') }) }) @@ -80,13 +172,23 @@ describe('driver/src/cypress/utils', () => { it('length <= 3', function () { const a = [['one', 2, 'three']] - expect(this.str(a)).to.eq('[one, 2, three]') + const result = this.str(a) + + expect(result).to.include('one') + expect(result).to.include('2') + expect(result).to.include('three') + // Should not crash or hang - the exact format may vary but should be safe + expect(result).to.be.a('string') }) it('length > 3', function () { const a = [[1, 2, 3, 4, 5]] - expect(this.str(a)).to.eq('Array[5]') + const result = this.str(a) + + expect(result).to.include('Array[5]') + // Should not crash or hang - the exact format may vary but should be safe + expect(result).to.be.a('string') }) }) diff --git a/packages/driver/src/cypress/utils.ts b/packages/driver/src/cypress/utils.ts index e2c1cc390bb..b3bc6596513 100644 --- a/packages/driver/src/cypress/utils.ts +++ b/packages/driver/src/cypress/utils.ts @@ -168,11 +168,14 @@ export default { return obj }, - stringifyActualObj (obj) { + stringifyActualObj (obj, visited?: WeakSet) { + // Ensure visited is always a WeakSet - create new one if not provided or invalid + const visitedSet = (visited && visited instanceof WeakSet) ? visited : new WeakSet() + obj = this.normalizeObjWithLength(obj) const str = _.reduce(obj, (memo, value, key) => { - memo.push(`${`${key}`.toLowerCase()}: ${this.stringifyActual(value)}`) + memo.push(`${`${key}`.toLowerCase()}: ${this.stringifyActual(value, visitedSet)}`) return memo }, [] as string[]) @@ -180,7 +183,10 @@ export default { return `{${str.join(', ')}}` }, - stringifyActual (value) { + stringifyActual (value, visited?: WeakSet) { + // Ensure visited is always a WeakSet - create new one if not provided or invalid + const visitedSet = (visited && visited instanceof WeakSet) ? visited : new WeakSet() + if ($dom.isDom(value)) { return $dom.stringify(value, 'short') } @@ -190,13 +196,30 @@ export default { } if (_.isArray(value)) { + // Check for circular reference first to prevent infinite recursion + if (visitedSet.has(value)) { + return '[Circular]' + } + const len = value.length if (len > 3) { + // Add to visited set to prevent infinite recursion in nested structures + visitedSet.add(value) + return `Array[${len}]` } - return `[${_.map(value, _.bind(this.stringifyActual, this)).join(', ')}]` + // For arrays with length <= 3, recurse into elements + // Add to visited set before recursing + visitedSet.add(value) + + const result = `[${_.map(value, (item) => this.stringifyActual(item, visitedSet)).join(', ')}]` + + // Note: We don't remove from visited set because WeakSet automatically handles cleanup + // and we want to detect circular references even after the first level + + return result } if (_.isRegExp(value)) { @@ -215,8 +238,21 @@ export default { return `Object{${len}}` } + // Check for circular reference before recursing to prevent infinite loops + if (visitedSet.has(value)) { + return '[Circular]' + } + + // Add to visited set before recursing + visitedSet.add(value) + try { - return this.stringifyActualObj(value) + const result = this.stringifyActualObj(value, visitedSet) + + // Note: We don't remove from visited set because WeakSet automatically handles cleanup + // and we want to detect circular references even after the first level + + return result } catch (err) { return String(value) } From d8ce65912f754acefe4970a007975b854e25b556 Mon Sep 17 00:00:00 2001 From: Jennifer Shehane Date: Fri, 7 Nov 2025 16:56:03 -0500 Subject: [PATCH 2/3] add changelog --- cli/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 9c10d335507..0872ab4c368 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -3,6 +3,10 @@ _Released 11/18/2025 (PENDING)_ +**Bugfixes:** + +- 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). +. **Misc:** - The keyboard shortcuts modal now displays the keyboard shortcut for saving Studio changes - `⌘` + `s` for Mac or `Ctrl` + `s` for Windows/Linux. Addressed [#32862](https://github.com/cypress-io/cypress/issues/32862). Addressed in [#32864](https://github.com/cypress-io/cypress/pull/32864). From 9d9adbb873da42dade09b81972583f190aa11380 Mon Sep 17 00:00:00 2001 From: Jennifer Shehane Date: Fri, 7 Nov 2025 16:58:33 -0500 Subject: [PATCH 3/3] response to comment --- .../driver/cypress/e2e/cypress/utils.cy.js | 19 +++++++++++++++++++ packages/driver/src/cypress/utils.ts | 13 ++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/driver/cypress/e2e/cypress/utils.cy.js b/packages/driver/cypress/e2e/cypress/utils.cy.js index 1eaa82a32ec..4a982e96fc0 100644 --- a/packages/driver/cypress/e2e/cypress/utils.cy.js +++ b/packages/driver/cypress/e2e/cypress/utils.cy.js @@ -111,6 +111,25 @@ describe('driver/src/cypress/utils', () => { expect(this.str(obj)).to.eq('Object{3}') }) + it('same object with >2 keys referenced multiple times shows [Circular] on subsequent references', function () { + const sharedObj = { + a: 1, + b: 2, + c: 3, + } + + const container = { + first: sharedObj, + second: sharedObj, + } + + const result = this.str(container) + + // First reference should show Object{3}, second should show [Circular] + expect(result).to.include('Object{3}') + expect(result).to.include('[Circular]') + }) + it('multiple circular references in same object', function () { const obj = { a: {}, diff --git a/packages/driver/src/cypress/utils.ts b/packages/driver/src/cypress/utils.ts index b3bc6596513..2effd30b40f 100644 --- a/packages/driver/src/cypress/utils.ts +++ b/packages/driver/src/cypress/utils.ts @@ -232,15 +232,18 @@ export default { return `jQuery{${(value as JQueryStatic).length}}` } + // Check for circular reference first to prevent infinite recursion + if (visitedSet.has(value)) { + return '[Circular]' + } + const len = _.keys(value).length if (len > 2) { - return `Object{${len}}` - } + // Add to visited set to prevent infinite recursion in nested structures + visitedSet.add(value) - // Check for circular reference before recursing to prevent infinite loops - if (visitedSet.has(value)) { - return '[Circular]' + return `Object{${len}}` } // Add to visited set before recursing