diff --git a/packages/bruno-cli/src/runner/interpolate-vars.js b/packages/bruno-cli/src/runner/interpolate-vars.js index a5a867f2b..7fcf860bd 100644 --- a/packages/bruno-cli/src/runner/interpolate-vars.js +++ b/packages/bruno-cli/src/runner/interpolate-vars.js @@ -96,7 +96,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc value: _interpolate(d?.value) })); } - } else if (contentType === 'multipart/form-data') { + } else if (contentType.startsWith('multipart/')) { if (Array.isArray(request?.data) && !isFormData(request.data)) { try { request.data = request?.data?.map((d) => ({ diff --git a/packages/bruno-cli/src/runner/run-single-request.js b/packages/bruno-cli/src/runner/run-single-request.js index ee46286f9..a34f6383c 100644 --- a/packages/bruno-cli/src/runner/run-single-request.js +++ b/packages/bruno-cli/src/runner/run-single-request.js @@ -540,12 +540,23 @@ const runSingleRequest = async function ( // if `data` is of string type - return as-is (assumes already encoded) } - if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') { + if (contentTypeHeader && contentTypeHeader.startsWith('multipart/')) { if (!isFormData(request?.data)) { request._originalMultipartData = request.data; request.collectionPath = collectionPath; let form = createFormData(request.data, collectionPath); request.data = form; + + if (request?.headers?.['content-type'] !== 'multipart/form-data') { + // Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched + const formHeaders = form.getHeaders(); + const ct = request.headers['content-type']; + formHeaders['content-type'] = `${ct}; boundary=${form.getBoundary()}`; + form.getHeaders = function () { + return formHeaders; + }; + } + extend(request.headers, form.getHeaders()); } } diff --git a/packages/bruno-electron/src/ipc/network/index.js b/packages/bruno-electron/src/ipc/network/index.js index 39b43f358..0942ac374 100644 --- a/packages/bruno-electron/src/ipc/network/index.js +++ b/packages/bruno-electron/src/ipc/network/index.js @@ -582,12 +582,22 @@ const registerNetworkIpc = (mainWindow) => { // if `data` is of string type - return as-is (assumes already encoded) } - if (contentTypeHeader && request.headers[contentTypeHeader] === 'multipart/form-data') { + if (contentTypeHeader && contentTypeHeader.startsWith('multipart/')) { if (!isFormData(request.data)) { request._originalMultipartData = request.data; request.collectionPath = collectionPath; let form = createFormData(request.data, collectionPath); request.data = form; + if (contentTypeHeader !== 'multipart/form-data') { + // Patch: Axios leverages getHeaders method to get the headers so FormData should be monkey patched + const formHeaders = form.getHeaders(); + const ct = contentTypeHeader; + formHeaders['content-type'] = `${ct}; boundary=${form.getBoundary()}`; + form.getHeaders = function () { + return formHeaders; + }; + } + extend(request.headers, form.getHeaders()); } } diff --git a/packages/bruno-electron/src/ipc/network/interpolate-vars.js b/packages/bruno-electron/src/ipc/network/interpolate-vars.js index 61c9fbd71..f90d35dac 100644 --- a/packages/bruno-electron/src/ipc/network/interpolate-vars.js +++ b/packages/bruno-electron/src/ipc/network/interpolate-vars.js @@ -76,11 +76,10 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc const contentType = getContentType(request.headers); const isGraphqlRequest = request.mode === 'graphql'; - if (isGrpcRequest) { + // gRPC: interpolate entire body (JSON message template and any other keys). + if (isGrpcRequest && request.body) { const jsonDoc = JSON.stringify(request.body); - const parsed = _interpolate(jsonDoc, { - escapeJSONStrings: true - }); + const parsed = _interpolate(jsonDoc, { escapeJSONStrings: true }); request.body = JSON.parse(parsed); } // Interpolate WebSocket message body @@ -88,15 +87,11 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc if (isWsRequest && request.body && request.body.ws && Array.isArray(request.body.ws)) { request.body.ws.forEach((message) => { if (message && message.content) { - // Try to detect if content is JSON for proper escaping let isJson = false; try { JSON.parse(message.content); isJson = true; - } catch (e) { - // Not JSON, treat as regular string - } - + } catch (e) {} message.content = _interpolate(message.content, { escapeJSONStrings: isJson }); @@ -138,7 +133,7 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc value: _interpolate(d?.value) })); } - } else if (contentType === 'multipart/form-data') { + } else if (contentType.startsWith('multipart/')) { if (Array.isArray(request?.data) && !isFormData(request.data)) { try { request.data = request?.data?.map((d) => ({ diff --git a/packages/bruno-electron/tests/network/interpolate-vars.spec.js b/packages/bruno-electron/tests/network/interpolate-vars.spec.js index 003928a11..2eea40a93 100644 --- a/packages/bruno-electron/tests/network/interpolate-vars.spec.js +++ b/packages/bruno-electron/tests/network/interpolate-vars.spec.js @@ -294,4 +294,136 @@ describe('interpolate-vars: interpolateVars', () => { expect(result.data).toEqual(undefined); }); }); + + describe('Multipart body (multipart/form-data and multipart/mixed)', () => { + it('interpolates value in each part when Content-Type is multipart/form-data', () => { + const request = { + method: 'POST', + url: 'http://api.example/upload', + headers: { 'Content-Type': 'multipart/form-data; boundary=----boundary' }, + data: [ + { name: 'field1', value: '{{token}}', type: 'text' }, + { name: 'field2', value: 'static', type: 'text' }, + { name: 'field3', value: '{{prefix}}-suffix', type: 'text' } + ] + }; + + const result = interpolateVars( + request, + { token: 'secret123', prefix: 'my' }, + null, + null + ); + + expect(result.data).toEqual([ + { name: 'field1', value: 'secret123', type: 'text' }, + { name: 'field2', value: 'static', type: 'text' }, + { name: 'field3', value: 'my-suffix', type: 'text' } + ]); + }); + + it('interpolates value in each part when Content-Type is multipart/mixed', () => { + const request = { + method: 'POST', + url: 'http://api.example/send', + headers: { 'Content-Type': 'multipart/mixed; boundary=----mixed' }, + data: [ + { name: 'part1', value: '{{envVar}}', type: 'text' }, + { name: 'part2', value: '{{another}}', type: 'text' } + ] + }; + + const result = interpolateVars( + request, + { envVar: 'first', another: 'second' }, + null, + null + ); + + expect(result.data).toEqual([ + { name: 'part1', value: 'first', type: 'text' }, + { name: 'part2', value: 'second', type: 'text' } + ]); + }); + + it('leaves part keys (name, type, etc.) intact and only interpolates value', () => { + const request = { + method: 'POST', + url: 'http://api.example/upload', + headers: { 'Content-Type': 'multipart/form-data' }, + data: [ + { name: 'file', value: '{{path}}', type: 'file', fileName: 'doc.pdf' } + ] + }; + + const result = interpolateVars(request, { path: '/tmp/doc.pdf' }, null, null); + + expect(result.data).toHaveLength(1); + expect(result.data[0].name).toBe('file'); + expect(result.data[0].type).toBe('file'); + expect(result.data[0].fileName).toBe('doc.pdf'); + expect(result.data[0].value).toBe('/tmp/doc.pdf'); + }); + + it('handles empty multipart array', () => { + const request = { + method: 'POST', + url: 'http://api.example/upload', + headers: { 'Content-Type': 'multipart/form-data' }, + data: [] + }; + + const result = interpolateVars(request, { x: 'y' }, null, null); + + expect(result.data).toEqual([]); + }); + + it('handles part with missing or undefined value', () => { + const request = { + method: 'POST', + url: 'http://api.example/upload', + headers: { 'Content-Type': 'multipart/form-data' }, + data: [ + { name: 'a', value: '{{present}}' }, + { name: 'b' }, + { name: 'c', value: undefined } + ] + }; + + const result = interpolateVars(request, { present: 'ok' }, null, null); + + expect(result.data[0].value).toBe('ok'); + expect(result.data[1].value).toBeUndefined(); + expect(result.data[2].value).toBeUndefined(); + }); + + it('preserves raw string body when Content-Type is multipart/mixed (manually constructed multipart)', () => { + // Equivalent to: curl -X POST https://httpbin.dev/post \ + // -H 'content-type: multipart/mixed; boundary=TestBoundary123' \ + // --data '--TestBoundary123\r\nContent-Type: application/json\r\n\r\n{"test": true}\r\n--TestBoundary123--\r\n' + const rawMultipartBody = [ + '--TestBoundary123', + 'Content-Type: application/json', + '', + '{"test": true}', + '--TestBoundary123--', + '' + ].join('\r\n'); + + const request = { + method: 'POST', + url: 'https://httpbin.dev/post', + headers: { 'content-type': 'multipart/mixed; boundary=TestBoundary123' }, + data: rawMultipartBody + }; + + const result = interpolateVars(request, {}, null, null); + + expect(result.data).toBe(rawMultipartBody); + expect(result.data).toContain('--TestBoundary123'); + expect(result.data).toContain('Content-Type: application/json'); + expect(result.data).toContain('{"test": true}'); + expect(result.data).toContain('--TestBoundary123--'); + }); + }); }); diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js index 69db5cd2c..60b143ab4 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/bru.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/bru.js @@ -471,20 +471,26 @@ const addBruShimToContext = (vm, bru) => { bruObject.dispose(); vm.evalCode(` + // sendRequest with callback: normalize error.status (axios uses error.response.status) so + // tests like expect(error.status).to.eql(404) pass in safe sandbox; return response after + // success callback for consistent promise resolution. globalThis.bru.sendRequest = async (requestConfig, callback) => { if (!callback) return await globalThis.bru._sendRequest(requestConfig); try { const response = await globalThis.bru._sendRequest(requestConfig); try { await callback(null, response); + return response; } catch(error) { return Promise.reject(error); } } catch(error) { + const errObj = JSON.parse(JSON.stringify(error)); + if (errObj && errObj.response && typeof errObj.response.status === 'number') errObj.status = errObj.response.status; try { - await callback(JSON.parse(JSON.stringify(error)), null); + await callback(errObj, null); } catch(err) { return Promise.reject(err); diff --git a/packages/bruno-requests/src/scripting/send-request.ts b/packages/bruno-requests/src/scripting/send-request.ts index 2564fde52..935a820d1 100644 --- a/packages/bruno-requests/src/scripting/send-request.ts +++ b/packages/bruno-requests/src/scripting/send-request.ts @@ -58,9 +58,16 @@ const createSendRequest = (config?: SendRequestConfig) => { } catch (error) { return Promise.reject(error); } - } catch (error) { + } catch (error: any) { + // Normalize axios error for callback: tests expect error.status (e.g. 404), but axios + // puts the status on error.response.status. Setting status here ensures the same + // behaviour in nodevm (--sandbox developer, used in CI) and in QuickJS (safe sandbox). + const errForCallback + = error && typeof error.response?.status === 'number' + ? { ...error, status: error.response.status } + : error; try { - await callback(error, null); + await callback(errForCallback, null); } catch (err) { return Promise.reject(err); } diff --git a/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookie.bru b/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookie.bru index 0e4a2bdf5..ed5dd963f 100644 --- a/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookie.bru +++ b/packages/bruno-tests/collection/scripting/api/bru/cookies/getCookie.bru @@ -18,8 +18,9 @@ script:pre-request { tests { const jar = bru.cookies.jar() - - jar.getCookie("https://testbench-sanity.usebruno.com", "name", function(error, data) { + // Await so the callback runs before jar.clear() below; otherwise the test script can finish + // before the callback registers/runs the test, causing a flaky failure (e.g. in CI). + await jar.getCookie("https://testbench-sanity.usebruno.com", "name", function(error, data) { if(error) { console.error("Cookie retrieval error:", error) throw new Error(`Failed to get cookie: ${error.message || error}`)