diff --git a/lib/model/ws-events.js b/lib/model/ws-events.js index d31a072c..907c967a 100644 --- a/lib/model/ws-events.js +++ b/lib/model/ws-events.js @@ -1,4 +1,5 @@ const { getUrl } = require('../config/url'); +const safeSerialize = require('../utils/safe-serialize'); // Only create socket connection when not in test environment let socket; @@ -63,61 +64,61 @@ module.exports = { ], console: { jsError(err) { - socket.emit('console.error', { + socket.emit('console.error', safeSerialize({ type: 'js', error: err, - }); + })); }, error(err) { - socket.emit('console.error', { + socket.emit('console.error', safeSerialize({ type: 'error', error: err, - }); + })); }, log(type, url, lineno, args) { - socket.emit('console.log', { + socket.emit('console.log', safeSerialize({ type, url, lineno, args - }); + })); } }, network: { failedRequest(data) { - socket.emit('network.failed_request', data); + socket.emit('network.failed_request', safeSerialize(data)); } }, rtr: { suiteBefore(data) { - socket.emit('suite.before', data); + socket.emit('suite.before', safeSerialize(data)); }, testBefore(data) { - socket.emit('test.before', data); + socket.emit('test.before', safeSerialize(data)); }, testAfter(data) { - socket.emit('test.after', data); + socket.emit('test.after', safeSerialize(data)); }, stepBefore(data) { - socket.emit('step.before', data); + socket.emit('step.before', safeSerialize(data)); }, stepAfter(data) { - socket.emit('step.after', data); + socket.emit('step.after', safeSerialize(data)); }, stepComment(comment) { - socket.emit('step.comment', comment); + socket.emit('step.comment', safeSerialize(comment)); }, stepPassed(data) { - socket.emit('step.passed', data); + socket.emit('step.passed', safeSerialize(data)); }, metaStepChanged(data) { - socket.emit('metastep.changed', data); + socket.emit('metastep.changed', safeSerialize(data)); }, testPassed(data) { - socket.emit('test.passed', data); + socket.emit('test.passed', safeSerialize(data)); }, testFailed(data) { - socket.emit('test.failed', data); + socket.emit('test.failed', safeSerialize(data)); }, testRunFinished() { socket.emit('testrun.finish'); @@ -131,32 +132,32 @@ module.exports = { socket.emit('codeceptjs:scenarios.updated'); }, scenariosParseError(err) { - socket.emit('codeceptjs:scenarios.parseerror', { + socket.emit('codeceptjs:scenarios.parseerror', safeSerialize({ message: err.message, stack: err.stack, - }); + })); }, configUpdated(configFile) { - socket.emit('codeceptjs:config.updated', { + socket.emit('codeceptjs:config.updated', safeSerialize({ file: configFile, timestamp: new Date().toISOString() - }); + })); }, fileChanged(filePath, changeType) { - socket.emit('codeceptjs:file.changed', { + socket.emit('codeceptjs:file.changed', safeSerialize({ file: filePath, changeType: changeType, // 'add', 'change', 'unlink' timestamp: new Date().toISOString() - }); + })); }, started(data) { - socket.emit('codeceptjs.started', data); + socket.emit('codeceptjs.started', safeSerialize(data)); }, exit(data) { - socket.emit('codeceptjs.exit', data); + socket.emit('codeceptjs.exit', safeSerialize(data)); }, error(err) { - socket.emit('codeceptjs.error', err); + socket.emit('codeceptjs.error', safeSerialize(err)); } } }; diff --git a/lib/utils/safe-serialize.js b/lib/utils/safe-serialize.js new file mode 100644 index 00000000..7964daa8 --- /dev/null +++ b/lib/utils/safe-serialize.js @@ -0,0 +1,85 @@ +/** + * Safely serializes objects by removing circular references and limiting depth + * This prevents "Maximum call stack size exceeded" errors in Socket.IO serialization + */ + +/** + * Creates a safe copy of an object with circular references resolved + * @param {*} obj - The object to serialize safely + * @param {number} maxDepth - Maximum recursion depth (default: 50) + * @param {WeakSet} seen - Internally used to track visited objects + * @returns {*} Safe copy of the object + */ +function safeSerialize(obj, maxDepth = 50, seen = new WeakSet()) { + // Handle primitive types and null + if (obj === null || typeof obj !== 'object') { + return obj; + } + + // Check depth limit + if (maxDepth <= 0) { + return '[Object: max depth reached]'; + } + + // Check for circular references + if (seen.has(obj)) { + return '[Circular Reference]'; + } + + // Add to seen set + seen.add(obj); + + try { + // Handle arrays + if (Array.isArray(obj)) { + return obj.map(item => safeSerialize(item, maxDepth - 1, seen)); + } + + // Handle Error objects specially + if (obj instanceof Error) { + return { + name: obj.name, + message: obj.message, + stack: obj.stack, + code: obj.code, + type: 'Error' + }; + } + + // Handle Date objects + if (obj instanceof Date) { + return obj.toISOString(); + } + + // Handle regular expressions + if (obj instanceof RegExp) { + return obj.toString(); + } + + // Handle Buffer objects + if (typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(obj)) { + return '[Buffer]'; + } + + // Handle plain objects + const result = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + try { + result[key] = safeSerialize(obj[key], maxDepth - 1, seen); + } catch (err) { + result[key] = '[Serialization Error: ' + err.message + ']'; + } + } + } + + return result; + } catch (err) { + return '[Serialization Error: ' + err.message + ']'; + } finally { + // Remove from seen set when done with this branch + seen.delete(obj); + } +} + +module.exports = safeSerialize; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2a27679e..70e9b3c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codeceptjs/ui", - "version": "1.3.0", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codeceptjs/ui", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "dependencies": { "@codeceptjs/configure": "^1.0.3", diff --git a/package.json b/package.json index 0ef6dc05..d98e7701 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codeceptjs/ui", - "version": "1.3.0", + "version": "1.3.1", "license": "MIT", "scripts": { "serve": "NODE_OPTIONS='--openssl-legacy-provider' vue-cli-service serve", diff --git a/test/safe-serialize.spec.js b/test/safe-serialize.spec.js new file mode 100644 index 00000000..d5476071 --- /dev/null +++ b/test/safe-serialize.spec.js @@ -0,0 +1,89 @@ +const test = require('ava'); +const safeSerialize = require('../lib/utils/safe-serialize'); + +test('safeSerialize handles circular references', (t) => { + const obj = { name: 'test', id: 123 }; + obj.self = obj; + + const result = safeSerialize(obj); + + t.is(result.name, 'test'); + t.is(result.id, 123); + t.is(result.self, '[Circular Reference]'); +}); + +test('safeSerialize handles Error objects with circular references', (t) => { + const error = new Error('Test error'); + error.code = 'TEST_CODE'; + error.cause = error; // Create circular reference + + const result = safeSerialize(error); + + t.is(result.name, 'Error'); + t.is(result.message, 'Test error'); + t.is(result.code, 'TEST_CODE'); + t.is(result.type, 'Error'); + t.truthy(result.stack); +}); + +test('safeSerialize limits recursion depth', (t) => { + const deep = { level: 1 }; + let current = deep; + for (let i = 2; i <= 60; i++) { + current.next = { level: i }; + current = current.next; + } + + const result = safeSerialize(deep); + const serialized = JSON.stringify(result); + + t.true(serialized.includes('[Object: max depth reached]')); +}); + +test('safeSerialize preserves normal objects', (t) => { + const obj = { + name: 'test', + count: 42, + tags: ['a', 'b'], + nested: { + value: 'nested' + } + }; + + const result = safeSerialize(obj); + + t.deepEqual(result, obj); +}); + +test('safeSerialize handles arrays', (t) => { + const arr = [1, 2, { name: 'test' }]; + const result = safeSerialize(arr); + + t.deepEqual(result, arr); +}); + +test('safeSerialize handles Date objects', (t) => { + const date = new Date('2023-01-01T00:00:00.000Z'); + const result = safeSerialize(date); + + t.is(result, '2023-01-01T00:00:00.000Z'); +}); + +test('safeSerialize handles RegExp objects', (t) => { + const regex = /test/gi; + const result = safeSerialize(regex); + + t.is(result, '/test/gi'); +}); + +test('safeSerialize handles null and undefined', (t) => { + t.is(safeSerialize(null), null); + t.is(safeSerialize(undefined), undefined); +}); + +test('safeSerialize handles primitive types', (t) => { + t.is(safeSerialize('string'), 'string'); + t.is(safeSerialize(123), 123); + t.is(safeSerialize(true), true); + t.is(safeSerialize(false), false); +}); \ No newline at end of file diff --git a/test/ws-events-circular-fix.spec.js b/test/ws-events-circular-fix.spec.js new file mode 100644 index 00000000..16c89caf --- /dev/null +++ b/test/ws-events-circular-fix.spec.js @@ -0,0 +1,86 @@ +const test = require('ava'); + +// Mock the Socket.IO environment to test ws-events without actually connecting +process.env.NODE_ENV = 'test'; + +const wsEvents = require('../lib/model/ws-events'); + +test('ws-events can emit error objects with circular references without crashing', (t) => { + // Create an error object with a circular reference + const error = new Error('Test error with circular reference'); + error.code = 'CIRCULAR_ERROR'; + error.cause = error; // Create circular reference + + // This should not throw "Maximum call stack size exceeded" + t.notThrows(() => { + wsEvents.console.jsError(error); + }); + + t.notThrows(() => { + wsEvents.console.error(error); + }); + + t.notThrows(() => { + wsEvents.codeceptjs.error(error); + }); +}); + +test('ws-events can emit objects with circular references without crashing', (t) => { + // Create an object with circular reference + const testData = { + name: 'test', + id: 123, + steps: [] + }; + testData.parent = testData; // Create circular reference + + // This should not throw "Maximum call stack size exceeded" + t.notThrows(() => { + wsEvents.rtr.testBefore(testData); + }); + + t.notThrows(() => { + wsEvents.rtr.testAfter(testData); + }); + + t.notThrows(() => { + wsEvents.rtr.stepBefore(testData); + }); + + t.notThrows(() => { + wsEvents.rtr.stepAfter(testData); + }); +}); + +test('ws-events can handle complex nested objects', (t) => { + // Create a deeply nested object that could potentially cause issues + const complexData = { + test: { + suite: { + title: 'Complex Test', + tests: [ + { title: 'Test 1', steps: [] }, + { title: 'Test 2', steps: [] } + ] + } + }, + error: new Error('Complex error'), + args: [1, 2, 3, { nested: { deep: { object: true } } }] + }; + + // Add circular reference + complexData.test.suite.parent = complexData; + complexData.error.context = complexData; + + t.notThrows(() => { + wsEvents.network.failedRequest(complexData); + }); + + t.notThrows(() => { + wsEvents.codeceptjs.started(complexData); + }); + + t.notThrows(() => { + wsEvents.codeceptjs.exit(complexData); + }); +}); \ No newline at end of file