feat: enhance axios shim error handling and add comprehensive tests (#6349)

This commit is contained in:
Pragadesh-45
2026-02-12 17:37:48 +05:30
committed by GitHub
parent 3871ca9edd
commit 91467f699c
5 changed files with 699 additions and 32 deletions

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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' });
});
});
});

View File

@@ -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;
};

View File

@@ -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);
});
});
});