From a036396cb8dfbe2563712334d24c245c12ec87b9 Mon Sep 17 00:00:00 2001 From: lohit Date: Wed, 18 Feb 2026 11:54:08 +0000 Subject: [PATCH] fix: `isJson` assertion fails after `res.setBody()` with object in `node-vm` (#7191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: isJson assertion fails after res.setBody() with object in node-vm Objects created inside Node's vm.createContext() have a different Object constructor than the host realm. When res.setBody() is called with a JS object from a script, _.cloneDeep preserves the cross-realm prototype, causing obj.constructor === Object to fail in the isJson assertion. Replace with Object.prototype.toString.call() which is cross-realm safe. * fix: register isJson chai assertion in QuickJS test runtime The bundled chai in QuickJS only exposes { expect, assert } via requireObject — no Assertion class. Access the prototype through Object.getPrototypeOf(expect(null)) and use Object.defineProperty to register the json property directly. * fix: enable assertion chaining on isJson in QuickJS runtime The QuickJS isJson property getter was missing `return this`, preventing chai assertion chaining (e.g. expect(body).to.be.json.and...). --- .../bruno-js/src/runtime/assert-runtime.js | 7 +- .../src/sandbox/quickjs/shims/test.js | 21 +++++ packages/bruno-js/tests/runtime.spec.js | 84 +++++++++++++++++++ .../api/res/setBody/isJson after setBody.bru | 36 ++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 packages/bruno-tests/collection/scripting/api/res/setBody/isJson after setBody.bru diff --git a/packages/bruno-js/src/runtime/assert-runtime.js b/packages/bruno-js/src/runtime/assert-runtime.js index ba752bf0d..3dd25f818 100644 --- a/packages/bruno-js/src/runtime/assert-runtime.js +++ b/packages/bruno-js/src/runtime/assert-runtime.js @@ -13,7 +13,12 @@ chai.use(function (chai, utils) { // Custom assertion for checking if a variable is JSON chai.Assertion.addProperty('json', function () { const obj = this._obj; - const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) && obj.constructor === Object; + // Use Object.prototype.toString instead of constructor check for cross-realm compatibility. + // Objects created inside Node's vm.createContext() have a different Object constructor, + // so obj.constructor === Object fails for objects passed via res.setBody() from scripts. + // Note: toString check is more permissive than constructor check — custom class instances + const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) + && Object.prototype.toString.call(obj) === '[object Object]'; this.assert(isJson, `expected ${utils.inspect(obj)} to be JSON`, `expected ${utils.inspect(obj)} not to be JSON`); }); diff --git a/packages/bruno-js/src/sandbox/quickjs/shims/test.js b/packages/bruno-js/src/sandbox/quickjs/shims/test.js index 9da224a39..12c6e5058 100644 --- a/packages/bruno-js/src/sandbox/quickjs/shims/test.js +++ b/packages/bruno-js/src/sandbox/quickjs/shims/test.js @@ -58,6 +58,27 @@ const addBruShimToContext = (vm, __brunoTestResults) => { globalThis.test = Test(__brunoTestResults); ` ); + + // Register custom chai assertion for isJson (expect(...).to.be.json) + // The bundled chai only exposes { expect, assert } — no Assertion class. + // Access the prototype through an expect() instance instead. + vm.evalCode( + ` + (function() { + var proto = Object.getPrototypeOf(expect(null)); + Object.defineProperty(proto, 'json', { + get: function () { + var obj = this._obj; + var isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj) && + Object.prototype.toString.call(obj) === '[object Object]'; + this.assert(isJson, 'expected #{this} to be JSON', 'expected #{this} not to be JSON'); + return this; + }, + configurable: true + }); + })(); + ` + ); }; module.exports = addBruShimToContext; diff --git a/packages/bruno-js/tests/runtime.spec.js b/packages/bruno-js/tests/runtime.spec.js index 797e30598..2ecc19f4e 100644 --- a/packages/bruno-js/tests/runtime.spec.js +++ b/packages/bruno-js/tests/runtime.spec.js @@ -1,6 +1,7 @@ const { describe, it, expect } = require('@jest/globals'); const TestRuntime = require('../src/runtime/test-runtime'); const ScriptRuntime = require('../src/runtime/script-runtime'); +const AssertRuntime = require('../src/runtime/assert-runtime'); const Bru = require('../src/bru'); const VarsRuntime = require('../src/runtime/vars-runtime'); @@ -258,4 +259,87 @@ describe('runtime', () => { expect(result.runtimeVariables.title).toBe('{{$randomFirstName}}'); }); }); + + describe('assert-runtime', () => { + const baseRequest = { + method: 'GET', + url: 'http://localhost:3000/', + headers: {}, + data: undefined + }; + + const makeResponse = (data) => ({ + status: 200, + statusText: 'OK', + data, + headers: {} + }); + + const runAssertions = (assertions, response, runtime = 'nodevm') => { + const assertRuntime = new AssertRuntime({ runtime }); + return assertRuntime.runAssertions(assertions, { ...baseRequest }, response, {}, {}, process.env); + }; + + describe('isJson', () => { + it('should pass for a plain object', () => { + const results = runAssertions( + [{ name: 'res.body', value: 'isJson', enabled: true }], + makeResponse({ id: 1, name: 'test' }) + ); + expect(results[0].status).toBe('pass'); + }); + + it('should pass for a nested object', () => { + const results = runAssertions( + [{ name: 'res.body', value: 'isJson', enabled: true }], + makeResponse({ user: { id: 1, tags: ['a', 'b'] } }) + ); + expect(results[0].status).toBe('pass'); + }); + + it('should pass for objects from a different realm (e.g. after res.setBody in node-vm)', async () => { + const response = makeResponse({ id: 1, name: 'original' }); + + // res.setBody() inside node-vm creates a cross-realm object whose + // constructor is the VM's Object, not the host's Object + const scriptRuntime = new ScriptRuntime({ runtime: 'nodevm' }); + await scriptRuntime.runResponseScript( + `res.setBody({ id: 2, name: 'updated' });`, + { ...baseRequest }, + response, + {}, {}, '.', null, process.env + ); + + const results = runAssertions( + [{ name: 'res.body', value: 'isJson', enabled: true }], + response + ); + expect(results[0].status).toBe('pass'); + }); + + it('should fail for an array', () => { + const results = runAssertions( + [{ name: 'res.body', value: 'isJson', enabled: true }], + makeResponse([1, 2, 3]) + ); + expect(results[0].status).toBe('fail'); + }); + + it('should fail for a string', () => { + const results = runAssertions( + [{ name: 'res.body', value: 'isJson', enabled: true }], + makeResponse('hello') + ); + expect(results[0].status).toBe('fail'); + }); + + it('should fail for null', () => { + const results = runAssertions( + [{ name: 'res.body', value: 'isJson', enabled: true }], + makeResponse(null) + ); + expect(results[0].status).toBe('fail'); + }); + }); + }); }); diff --git a/packages/bruno-tests/collection/scripting/api/res/setBody/isJson after setBody.bru b/packages/bruno-tests/collection/scripting/api/res/setBody/isJson after setBody.bru new file mode 100644 index 000000000..1f858352a --- /dev/null +++ b/packages/bruno-tests/collection/scripting/api/res/setBody/isJson after setBody.bru @@ -0,0 +1,36 @@ +meta { + name: isJson after setBody + type: http + seq: 2 +} + +post { + url: {{host}}/api/echo/json + body: json + auth: none +} + +body:json { + { + "hello": "bruno" + } +} + +assert { + res.status: eq 200 + res.body: isJson +} + +script:post-response { + res.setBody({ id: 1, name: "updated", nested: { key: "value" } }); +} + +tests { + test("res.body should be json after setBody with object", function() { + const body = res.getBody(); + expect(body).to.be.json; + expect(body.id).to.eql(1); + expect(body.name).to.eql("updated"); + expect(body.nested.key).to.eql("value"); + }); +}