Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/bruno-js/src/runtime/assert-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]';
Comment on lines +19 to +21
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Comment on Line 19 is truncated — complete the thought.

The comment ends mid-sentence (— custom class instances) without saying what the implication is. The actual behavioral change is that class instances without a custom [Symbol.toStringTag] will now also pass isJson (e.g., new class Foo {} now returns true where the old constructor === Object check would have returned false). Worth capturing so future readers know the tradeoff.

📝 Suggested completion
-    // Note: toString check is more permissive than constructor check — custom class instances
+    // Note: toString check is more permissive than constructor check — custom class instances
+    // without a [Symbol.toStringTag] also return '[object Object]' and will pass. This is
+    // acceptable since response bodies are never expected to be class instances.
     const isJson = typeof obj === 'object' && obj !== null && !Array.isArray(obj)
       && Object.prototype.toString.call(obj) === '[object Object]';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bruno-js/src/runtime/assert-runtime.js` around lines 19 - 21,
Complete the truncated comment above the isJson check to explain the tradeoff:
state that using Object.prototype.toString.call(obj) === '[object Object]' is
more permissive than checking obj.constructor === Object because it will treat
plain objects and also instances of custom classes (without a custom
Symbol.toStringTag) as JSON-like objects (e.g., new class Foo {} will return
true), and note that this diverges from the previous behavior which excluded
such class instances; reference the isJson variable and the
Object.prototype.toString.call check so future readers understand the rationale
and implications.


this.assert(isJson, `expected ${utils.inspect(obj)} to be JSON`, `expected ${utils.inspect(obj)} not to be JSON`);
});
Expand Down
21 changes: 21 additions & 0 deletions packages/bruno-js/src/sandbox/quickjs/shims/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
84 changes: 84 additions & 0 deletions packages/bruno-js/tests/runtime.spec.js
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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');
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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");
});
}
Loading