From 91467f699c3b401819cb0c49df8e31c1c36f0722 Mon Sep 17 00:00:00 2001 From: Pragadesh-45 <54320162+Pragadesh-45@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:37:48 +0530 Subject: [PATCH] feat: enhance axios shim error handling and add comprehensive tests (#6349) --- .../src/components/ErrorCapture/index.js | 31 +- .../src/sandbox/quickjs/shims/lib/axios.js | 42 +- .../sandbox/quickjs/shims/lib/axios.spec.js | 495 ++++++++++++++++++ packages/bruno-js/src/utils.js | 33 +- packages/bruno-js/tests/utils.spec.js | 130 ++++- 5 files changed, 699 insertions(+), 32 deletions(-) create mode 100644 packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.spec.js diff --git a/packages/bruno-app/src/components/ErrorCapture/index.js b/packages/bruno-app/src/components/ErrorCapture/index.js index 47e8452c9..1dfe168ee 100644 --- a/packages/bruno-app/src/components/ErrorCapture/index.js +++ b/packages/bruno-app/src/components/ErrorCapture/index.js @@ -34,23 +34,36 @@ class ErrorBoundary extends Component { const serializeArgs = (args) => { return args.map((arg) => { + const seen = new WeakSet(); + + const replacer = (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular Reference]'; + } + seen.add(value); + + if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) { + const error = {}; + Object.getOwnPropertyNames(value).forEach((prop) => { + error[prop] = value[prop]; + }); + return error; + } + } + return value; + }; + try { if (arg === null) return 'null'; if (arg === undefined) return 'undefined'; if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') { return arg; } - if (arg instanceof Error) { - return { - __type: 'Error', - name: arg.name, - message: arg.message, - stack: arg.stack - }; - } + if (typeof arg === 'object') { try { - return JSON.parse(JSON.stringify(arg)); + return JSON.parse(JSON.stringify(arg, replacer)); } catch { return String(arg); } diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.js index 2f0fc0789..b64fef71b 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.js @@ -4,6 +4,30 @@ const { marshallToVm } = require('../../utils'); const methods = ['get', 'post', 'put', 'patch', 'delete']; +const buildAxiosErrorData = (err) => { + return { + message: err.message, + code: err.code, + isAxiosError: err.isAxiosError, + ...(err.response && { + response: { + status: err.response.status, + statusText: err.response.statusText, + headers: err.response.headers, + data: err.response.data + } + }), + ...(err.config && { + config: { + url: err.config.url, + method: err.config.method, + headers: err.config.headers, + data: err.config.data + } + }) + }; +}; + const addAxiosShimToContext = async (vm) => { methods?.forEach((method) => { const axiosHandle = vm.newFunction(method, (...args) => { @@ -15,14 +39,7 @@ const addAxiosShimToContext = async (vm) => { promise.resolve(marshallToVm(cleanJson({ status, headers, data }), vm)); }) .catch((err) => { - promise.resolve( - marshallToVm( - cleanJson({ - message: err.message - }), - vm - ) - ); + promise.reject(marshallToVm(cleanJson(buildAxiosErrorData(err)), vm)); }); promise.settled.then(vm.runtime.executePendingJobs); return promise.handle; @@ -39,14 +56,7 @@ const addAxiosShimToContext = async (vm) => { promise.resolve(marshallToVm(cleanJson({ status, headers, data }), vm)); }) .catch((err) => { - promise.resolve( - marshallToVm( - cleanJson({ - message: err.message - }), - vm - ) - ); + promise.reject(marshallToVm(cleanJson(buildAxiosErrorData(err)), vm)); }); promise.settled.then(vm.runtime.executePendingJobs); return promise.handle; diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.spec.js b/packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.spec.js new file mode 100644 index 000000000..ae25d47c6 --- /dev/null +++ b/packages/bruno-js/src/sandbox/quickjs/shims/lib/axios.spec.js @@ -0,0 +1,495 @@ +const { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } = require('@jest/globals'); +const { newQuickJSWASMModule } = require('quickjs-emscripten'); +const addAxiosShimToContext = require('./axios'); + +// Mock axios +jest.mock('axios'); +const axios = require('axios'); + +describe('axios shim tests', () => { + let vm, module; + + beforeAll(async () => { + module = await newQuickJSWASMModule(); + }); + + beforeEach(async () => { + vm = module.newContext(); + await addAxiosShimToContext(vm); + jest.clearAllMocks(); + }); + + afterEach(() => { + if (vm) { + try { + vm.dispose(); + } catch (err) { + console.error('Error disposing vm', err); + } + vm = null; + } + }); + + afterAll(() => { + if (module) { + try { + module.dispose(); + } catch (err) { + console.error('Error disposing module', err); + } + module = null; + } + }); + + describe('successful requests', () => { + it('should resolve axios.get with response data', async () => { + const mockResponse = { + status: 200, + headers: { 'content-type': 'application/json' }, + data: { message: 'success' } + }; + axios.get.mockResolvedValue(mockResponse); + + const result = vm.evalCode(` + (async () => { + const response = await axios.get('https://api.example.com/data'); + return response; + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const responseData = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(responseData).toEqual({ + status: 200, + headers: { 'content-type': 'application/json' }, + data: { message: 'success' } + }); + }); + + it('should resolve axios.post with response data', async () => { + const mockResponse = { + status: 201, + headers: { 'content-type': 'application/json' }, + data: { id: 123, created: true } + }; + axios.post.mockResolvedValue(mockResponse); + + const result = vm.evalCode(` + (async () => { + const response = await axios.post('https://api.example.com/users', { name: 'test' }); + return response; + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const responseData = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(responseData.status).toBe(201); + expect(responseData.data).toEqual({ id: 123, created: true }); + }); + + it('should resolve all HTTP methods', async () => { + const mockResponse = { + status: 200, + headers: {}, + data: { success: true } + }; + + const methods = ['get', 'post', 'put', 'patch', 'delete']; + + for (const method of methods) { + axios[method].mockResolvedValue(mockResponse); + + const result = vm.evalCode(` + (async () => { + const response = await axios.${method}('https://api.example.com/endpoint'); + return response.status; + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const status = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(status).toBe(200); + } + }); + }); + + describe('error handling - 4xx/5xx responses', () => { + it('should reject on 404 error with full error information', async () => { + const mockError = { + message: 'Request failed with status code 404', + response: { + status: 404, + statusText: 'Not Found', + headers: { 'content-type': 'application/json' }, + data: { error: 'Resource not found' } + }, + config: { + url: 'https://api.example.com/users/999', + method: 'get', + headers: { Accept: 'application/json' }, + data: undefined + } + }; + axios.get.mockRejectedValue(mockError); + + const result = vm.evalCode(` + (async () => { + try { + await axios.get('https://api.example.com/users/999'); + return { caught: false }; + } catch (error) { + return { + caught: true, + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + configUrl: error.config?.url, + configMethod: error.config?.method + }; + } + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const errorData = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(errorData.caught).toBe(true); + expect(errorData.message).toBe('Request failed with status code 404'); + expect(errorData.status).toBe(404); + expect(errorData.statusText).toBe('Not Found'); + expect(errorData.responseData).toEqual({ error: 'Resource not found' }); + expect(errorData.configUrl).toBe('https://api.example.com/users/999'); + expect(errorData.configMethod).toBe('get'); + }); + + it('should reject on 500 error', async () => { + const mockError = { + message: 'Request failed with status code 500', + response: { + status: 500, + statusText: 'Internal Server Error', + headers: {}, + data: { error: 'Server error' } + }, + config: { + url: 'https://api.example.com/endpoint', + method: 'post', + headers: {}, + data: { test: 'data' } + } + }; + axios.post.mockRejectedValue(mockError); + + const result = vm.evalCode(` + (async () => { + try { + await axios.post('https://api.example.com/endpoint', { test: 'data' }); + return { caught: false }; + } catch (error) { + return { + caught: true, + status: error.response?.status, + message: error.message + }; + } + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const errorData = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(errorData.caught).toBe(true); + expect(errorData.status).toBe(500); + expect(errorData.message).toBe('Request failed with status code 500'); + }); + + it('should reject on 401 unauthorized error', async () => { + const mockError = { + message: 'Request failed with status code 401', + response: { + status: 401, + statusText: 'Unauthorized', + headers: { 'www-authenticate': 'Bearer' }, + data: { error: 'Invalid token' } + }, + config: { + url: 'https://api.example.com/protected', + method: 'get', + headers: { Authorization: 'Bearer invalid' }, + data: undefined + } + }; + axios.get.mockRejectedValue(mockError); + + const result = vm.evalCode(` + (async () => { + try { + await axios.get('https://api.example.com/protected'); + return { caught: false }; + } catch (error) { + return { + caught: true, + status: error.response?.status, + responseData: error.response?.data + }; + } + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const errorData = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(errorData.caught).toBe(true); + expect(errorData.status).toBe(401); + expect(errorData.responseData).toEqual({ error: 'Invalid token' }); + }); + }); + + describe('error handling - network errors', () => { + it('should reject on network error without response', async () => { + const mockError = { + message: 'Network Error', + config: { + url: 'https://api.example.com/endpoint', + method: 'get', + headers: {}, + data: undefined + } + }; + axios.get.mockRejectedValue(mockError); + + const result = vm.evalCode(` + (async () => { + try { + await axios.get('https://api.example.com/endpoint'); + return { caught: false }; + } catch (error) { + return { + caught: true, + message: error.message, + hasResponse: !!error.response, + configUrl: error.config?.url + }; + } + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const errorData = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(errorData.caught).toBe(true); + expect(errorData.message).toBe('Network Error'); + expect(errorData.hasResponse).toBe(false); + expect(errorData.configUrl).toBe('https://api.example.com/endpoint'); + }); + + it('should reject on timeout error', async () => { + const mockError = { + message: 'timeout of 1000ms exceeded', + config: { + url: 'https://api.example.com/slow', + method: 'get', + headers: {}, + data: undefined + } + }; + axios.get.mockRejectedValue(mockError); + + const result = vm.evalCode(` + (async () => { + try { + await axios.get('https://api.example.com/slow'); + return { caught: false }; + } catch (error) { + return { + caught: true, + message: error.message + }; + } + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const errorData = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(errorData.caught).toBe(true); + expect(errorData.message).toBe('timeout of 1000ms exceeded'); + }); + }); + + describe('base axios function', () => { + it('should work with axios() base function', async () => { + const mockResponse = { + status: 200, + headers: {}, + data: { success: true } + }; + axios.mockResolvedValue(mockResponse); + + const result = vm.evalCode(` + (async () => { + const response = await axios({ + method: 'GET', + url: 'https://api.example.com/data' + }); + return response; + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const responseData = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(responseData.status).toBe(200); + expect(responseData.data).toEqual({ success: true }); + }); + + it('should reject on error with axios() base function', async () => { + const mockError = { + message: 'Request failed with status code 403', + response: { + status: 403, + statusText: 'Forbidden', + headers: {}, + data: { error: 'Access denied' } + }, + config: { + url: 'https://api.example.com/forbidden', + method: 'get', + headers: {}, + data: undefined + } + }; + axios.mockRejectedValue(mockError); + + const result = vm.evalCode(` + (async () => { + try { + await axios({ + method: 'GET', + url: 'https://api.example.com/forbidden' + }); + return { caught: false }; + } catch (error) { + return { + caught: true, + status: error.response?.status, + message: error.message + }; + } + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const errorData = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(errorData.caught).toBe(true); + expect(errorData.status).toBe(403); + expect(errorData.message).toBe('Request failed with status code 403'); + }); + }); + + describe('real-world use case from issue #6342', () => { + it('should properly handle token refresh error with full error info', async () => { + const mockError = { + message: 'Request failed with status code 404', + response: { + status: 404, + statusText: 'Not Found', + headers: { 'content-type': 'application/json' }, + data: { error: 'Realm not found' } + }, + config: { + url: 'https://keycloak.example.com/auth/realms/test/protocol/openid-connect/token', + method: 'post', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + data: 'grant_type=password&client_id=test&username=user&password=pass&scope=openid' + } + }; + axios.post.mockRejectedValue(mockError); + + const result = vm.evalCode(` + (async () => { + const url = 'https://keycloak.example.com/auth/realms/test/protocol/openid-connect/token'; + const data = 'grant_type=password&client_id=test&username=user&password=pass&scope=openid'; + + try { + const response = await axios.post(url, data, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + return { success: true, token: response.data?.access_token }; + } catch (error) { + return { + success: false, + errorMessage: error.message, + hasConfig: !!error.config, + configUrl: error.config?.url, + configMethod: error.config?.method, + configData: error.config?.data, + responseStatus: error.response?.status, + responseData: error.response?.data + }; + } + })() + `); + const promiseHandle = vm.unwrapResult(result); + const resolvedResult = await vm.resolvePromise(promiseHandle); + const resolvedHandle = vm.unwrapResult(resolvedResult); + const result_data = vm.dump(resolvedHandle); + + resolvedHandle.dispose(); + promiseHandle.dispose(); + + expect(result_data.success).toBe(false); + expect(result_data.errorMessage).toBe('Request failed with status code 404'); + expect(result_data.hasConfig).toBe(true); + expect(result_data.configUrl).toBe('https://keycloak.example.com/auth/realms/test/protocol/openid-connect/token'); + expect(result_data.configMethod).toBe('post'); + expect(result_data.responseStatus).toBe(404); + expect(result_data.responseData).toEqual({ error: 'Realm not found' }); + }); + }); +}); diff --git a/packages/bruno-js/src/utils.js b/packages/bruno-js/src/utils.js index e92ea5653..da2595fb7 100644 --- a/packages/bruno-js/src/utils.js +++ b/packages/bruno-js/src/utils.js @@ -161,13 +161,34 @@ const cleanJson = (data) => { ].filter(Boolean); const binaryNames = typedArrays.map((d) => d.name); + const seen = new WeakSet(); + const replacer = (key, value) => { - const isBinary = typedArrays.find((d) => value instanceof d); - if (isBinary) { - return { - __cleanJSONType: isBinary.name, - __cleanJSONValue: Buffer.from(value.buffer).toJSON() - }; + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular Reference]'; + } + seen.add(value); + + // instanceof + [[Class]] cover same-realm; duck-type fallback for cross-realm/cross-context Error-like objects + if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) { + const error = {}; + // name/message are often on prototype; ensure they're in the output + error.name = value.name; + error.message = value.message; + Object.getOwnPropertyNames(value).forEach((prop) => { + error[prop] = value[prop]; + }); + return error; + } + + const isBinary = typedArrays.find((d) => value instanceof d); + if (isBinary) { + return { + __cleanJSONType: isBinary.name, + __cleanJSONValue: Buffer.from(value.buffer).toJSON() + }; + } } return value; }; diff --git a/packages/bruno-js/tests/utils.spec.js b/packages/bruno-js/tests/utils.spec.js index 6d1fa070d..503df28f9 100644 --- a/packages/bruno-js/tests/utils.spec.js +++ b/packages/bruno-js/tests/utils.spec.js @@ -1,5 +1,12 @@ const { describe, it, expect } = require('@jest/globals'); -const { evaluateJsExpression, internalExpressionCache: cache, createResponseParser, cleanJson } = require('../src/utils'); +const { + evaluateJsExpression, + evaluateJsTemplateLiteral, + internalExpressionCache: cache, + createResponseParser, + cleanJson, + cleanCircularJson +} = require('../src/utils'); describe('utils', () => { describe('expression evaluation', () => { @@ -212,5 +219,126 @@ describe('utils', () => { expect(cleanJson(input)).toEqual(input); }); + + it('replaces circular references with [Circular Reference]', () => { + const obj = { a: 1 }; + obj.self = obj; + expect(cleanJson(obj)).toEqual({ a: 1, self: '[Circular Reference]' }); + }); + + it('serializes Error instances with all own properties', () => { + const err = new Error('oops'); + const out = cleanJson(err); + expect(out).toMatchObject({ message: 'oops', name: 'Error' }); + expect(typeof out.stack).toBe('string'); + }); + + it('serializes Error with extra own properties (code, cause)', () => { + const err = new Error('failed'); + err.code = 'ERR_FAILED'; + err.cause = new Error('root cause'); + const out = cleanJson(err); + expect(out.message).toBe('failed'); + expect(out.code).toBe('ERR_FAILED'); + expect(out.cause).toMatchObject({ message: 'root cause', name: 'Error' }); + expect(typeof out.cause.stack).toBe('string'); + }); + + it('serializes Error subclasses', () => { + const err = new TypeError('type oops'); + const out = cleanJson(err); + expect(out).toMatchObject({ message: 'type oops', name: 'TypeError' }); + expect(typeof out.stack).toBe('string'); + }); + + it('serializes duck-typed error-like objects (message + stack strings)', () => { + const fake = { message: 'fake', stack: 'at line 1' }; + const out = cleanJson(fake); + expect(out).toEqual(fake); + }); + + it('does not treat plain objects with non-string message/stack as errors', () => { + const notError = { message: 123, stack: 'at line 1' }; + const out = cleanJson(notError); + expect(out).toEqual(notError); + const notError2 = { message: 'ok', stack: 456 }; + const out2 = cleanJson(notError2); + expect(out2).toEqual(notError2); + }); + + it('serializes nested Error inside object', () => { + const input = { err: new Error('nested'), id: 1 }; + const out = cleanJson(input); + expect(out.id).toBe(1); + expect(out.err).toMatchObject({ message: 'nested', name: 'Error' }); + expect(typeof out.err.stack).toBe('string'); + }); + + it('handles circular ref and Error in same structure', () => { + const err = new Error('cycle'); + const obj = { err, ref: null }; + obj.ref = obj; + const out = cleanJson(obj); + expect(out.err).toMatchObject({ message: 'cycle' }); + expect(out.ref).toBe('[Circular Reference]'); + }); + }); + + describe('cleanCircularJson', () => { + it('returns primitives and plain objects as-is', () => { + expect(cleanCircularJson(1)).toBe(1); + expect(cleanCircularJson('x')).toBe('x'); + expect(cleanCircularJson({ a: 1 })).toEqual({ a: 1 }); + }); + + it('replaces circular references with [Circular Reference]', () => { + const obj = { a: 1 }; + obj.self = obj; + expect(cleanCircularJson(obj)).toEqual({ a: 1, self: '[Circular Reference]' }); + }); + + it('handles deeply nested circular ref', () => { + const obj = { level: 1, child: null }; + obj.child = { level: 2, back: obj }; + const out = cleanCircularJson(obj); + expect(out.level).toBe(1); + expect(out.child.level).toBe(2); + expect(out.child.back).toBe('[Circular Reference]'); + }); + }); + + describe('evaluateJsTemplateLiteral', () => { + it('returns non-string or empty input as-is', () => { + expect(evaluateJsTemplateLiteral(null)).toBe(null); + expect(evaluateJsTemplateLiteral('')).toBe(''); + expect(evaluateJsTemplateLiteral(42)).toBe(42); + }); + + it('parses boolean and null literals', () => { + expect(evaluateJsTemplateLiteral('true')).toBe(true); + expect(evaluateJsTemplateLiteral('false')).toBe(false); + expect(evaluateJsTemplateLiteral('null')).toBe(null); + expect(evaluateJsTemplateLiteral('undefined')).toBe(undefined); + }); + + it('parses quoted strings', () => { + expect(evaluateJsTemplateLiteral('"hello"')).toBe('hello'); + expect(evaluateJsTemplateLiteral('\'world\'')).toBe('world'); + }); + + it('parses numbers', () => { + expect(evaluateJsTemplateLiteral('42')).toBe(42); + expect(evaluateJsTemplateLiteral('3.14')).toBe(3.14); + }); + + it('evaluates template literal with context', () => { + const context = { res: { data: { name: 'Bruno' } } }; + expect(evaluateJsTemplateLiteral('${res.data.name}', context)).toBe('Bruno'); + }); + + it('keeps large numbers as string (safe integer limit)', () => { + const big = '9007199254740993'; + expect(evaluateJsTemplateLiteral(big)).toBe(big); + }); }); });