feat: enhance translation capabilities for Bruno to Postman conversion (#7052)

* feat: enhance translation capabilities for Bruno to Postman conversion

- Added support for translating req.getHost(), req.getPath(), and req.getQueryString() to their Postman equivalents.
- Implemented translation for req.getPathParams() to pm.request.url.variables.
- Introduced handling for bru.visualize() to pm.visualizer.set() with various argument types.
- Added tests to validate new translation features and ensure correct behavior for URL-related methods and visualizer functionality.

* rm: duplicates

* refactor: remove bru.visualize transformation and associated tests

* feat: enhance BDD-style assertion translations in Postman converter

- Updated transformation logic to translate BDD-style assertions like pm.response.to.be.ok, pm.response.to.be.success, and others to their corresponding expect statements.
- Added comprehensive tests to validate the new translations for various response status checks.
- Improved handling of BDD assertions within test blocks to ensure accurate translation.

* fix: correct variable naming in transformation logic for Postman converter

- Updated variable names in the transformation logic to improve clarity and consistency.
- Ensured that the correct nodes are replaced and added to the transformedNodes set during processing.

* fix: improve AST mutation handling in Postman to Bruno translation

- Enhanced the processTransformations function to capture stable references before mutating the AST, ensuring correct node replacement and insertion.
- Added a defensive guard for ExpressionStatements to prevent errors when accessing undefined properties.
- Improved the logic for inserting remaining nodes after the grandparent in reverse order to maintain the correct sequence.

* fix: remove unnecessary defensive guard in AST mutation for Postman to Bruno translation
This commit is contained in:
sanish chirayath
2026-02-12 17:17:39 +05:30
committed by GitHub
parent 2517fe078f
commit 3871ca9edd
6 changed files with 509 additions and 16 deletions

View File

@@ -8,12 +8,14 @@ const simpleTranslations = {
// Global Variables
'pm.globals.get': 'bru.getGlobalEnvVar',
'pm.globals.set': 'bru.setGlobalEnvVar',
'pm.globals.replaceIn': 'bru.interpolate',
// Environment variables
'pm.environment.get': 'bru.getEnvVar',
'pm.environment.set': 'bru.setEnvVar',
'pm.environment.name': 'bru.getEnvName()',
'pm.environment.unset': 'bru.deleteEnvVar',
'pm.environment.replaceIn': 'bru.interpolate',
// Variables
'pm.variables.get': 'bru.getVar',
@@ -25,6 +27,7 @@ const simpleTranslations = {
'pm.collectionVariables.set': 'bru.setVar',
'pm.collectionVariables.has': 'bru.hasVar',
'pm.collectionVariables.unset': 'bru.deleteVar',
'pm.collectionVariables.replaceIn': 'bru.interpolate',
// Request flow control
'pm.setNextRequest': 'bru.setNextRequest',
@@ -80,7 +83,11 @@ const simpleTranslations = {
// Legacy Postman API (deprecated) (we can use pm instead of postman, as we are converting all postman references to pm in the code as the part of pre-processing)
'pm.setEnvironmentVariable': 'bru.setEnvVar',
'pm.getEnvironmentVariable': 'bru.getEnvVar',
'pm.clearEnvironmentVariable': 'bru.deleteEnvVar'
'pm.clearEnvironmentVariable': 'bru.deleteEnvVar',
// Legacy response properties
'responseCode.code': 'res.getStatus()',
'responseCode.name': 'res.statusText'
};
/* Complex transformations that need custom handling
@@ -328,6 +335,229 @@ const complexTransformations = [
[reduceFn, j.objectExpression([])]
);
}
},
// pm.globals.has requires special handling
{
pattern: 'pm.globals.has',
transform: (path, j) => {
const callExpr = path.parent.value;
const args = callExpr.arguments;
// Create: bru.getGlobalEnvVar(arg) !== undefined && bru.getGlobalEnvVar(arg) !== null
return j.logicalExpression(
'&&',
j.binaryExpression(
'!==',
j.callExpression(j.identifier('bru.getGlobalEnvVar'), args),
j.identifier('undefined')
),
j.binaryExpression(
'!==',
j.callExpression(j.identifier('bru.getGlobalEnvVar'), args),
j.identifier('null')
)
);
}
},
// pm.request.headers.add({key, value}) -> req.setHeader(key, value)
{
pattern: 'pm.request.headers.add',
transform: (path, j) => {
const callExpr = path.parent.value;
const args = callExpr.arguments;
// Check if the argument is an object with key and value properties
if (args.length > 0 && args[0].type === 'ObjectExpression') {
const obj = args[0];
let keyProp = null;
let valueProp = null;
obj.properties.forEach((prop) => {
if (prop.key.name === 'key' || prop.key.value === 'key') {
keyProp = prop.value;
}
if (prop.key.name === 'value' || prop.key.value === 'value') {
valueProp = prop.value;
}
});
if (keyProp && valueProp) {
return j.callExpression(
j.identifier('req.setHeader'),
[keyProp, valueProp]
);
}
}
// Fallback: keep original args
return j.callExpression(j.identifier('req.setHeader'), args);
}
},
// pm.request.headers.upsert({key, value}) -> req.setHeader(key, value)
{
pattern: 'pm.request.headers.upsert',
transform: (path, j) => {
const callExpr = path.parent.value;
const args = callExpr.arguments;
// Check if the argument is an object with key and value properties
if (args.length > 0 && args[0].type === 'ObjectExpression') {
const obj = args[0];
let keyProp = null;
let valueProp = null;
obj.properties.forEach((prop) => {
if (prop.key.name === 'key' || prop.key.value === 'key') {
keyProp = prop.value;
}
if (prop.key.name === 'value' || prop.key.value === 'value') {
valueProp = prop.value;
}
});
if (keyProp && valueProp) {
return j.callExpression(
j.identifier('req.setHeader'),
[keyProp, valueProp]
);
}
}
// Fallback: keep original args
return j.callExpression(j.identifier('req.setHeader'), args);
}
},
// pm.response.to.be.ok -> expect(res.getStatus()).to.be.within(200, 299)
{
pattern: 'pm.response.to.be.ok',
transform: (path, j) => {
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getStatus'), [])]),
j.identifier('to.be.within')
),
[j.literal(200), j.literal(299)]
);
}
},
// pm.response.to.be.success -> expect(res.getStatus()).to.be.within(200, 299)
{
pattern: 'pm.response.to.be.success',
transform: (path, j) => {
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getStatus'), [])]),
j.identifier('to.be.within')
),
[j.literal(200), j.literal(299)]
);
}
},
// pm.response.to.be.redirection -> expect(res.getStatus()).to.be.within(300, 399)
{
pattern: 'pm.response.to.be.redirection',
transform: (path, j) => {
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getStatus'), [])]),
j.identifier('to.be.within')
),
[j.literal(300), j.literal(399)]
);
}
},
// pm.response.to.be.clientError -> expect(res.getStatus()).to.be.within(400, 499)
{
pattern: 'pm.response.to.be.clientError',
transform: (path, j) => {
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getStatus'), [])]),
j.identifier('to.be.within')
),
[j.literal(400), j.literal(499)]
);
}
},
// pm.response.to.be.serverError -> expect(res.getStatus()).to.be.within(500, 599)
{
pattern: 'pm.response.to.be.serverError',
transform: (path, j) => {
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getStatus'), [])]),
j.identifier('to.be.within')
),
[j.literal(500), j.literal(599)]
);
}
},
// pm.response.to.be.error -> expect(res.getStatus()).to.be.at.least(400)
{
pattern: 'pm.response.to.be.error',
transform: (path, j) => {
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getStatus'), [])]),
j.identifier('to.be.at.least')
),
[j.literal(400)]
);
}
},
// pm.response.to.have.jsonBody(path) -> expect(res.getBody()).to.have.nested.property(path)
{
pattern: 'pm.response.to.have.jsonBody',
transform: (path, j) => {
const callExpr = path.parent.value;
const args = callExpr.arguments;
if (args.length === 0) {
// No path provided, just check that body exists
return j.memberExpression(
j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getBody'), [])]),
j.identifier('to.exist')
);
} else if (args.length === 1) {
// Path provided, check property exists
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getBody'), [])]),
j.identifier('to.have.nested.property')
),
args
);
} else {
// Path and value provided, check property equals value
return j.callExpression(
j.memberExpression(
j.callExpression(j.identifier('expect'), [j.callExpression(j.identifier('res.getBody'), [])]),
j.identifier('to.have.nested.property')
),
args
);
}
}
},
// Legacy postman.getResponseHeader(name) -> res.getHeader(name)
{
pattern: 'pm.getResponseHeader',
transform: (path, j) => {
const callExpr = path.parent.value;
const args = callExpr.arguments;
return j.callExpression(j.identifier('res.getHeader'), args);
}
}
];
@@ -360,24 +590,40 @@ function processTransformations(ast, transformedNodes) {
}
// Then check for complex transformations (O(1))
if (complexTransformationsMap.hasOwnProperty(memberExprStr)
&& path.parent.value.type === 'CallExpression') {
const transform = complexTransformationsMap[memberExprStr];
const replacement = transform.transform(path, j);
if (Array.isArray(replacement)) {
replacement.forEach((nodePath, index) => {
if (index === 0) {
j(path.parent).replaceWith(nodePath);
} else {
j(path.parent.parent).insertAfter(nodePath);
if (complexTransformationsMap.hasOwnProperty(memberExprStr)) {
const parentType = path.parent.value.type;
// Call-based patterns (e.g., pm.response.to.have.jsonBody("path"))
if (parentType === 'CallExpression') {
const transform = complexTransformationsMap[memberExprStr];
const replacement = transform.transform(path, j);
if (Array.isArray(replacement)) {
// Capture stable references before mutating the AST
const parentPath = path.parent;
const grandParentPath = parentPath.parent;
// Replace the original CallExpression with the first node
j(parentPath).replaceWith(replacement[0]);
transformedNodes.add(replacement[0]);
transformedNodes.add(parentPath.node);
// Insert remaining nodes after the grandparent in reverse order
// so that repeated insertAfter on the same anchor yields correct sequence
for (let i = replacement.length - 1; i >= 1; i--) {
j(grandParentPath).insertAfter(replacement[i]);
transformedNodes.add(replacement[i]);
}
transformedNodes.add(nodePath.node);
} else {
j(path.parent).replaceWith(replacement);
transformedNodes.add(path.node);
transformedNodes.add(path.parent.node);
});
} else {
j(path.parent).replaceWith(replacement);
}
} else if (parentType === 'ExpressionStatement') {
// Property-access patterns used as statements (e.g., pm.response.to.be.ok;)
const transform = complexTransformationsMap[memberExprStr];
const replacement = transform.transform(path, j);
j(path).replaceWith(replacement);
transformedNodes.add(path.node);
transformedNodes.add(path.parent.node);
}
}
});

View File

@@ -203,4 +203,18 @@ console.log("Headers:", JSON.stringify(pm.request.headers));
const translatedCode = translateBruToPostman(code);
expect(translatedCode).toBe('const pathParams = pm.request.url.variables;');
});
it('should handle URL methods in complex expressions', () => {
const code = 'const fullUrl = req.getHost() + req.getPath() + "?" + req.getQueryString();';
const translatedCode = translateBruToPostman(code);
expect(translatedCode).toContain('pm.request.url.getHost()');
expect(translatedCode).toContain('pm.request.url.getPath()');
expect(translatedCode).toContain('pm.request.url.getQueryString()');
});
it('should handle req.getPathParams() in conditional', () => {
const code = 'if (req.getPathParams().id) { console.log("Has ID"); }';
const translatedCode = translateBruToPostman(code);
expect(translatedCode).toContain('pm.request.url.variables.id');
});
});

View File

@@ -295,4 +295,55 @@ describe('Legacy Postman API Translation', () => {
expect(result).toEqual(expected);
});
});
describe('responseCode translations', () => {
test('should translate responseCode.code', () => {
const input = 'const status = responseCode.code;';
const result = translateCode(input);
expect(result).toBe('const status = res.getStatus();');
});
test('should translate responseCode.name', () => {
const input = 'const statusName = responseCode.name;';
const result = translateCode(input);
expect(result).toBe('const statusName = res.statusText;');
});
test('should translate responseCode.code in conditional', () => {
const input = 'if (responseCode.code === 200) { console.log("Success"); }';
const result = translateCode(input);
expect(result).toBe('if (res.getStatus() === 200) { console.log("Success"); }');
});
test('should translate both responseCode.code and responseCode.name together', () => {
const input = `
const code = responseCode.code;
const name = responseCode.name;
console.log(code, name);
`;
const result = translateCode(input);
expect(result).toContain('const code = res.getStatus();');
expect(result).toContain('const name = res.statusText;');
});
});
describe('postman.getResponseHeader translations', () => {
test('should translate postman.getResponseHeader', () => {
const input = 'postman.getResponseHeader("Content-Type");';
const result = translateCode(input);
expect(result).toBe('res.getHeader("Content-Type");');
});
test('should translate postman.getResponseHeader in assignment', () => {
const input = 'const contentType = postman.getResponseHeader("Content-Type");';
const result = translateCode(input);
expect(result).toBe('const contentType = res.getHeader("Content-Type");');
});
test('should translate postman.getResponseHeader with variable argument', () => {
const input = 'const headerName = "Authorization"; const value = postman.getResponseHeader(headerName);';
const result = translateCode(input);
expect(result).toContain('res.getHeader(headerName)');
});
});
});

View File

@@ -121,4 +121,39 @@ describe('Request Translation', () => {
const name = req.getName();
`);
});
// --- pm.request.headers.add and upsert ---------------------------
it('should translate pm.request.headers.add with object argument', () => {
const code = 'pm.request.headers.add({key: "Authorization", value: "Bearer token"});';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('req.setHeader("Authorization", "Bearer token");');
});
it('should translate pm.request.headers.upsert with object argument', () => {
const code = 'pm.request.headers.upsert({key: "Content-Type", value: "application/json"});';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('req.setHeader("Content-Type", "application/json");');
});
it('should translate pm.request.headers.add with quoted key property', () => {
const code = 'pm.request.headers.add({"key": "X-Custom-Header", "value": "custom-value"});';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('req.setHeader("X-Custom-Header", "custom-value");');
});
it('should translate pm.request.headers.upsert with variable values', () => {
const code = 'const headerName = "Authorization"; const headerValue = "Bearer " + token; pm.request.headers.upsert({key: headerName, value: headerValue});';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('req.setHeader(headerName, headerValue)');
});
it('should translate multiple headers.add calls', () => {
const code = `
pm.request.headers.add({key: "Authorization", value: "Bearer token"});
pm.request.headers.add({key: "Content-Type", value: "application/json"});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('req.setHeader("Authorization", "Bearer token")');
expect(translatedCode).toContain('req.setHeader("Content-Type", "application/json")');
});
});

View File

@@ -583,4 +583,97 @@ describe('Response Translation', () => {
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const responseSize = res.getSize().body;');
});
// --- BDD-style response assertions ---------------------------
it('should translate pm.response.to.be.ok', () => {
const code = 'pm.response.to.be.ok;';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getStatus()).to.be.within(200, 299)');
});
it('should translate pm.response.to.be.success', () => {
const code = 'pm.response.to.be.success;';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getStatus()).to.be.within(200, 299)');
});
it('should translate pm.response.to.be.redirection', () => {
const code = 'pm.response.to.be.redirection;';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getStatus()).to.be.within(300, 399)');
});
it('should translate pm.response.to.be.clientError', () => {
const code = 'pm.response.to.be.clientError;';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getStatus()).to.be.within(400, 499)');
});
it('should translate pm.response.to.be.serverError', () => {
const code = 'pm.response.to.be.serverError;';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getStatus()).to.be.within(500, 599)');
});
it('should translate pm.response.to.be.error', () => {
const code = 'pm.response.to.be.error;';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getStatus()).to.be.at.least(400)');
});
it('should handle BDD-style assertions inside test blocks', () => {
const code = `
pm.test("Status check", function() {
pm.response.to.be.ok;
pm.response.to.be.success;
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Status check", function() {');
expect(translatedCode).toContain('expect(res.getStatus()).to.be.within(200, 299)');
});
it('should translate pm.response.to.have.jsonBody with path', () => {
const code = 'pm.response.to.have.jsonBody("user.id");';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property("user.id")');
});
it('should translate pm.response.to.have.jsonBody with path and value', () => {
const code = 'pm.response.to.have.jsonBody("status", "success");';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property("status", "success")');
});
it('should translate pm.response.to.have.jsonBody without arguments', () => {
const code = 'pm.response.to.have.jsonBody();';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getBody()).to.exist');
});
it('should handle pm.response.to.have.jsonBody inside test blocks', () => {
const code = `
pm.test("Response validation", function() {
pm.response.to.have.jsonBody("data");
pm.response.to.have.jsonBody("data.id", 123);
});
`;
const translatedCode = translateCode(code);
expect(translatedCode).toContain('test("Response validation", function() {');
expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property("data")');
expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property("data.id", 123)');
});
it('should translate pm.response.to.have.jsonBody with nested path', () => {
const code = 'pm.response.to.have.jsonBody("response.data.items[0].name");';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property("response.data.items[0].name")');
});
it('should translate pm.response.to.have.jsonBody with variable path', () => {
const code = 'const path = "user.id"; pm.response.to.have.jsonBody(path);';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('expect(res.getBody()).to.have.nested.property(path)');
});
});

View File

@@ -204,4 +204,58 @@ describe('Variables Translation', () => {
expect(translatedCode).toBe('bru.setVar("fullPath", bru.getEnvVar("baseUrl") + bru.getVar("endpoint"));');
});
// replaceIn tests for different variable scopes
it('should translate pm.globals.replaceIn', () => {
const code = 'pm.globals.replaceIn("Hello {{name}}");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.interpolate("Hello {{name}}");');
});
it('should translate pm.environment.replaceIn', () => {
const code = 'pm.environment.replaceIn("{{baseUrl}}/api");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.interpolate("{{baseUrl}}/api");');
});
it('should translate pm.collectionVariables.replaceIn', () => {
const code = 'pm.collectionVariables.replaceIn("{{apiKey}}");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('bru.interpolate("{{apiKey}}");');
});
it('should translate pm.globals.replaceIn in assignment', () => {
const code = 'const message = pm.globals.replaceIn("Welcome {{username}}!");';
const translatedCode = translateCode(code);
expect(translatedCode).toBe('const message = bru.interpolate("Welcome {{username}}!");');
});
// pm.globals.has tests
it('should translate pm.globals.has', () => {
const code = 'pm.globals.has("token");';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.getGlobalEnvVar("token") !== undefined');
expect(translatedCode).toContain('bru.getGlobalEnvVar("token") !== null');
});
it('should translate pm.globals.has in conditional', () => {
const code = 'if (pm.globals.has("authToken")) { console.log("Token exists"); }';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('bru.getGlobalEnvVar("authToken") !== undefined');
expect(translatedCode).toContain('bru.getGlobalEnvVar("authToken") !== null');
expect(translatedCode).toContain('console.log("Token exists");');
});
it('should translate pm.globals.has with variable assignment', () => {
const code = 'const hasGlobal = pm.globals.has("config");';
const translatedCode = translateCode(code);
expect(translatedCode).toContain('const hasGlobal = bru.getGlobalEnvVar("config") !== undefined && bru.getGlobalEnvVar("config") !== null');
});
});