From 28af66444c524ed6ec2942d4704ef427b940bda1 Mon Sep 17 00:00:00 2001 From: Harshit Date: Sun, 7 Jun 2026 00:14:57 +0530 Subject: [PATCH] fix(observability): decycle o11y payloads before cy.task to prevent "Converting circular structure to JSON" crash [SDK-6016] The Cypress Observability instrumentation captures command args (command.attributes.args) and cy.log items raw and ships them to the Node plugin via cy.task(), which JSON-serializes the payload. When a command/log arg is a circular Cypress runtime object (e.g. one whose renderOptions.host references itself), serialization throws "Converting circular structure to JSON" and aborts the entire test run. Add a WeakSet-based getCircularReplacer() and a sanitizeForTask() that decycles the payload (circular refs -> "[Circular]") before handing it to cy.task, applied at both flush points (beforeEach/afterEach) and the command:enqueued log stringify. sanitizeForTask wraps serialization in a try/catch so o11y instrumentation can never break the user's test run. Co-Authored-By: Claude Opus 4.8 --- bin/testObservability/cypress/index.js | 34 +++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/bin/testObservability/cypress/index.js b/bin/testObservability/cypress/index.js index bb898811..9cae1617 100644 --- a/bin/testObservability/cypress/index.js +++ b/bin/testObservability/cypress/index.js @@ -6,6 +6,34 @@ const STEP_KEYWORDS = ['given', 'when', 'then', 'and', 'but', '*']; let eventsQueue = []; let testRunStarted = false; +/* + * Command args (command.attributes.args) and cy.log items are captured raw and can hold + * circular Cypress runtime objects (e.g. a config-like object whose `renderOptions.host` + * points back to itself). cy.task() JSON-serializes its payload to ship it from the browser + * to the Node plugin process, so a circular arg makes Cypress throw + * "Converting circular structure to JSON" and aborts the run. Decycle the payload before + * handing it to cy.task so o11y instrumentation can never break the customer's tests. [SDK-6016] + */ +const getCircularReplacer = () => { + const seen = new WeakSet(); + return (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) return '[Circular]'; + seen.add(value); + } + return value; + }; +}; + +const sanitizeForTask = (data) => { + try { + return JSON.parse(JSON.stringify(data, getCircularReplacer())); + } catch (e) { + /* Never let serialization of o11y data break the user's test run (graceful degradation). */ + return { serializationError: e && e.message ? e.message : String(e) }; + } +}; + const browserStackLog = (message) => { if (!Cypress.env('BROWSERSTACK_LOGS')) return; @@ -208,7 +236,7 @@ Cypress.on('command:enqueued', (attrs) => { if (args.includes('test_observability_log') || args.includes('test_observability_command')) return; const message = args.reduce((result, logItem) => { if (typeof logItem === 'object') { - return [result, JSON.stringify(logItem)].join(' '); + return [result, JSON.stringify(logItem, getCircularReplacer())].join(' '); } return [result, logItem ? logItem.toString() : ''].join(' '); }, ''); @@ -309,7 +337,7 @@ beforeEach(() => { if (eventsQueue.length > 0) { eventsQueue.forEach(event => { - cy.task(event.task, event.data, event.options); + cy.task(event.task, sanitizeForTask(event.data), event.options); }); } eventsQueue = []; @@ -324,7 +352,7 @@ afterEach(function() { if (eventsQueue.length > 0) { eventsQueue.forEach(event => { - cy.task(event.task, event.data, event.options); + cy.task(event.task, sanitizeForTask(event.data), event.options); }); }