Files
bruno/packages/bruno-js/tests/quickjs-handle-lifecycle.spec.js
2026-06-17 13:46:14 +05:30

308 lines
8.8 KiB
JavaScript

const { describe, it, expect, beforeAll, afterAll, beforeEach } = require('@jest/globals');
const { newQuickJSWASMModule } = require('quickjs-emscripten');
const Bru = require('../src/bru');
const {
createManagedQuickJsContext,
disposeQuickJsContext,
trackQuickJsContext
} = require('../src/sandbox/quickjs/utils');
describe('QuickJS handle lifecycle utils', () => {
let module;
beforeAll(async () => {
module = await newQuickJSWASMModule();
});
afterAll(() => {
module = null;
});
describe('trackQuickJsContext', () => {
it('tracks newObject, newArray, and newFunction handles', () => {
const vm = module.newContext();
const disposeTracked = trackQuickJsContext(vm);
const objectHandle = vm.newObject();
const arrayHandle = vm.newArray();
const functionHandle = vm.newFunction('trackedFn', () => vm.undefined);
expect(objectHandle.alive).toBe(true);
expect(arrayHandle.alive).toBe(true);
expect(functionHandle.alive).toBe(true);
disposeTracked();
expect(objectHandle.alive).toBe(false);
expect(arrayHandle.alive).toBe(false);
expect(functionHandle.alive).toBe(false);
expect(vm.alive).toBe(true);
disposeQuickJsContext(vm);
});
it('disposes nested tracked handles without throwing', () => {
const vm = module.newContext();
const disposeTracked = trackQuickJsContext(vm);
const child = vm.newObject();
const parent = vm.newObject();
vm.setProp(parent, 'child', child);
expect(parent.alive).toBe(true);
expect(child.alive).toBe(true);
expect(() => disposeTracked()).not.toThrow();
expect(parent.alive).toBe(false);
expect(child.alive).toBe(false);
disposeQuickJsContext(vm);
});
it('can be called more than once safely', () => {
const vm = module.newContext();
const disposeTracked = trackQuickJsContext(vm);
const handle = vm.newObject();
disposeTracked();
expect(handle.alive).toBe(false);
expect(() => disposeTracked()).not.toThrow();
expect(handle.alive).toBe(false);
disposeQuickJsContext(vm);
});
});
describe('disposeQuickJsContext', () => {
it('disposes tracked handles before disposing the context', () => {
const vm = module.newContext();
const disposeTracked = trackQuickJsContext(vm);
const handle = vm.newArray();
disposeQuickJsContext(vm, disposeTracked);
expect(handle.alive).toBe(false);
expect(vm.alive).toBe(false);
});
it('is a no-op when the context is already disposed', () => {
const vm = module.newContext();
const disposeTracked = trackQuickJsContext(vm);
const handle = vm.newObject();
disposeQuickJsContext(vm, disposeTracked);
expect(handle.alive).toBe(false);
expect(vm.alive).toBe(false);
expect(() => disposeQuickJsContext(vm, disposeTracked)).not.toThrow();
});
});
describe('createManagedQuickJsContext', () => {
it('clears tracked handles when dispose is called', () => {
const managed = createManagedQuickJsContext(module);
const { vm } = managed;
const objectHandle = vm.newObject();
const functionHandle = vm.newFunction('shimFn', () => vm.true);
expect(objectHandle.alive).toBe(true);
expect(functionHandle.alive).toBe(true);
managed.dispose();
expect(objectHandle.alive).toBe(false);
expect(functionHandle.alive).toBe(false);
expect(vm.alive).toBe(false);
});
it('auto-disposes evalCode results while keeping the context alive', () => {
const managed = createManagedQuickJsContext(module);
const { vm } = managed;
expect(() => {
vm.evalCode('1 + 1');
}).not.toThrow();
expect(vm.alive).toBe(true);
managed.dispose();
expect(vm.alive).toBe(false);
});
it('exposes evalCodeRetained for callers that manage result handles themselves', () => {
const managed = createManagedQuickJsContext(module);
const { vm } = managed;
const result = vm.evalCodeRetained('42');
const valueHandle = vm.unwrapResult(result);
const value = vm.dump(valueHandle);
expect(value).toBe(42);
expect(valueHandle.alive).toBe(true);
valueHandle.dispose();
expect(valueHandle.alive).toBe(false);
managed.dispose();
expect(vm.alive).toBe(false);
});
});
});
describe('QuickJS VM handle lifecycle', () => {
let executeQuickJsVm;
let executeQuickJsVmAsync;
let loader;
let createManagedQuickJsContextSpy;
let lastManaged;
let lastHandles;
const makeBru = () =>
new Bru({
runtime: 'quickjs',
envVariables: {},
runtimeVariables: {},
processEnvVars: {},
collectionPath: '/',
collectionName: 'Test'
});
const assertHandlesWereCleared = () => {
expect(lastHandles.length).toBeGreaterThan(0);
lastHandles.forEach((handle) => expect(handle.alive).toBe(false));
expect(lastManaged.vm.alive).toBe(false);
};
beforeAll(async () => {
jest.resetModules();
const utils = require('../src/sandbox/quickjs/utils');
const createManaged = utils.createManagedQuickJsContext;
createManagedQuickJsContextSpy = jest
.spyOn(utils, 'createManagedQuickJsContext')
.mockImplementation((moduleArg) => {
lastHandles = [];
lastManaged = createManaged(moduleArg);
const { vm } = lastManaged;
['newObject', 'newArray', 'newFunction'].forEach((method) => {
const allocate = vm[method];
vm[method] = (...args) => {
const handle = allocate(...args);
lastHandles.push(handle);
return handle;
};
});
return lastManaged;
});
({ executeQuickJsVm, executeQuickJsVmAsync, loader } = require('../src/sandbox/quickjs'));
await loader();
});
afterAll(() => {
createManagedQuickJsContextSpy.mockRestore();
jest.resetModules();
});
beforeEach(() => {
lastManaged = null;
lastHandles = [];
createManagedQuickJsContextSpy.mockClear();
});
describe('executeQuickJsVm', () => {
it('clears tracked handles when execution completes', () => {
const result = executeQuickJsVm({
script: 'hello world',
context: { bru: makeBru() }
});
expect(result).toBe('hello world');
assertHandlesWereCleared();
});
it('clears tracked handles when script evaluation fails', () => {
const result = executeQuickJsVm({
script: 'throw new Error("sync failure")',
context: { bru: makeBru() },
scriptType: 'expression'
});
expect(result).toMatchObject({ message: 'sync failure' });
assertHandlesWereCleared();
});
it('does not create a managed context for literal early returns', () => {
expect(executeQuickJsVm({ script: '42', context: {} })).toBe(42);
expect(executeQuickJsVm({ script: 'true', context: {} })).toBe(true);
expect(createManagedQuickJsContextSpy).not.toHaveBeenCalled();
});
it('clears tracked handles on every invocation', () => {
for (let i = 0; i < 25; i++) {
executeQuickJsVm({
script: `\`value-${i}\``,
context: { bru: makeBru() },
scriptType: 'expression'
});
assertHandlesWereCleared();
}
expect(createManagedQuickJsContextSpy).toHaveBeenCalledTimes(25);
});
});
describe('executeQuickJsVmAsync', () => {
it('clears tracked handles when async execution completes', async () => {
await executeQuickJsVmAsync({
script: 'console.log(bru.getCollectionName());',
context: { bru: makeBru(), console: jest.fn() },
collectionPath: '/tmp/collection'
});
assertHandlesWereCleared();
});
it('clears tracked handles when async script execution fails', async () => {
await expect(
executeQuickJsVmAsync({
script: 'throw new Error("async failure");',
context: { bru: makeBru() },
collectionPath: '/tmp/collection'
})
).rejects.toMatchObject({
message: 'async failure',
__isQuickJS: true
});
assertHandlesWereCleared();
});
it('does not create a managed context for empty scripts', async () => {
await expect(executeQuickJsVmAsync({ script: '', context: {} })).resolves.toBe('');
expect(createManagedQuickJsContextSpy).not.toHaveBeenCalled();
});
it('clears tracked handles on every async invocation', async () => {
const consoleFn = jest.fn();
for (let i = 0; i < 10; i++) {
await executeQuickJsVmAsync({
script: `console.log('run-${i}');`,
context: { bru: makeBru(), console: consoleFn },
collectionPath: '/tmp/collection'
});
assertHandlesWereCleared();
}
expect(createManagedQuickJsContextSpy).toHaveBeenCalledTimes(10);
});
});
});