fix: isJson assertion fails after res.setBody() with object in node-vm (#7191)

* 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...).
This commit is contained in:
lohit
2026-02-18 11:54:08 +00:00
committed by GitHub
parent 2337d77092
commit 479fc160d7
4 changed files with 147 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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