@usebruno/js 0.50.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,37 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
5
  const { runScriptInNodeVm } = require('./index');
6
+ const { __resetNpmModuleStateForTests } = require('./cjs-loader');
7
+
8
+ // Windows denies symlink creation without developer mode / admin. Probe once at
9
+ // module load so the dependent tests can be marked skipped in the reporter
10
+ // instead of silently no-oping mid-test.
11
+ const symlinksSupported = (() => {
12
+ const target = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-symlink-probe-'));
13
+ const link = target + '-link';
14
+ try {
15
+ fs.symlinkSync(target, link, 'dir');
16
+ fs.unlinkSync(link);
17
+ return true;
18
+ } catch (e) {
19
+ if (e.code === 'EPERM' || e.code === 'ENOTSUP') return false;
20
+ throw e;
21
+ } finally {
22
+ fs.rmSync(target, { recursive: true, force: true });
23
+ }
24
+ })();
25
+ const itIfSymlinks = symlinksSupported ? it : it.skip;
26
+
27
+ const makePkg = (parentDir, pkgName, files) => {
28
+ const pkgDir = path.join(parentDir, pkgName);
29
+ fs.mkdirSync(pkgDir, { recursive: true });
30
+ for (const [relPath, content] of Object.entries(files)) {
31
+ const filePath = path.join(pkgDir, relPath);
32
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
33
+ fs.writeFileSync(filePath, content);
34
+ }
35
+ return pkgDir;
36
+ };
6
37
 
7
38
  describe('node-vm sandbox', () => {
8
39
  let testDir;
@@ -240,6 +271,256 @@ describe('node-vm sandbox', () => {
240
271
  // Nested module should successfully access the additional root
241
272
  expect(context.bru.setVar).toHaveBeenCalledWith('result', true);
242
273
  });
274
+
275
+ it('should not cross-resolve npm package from a sibling additionalContextRoot when required by a collection script', async () => {
276
+ // Package lives only in additionalRoot/node_modules — the collection has
277
+ // no dependency declared for it.
278
+ const additionalRoot = path.join(testDir, 'shared');
279
+ makePkg(path.join(additionalRoot, 'node_modules'), 'shared-package', {
280
+ 'index.js': 'module.exports = { fromShared: true };'
281
+ });
282
+
283
+ // A COLLECTION script directly requiring `shared-package` must fail:
284
+ // native Node walk-up from the collection never reaches a sibling
285
+ // additional root's node_modules, and cross-root discovery for bare-name
286
+ // resolution is intentionally not implemented. The supported patterns
287
+ // are (a) require the package from a shared script that itself lives
288
+ // inside additionalRoot (see the next test), or (b) declare the dep in
289
+ // the collection's own package.json.
290
+ const script = `require('shared-package');`;
291
+
292
+ const context = {
293
+ bru: { setVar: jest.fn() },
294
+ console: console
295
+ };
296
+
297
+ const scriptingConfig = {
298
+ additionalContextRoots: [additionalRoot]
299
+ };
300
+
301
+ await expect(
302
+ runScriptInNodeVm({ script, context, collectionPath, scriptingConfig })
303
+ ).rejects.toThrow(/Could not resolve module "shared-package"/);
304
+ });
305
+
306
+ it('should resolve npm module required by a shared script in additionalContextRoots', async () => {
307
+ const additionalRoot = path.join(testDir, 'shared');
308
+ makePkg(path.join(additionalRoot, 'node_modules'), 'shared-util', {
309
+ 'index.js': 'module.exports = { parse: function(s) { return JSON.parse(s); } };'
310
+ });
311
+ fs.writeFileSync(
312
+ path.join(additionalRoot, 'parser.js'),
313
+ 'const sharedUtil = require("shared-util"); module.exports = { parse: sharedUtil.parse };'
314
+ );
315
+
316
+ // Collection script requires the shared local script, which internally
317
+ // requires an npm package from the shared root's node_modules
318
+ const script = `
319
+ const parser = require('../shared/parser');
320
+ const result = parser.parse('{"ok":true}');
321
+ bru.setVar('result', result.ok);
322
+ `;
323
+
324
+ const context = {
325
+ bru: { setVar: jest.fn() },
326
+ console: console
327
+ };
328
+
329
+ const scriptingConfig = {
330
+ additionalContextRoots: [additionalRoot]
331
+ };
332
+
333
+ await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig });
334
+
335
+ expect(context.bru.setVar).toHaveBeenCalledWith('result', true);
336
+ });
337
+
338
+ it('should walk up from a nested shared script to find its npm dependency', async () => {
339
+ // Structure:
340
+ // shared/
341
+ // node_modules/deep-dep/index.js ← package hoisted at shared root
342
+ // deep/nested/parser.js ← requires 'deep-dep'
343
+ const additionalRoot = path.join(testDir, 'shared');
344
+ const nestedDir = path.join(additionalRoot, 'deep', 'nested');
345
+ fs.mkdirSync(nestedDir, { recursive: true });
346
+
347
+ makePkg(path.join(additionalRoot, 'node_modules'), 'deep-dep', {
348
+ 'index.js': 'module.exports = { walkedUp: true };'
349
+ });
350
+
351
+ fs.writeFileSync(
352
+ path.join(nestedDir, 'parser.js'),
353
+ 'const dep = require("deep-dep"); module.exports = { ok: dep.walkedUp };'
354
+ );
355
+
356
+ const script = `
357
+ const parser = require('../shared/deep/nested/parser');
358
+ bru.setVar('result', parser.ok);
359
+ `;
360
+
361
+ const context = {
362
+ bru: { setVar: jest.fn() },
363
+ console: console
364
+ };
365
+
366
+ const scriptingConfig = {
367
+ additionalContextRoots: [additionalRoot]
368
+ };
369
+
370
+ await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig });
371
+
372
+ expect(context.bru.setVar).toHaveBeenCalledWith('result', true);
373
+ });
374
+
375
+ itIfSymlinks('should resolve npm modules when additionalContextRoots points at a symlink', async () => {
376
+ // Physical location of the shared root
377
+ const realShared = path.join(testDir, 'real-shared');
378
+ makePkg(path.join(realShared, 'node_modules'), 'symlinked-lib', {
379
+ 'index.js': 'module.exports = { via: "symlink" };'
380
+ });
381
+
382
+ // Shared script inside the real location that requires the npm package.
383
+ // Loaded through the symlink below.
384
+ fs.writeFileSync(
385
+ path.join(realShared, 'helper.js'),
386
+ 'const pkg = require("symlinked-lib"); module.exports = { via: pkg.via };'
387
+ );
388
+
389
+ // User-facing symlink that Bruno is told to treat as the shared root.
390
+ const linkedShared = path.join(testDir, 'linked-shared');
391
+ fs.symlinkSync(realShared, linkedShared, 'dir');
392
+
393
+ const script = `
394
+ const helper = require('../linked-shared/helper');
395
+ bru.setVar('via', helper.via);
396
+ `;
397
+
398
+ const context = {
399
+ bru: { setVar: jest.fn() },
400
+ console: console
401
+ };
402
+
403
+ const scriptingConfig = {
404
+ additionalContextRoots: [linkedShared]
405
+ };
406
+
407
+ await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig });
408
+
409
+ expect(context.bru.setVar).toHaveBeenCalledWith('via', 'symlink');
410
+ });
411
+
412
+ itIfSymlinks('should allow subpath imports into an npm-linked package', async () => {
413
+ // Physical location of a multi-file package outside every declared root.
414
+ // Subpath file utils.js — require('subpath-pkg/utils') maps to utils.js
415
+ // (a file, not a directory-with-index.js).
416
+ const externalPkg = makePkg(testDir, 'external-subpath-pkg', {
417
+ 'package.json': JSON.stringify({ name: 'subpath-pkg', main: 'index.js' }),
418
+ 'index.js': 'module.exports = { root: true };',
419
+ 'utils.js': 'module.exports = { greet: () => "sub-hello" };'
420
+ });
421
+
422
+ const nodeModulesDir = path.join(collectionPath, 'node_modules');
423
+ fs.mkdirSync(nodeModulesDir, { recursive: true });
424
+ fs.symlinkSync(externalPkg, path.join(nodeModulesDir, 'subpath-pkg'), 'dir');
425
+
426
+ const script = `
427
+ const utils = require('subpath-pkg/utils');
428
+ bru.setVar('result', utils.greet());
429
+ `;
430
+
431
+ const context = {
432
+ bru: { setVar: jest.fn() },
433
+ console: console
434
+ };
435
+
436
+ await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
437
+
438
+ expect(context.bru.setVar).toHaveBeenCalledWith('result', 'sub-hello');
439
+ });
440
+
441
+ itIfSymlinks('should allow internal relative requires inside an npm-linked package', async () => {
442
+ // Physical location of a multi-file package outside every declared root.
443
+ const externalPkg = makePkg(testDir, 'external-pkg', {
444
+ 'package.json': JSON.stringify({ name: 'linked-pkg', main: 'index.js' }),
445
+ 'index.js': 'const util = require("./util"); module.exports = { greet: util.greet };',
446
+ 'util.js': 'module.exports = { greet: () => "hello" };'
447
+ });
448
+
449
+ // npm-link style: collection has node_modules/<pkg> as a symlink to the
450
+ // physical location that lives outside the collection.
451
+ const nodeModulesDir = path.join(collectionPath, 'node_modules');
452
+ fs.mkdirSync(nodeModulesDir, { recursive: true });
453
+ fs.symlinkSync(externalPkg, path.join(nodeModulesDir, 'linked-pkg'), 'dir');
454
+
455
+ const script = `
456
+ const pkg = require('linked-pkg');
457
+ bru.setVar('result', pkg.greet());
458
+ `;
459
+
460
+ const context = {
461
+ bru: { setVar: jest.fn() },
462
+ console: console
463
+ };
464
+
465
+ await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
466
+
467
+ expect(context.bru.setVar).toHaveBeenCalledWith('result', 'hello');
468
+ });
469
+
470
+ it('should allow a package in collection node_modules to require a sibling package', async () => {
471
+ // Two packages installed side-by-side in the collection's node_modules —
472
+ // pkg-a transitively requires pkg-b.
473
+ const nodeModulesDir = path.join(collectionPath, 'node_modules');
474
+ makePkg(nodeModulesDir, 'pkg-a', {
475
+ 'package.json': JSON.stringify({ name: 'pkg-a', main: 'index.js' }),
476
+ 'index.js': 'const b = require("pkg-b"); module.exports = { value: b.value + 1 };'
477
+ });
478
+ makePkg(nodeModulesDir, 'pkg-b', {
479
+ 'package.json': JSON.stringify({ name: 'pkg-b', main: 'index.js' }),
480
+ 'index.js': 'module.exports = { value: 41 };'
481
+ });
482
+
483
+ const script = `
484
+ const a = require('pkg-a');
485
+ bru.setVar('result', a.value);
486
+ `;
487
+
488
+ const context = {
489
+ bru: { setVar: jest.fn() },
490
+ console: console
491
+ };
492
+
493
+ await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
494
+
495
+ expect(context.bru.setVar).toHaveBeenCalledWith('result', 42);
496
+ });
497
+
498
+ itIfSymlinks('should allow a scoped npm-linked package', async () => {
499
+ // Physical location of a scoped multi-file package outside every declared root.
500
+ const externalPkg = makePkg(testDir, 'external-scoped-pkg', {
501
+ 'package.json': JSON.stringify({ name: '@bruno/scoped-pkg', main: 'index.js' }),
502
+ 'index.js': 'const util = require("./util"); module.exports = { greet: util.greet };',
503
+ 'util.js': 'module.exports = { greet: () => "scoped-hello" };'
504
+ });
505
+
506
+ const scopeDir = path.join(collectionPath, 'node_modules', '@bruno');
507
+ fs.mkdirSync(scopeDir, { recursive: true });
508
+ fs.symlinkSync(externalPkg, path.join(scopeDir, 'scoped-pkg'), 'dir');
509
+
510
+ const script = `
511
+ const pkg = require('@bruno/scoped-pkg');
512
+ bru.setVar('result', pkg.greet());
513
+ `;
514
+
515
+ const context = {
516
+ bru: { setVar: jest.fn() },
517
+ console: console
518
+ };
519
+
520
+ await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} });
521
+
522
+ expect(context.bru.setVar).toHaveBeenCalledWith('result', 'scoped-hello');
523
+ });
243
524
  });
244
525
 
245
526
  describe('createCustomRequire - npm modules', () => {
@@ -333,6 +614,621 @@ describe('node-vm sandbox', () => {
333
614
  });
334
615
  });
335
616
 
617
+ describe('createCustomRequire - npm modules are shared across script executions', () => {
618
+ const scriptingConfig = { cacheModules: true };
619
+
620
+ beforeEach(() => {
621
+ __resetNpmModuleStateForTests();
622
+ });
623
+
624
+ it('should evaluate an npm module once per process, not once per script context', async () => {
625
+ const marker = `_npmEvalCount_${Date.now()}`;
626
+ makePkg(path.join(collectionPath, 'node_modules'), 'counted-module', {
627
+ 'index.js': `
628
+ process.${marker} = (process.${marker} || 0) + 1;
629
+ module.exports = { token: Symbol('counted') };
630
+ `
631
+ });
632
+
633
+ const script = `
634
+ const mod = require('counted-module');
635
+ bru.setVar('token', mod.token);
636
+ `;
637
+ const contextA = { bru: { setVar: jest.fn() }, console };
638
+ const contextB = { bru: { setVar: jest.fn() }, console };
639
+
640
+ await runScriptInNodeVm({ script, context: contextA, collectionPath, scriptingConfig });
641
+ await runScriptInNodeVm({ script, context: contextB, collectionPath, scriptingConfig });
642
+
643
+ expect(process[marker]).toBe(1);
644
+ expect(contextA.bru.setVar.mock.calls[0][1]).toBe(contextB.bru.setVar.mock.calls[0][1]);
645
+ delete process[marker];
646
+ });
647
+
648
+ it('should keep Date/Array/Object instanceof true for values from cached npm modules', async () => {
649
+ makePkg(path.join(collectionPath, 'node_modules'), 'realm-values', {
650
+ 'index.js': `
651
+ module.exports = {
652
+ date: () => new Date(0),
653
+ array: () => [1],
654
+ object: () => ({ a: 1 }),
655
+ map: () => new Map([['k', 1]]),
656
+ set: () => new Set([1]),
657
+ regexp: () => /x/,
658
+ error: () => new Error('e')
659
+ };
660
+ `
661
+ });
662
+
663
+ const script = `
664
+ const v = require('realm-values');
665
+ bru.setVar('checks', [
666
+ v.date() instanceof Date,
667
+ v.array() instanceof Array,
668
+ v.object() instanceof Object,
669
+ v.map() instanceof Map,
670
+ v.set() instanceof Set,
671
+ v.regexp() instanceof RegExp,
672
+ v.error() instanceof Error
673
+ ].every(Boolean));
674
+ `;
675
+ const context = { bru: { setVar: jest.fn() }, console };
676
+
677
+ await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig });
678
+
679
+ expect(context.bru.setVar).toHaveBeenCalledWith('checks', true);
680
+ });
681
+
682
+ it('should not let Object.freeze(bru) in one script poison later scripts', async () => {
683
+ makePkg(path.join(collectionPath, 'node_modules'), 'bru-freezer', {
684
+ 'index.js': `
685
+ module.exports = {
686
+ freeze: () => { Object.freeze(bru); return true; },
687
+ read: (name) => bru.getVar(name)
688
+ };
689
+ `
690
+ });
691
+
692
+ const freezeScript = `
693
+ bru.setVar('froze', require('bru-freezer').freeze());
694
+ `;
695
+ const readScript = `
696
+ bru.setVar('seen', require('bru-freezer').read('who'));
697
+ `;
698
+ const contextA = { bru: { getVar: jest.fn(), setVar: jest.fn() }, console };
699
+ const contextB = {
700
+ bru: { getVar: jest.fn().mockReturnValue('B'), setVar: jest.fn() },
701
+ console
702
+ };
703
+
704
+ await runScriptInNodeVm({ script: freezeScript, context: contextA, collectionPath, scriptingConfig });
705
+ await runScriptInNodeVm({ script: readScript, context: contextB, collectionPath, scriptingConfig });
706
+
707
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('froze', true);
708
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('seen', 'B');
709
+ });
710
+
711
+ it('should let a cached npm module see the bru of the script currently running', async () => {
712
+ makePkg(path.join(collectionPath, 'node_modules'), 'bru-reader', {
713
+ 'index.js': `module.exports = { read: (name) => bru.getVar(name) };`
714
+ });
715
+
716
+ const script = `
717
+ const reader = require('bru-reader');
718
+ bru.setVar('seen', reader.read('who'));
719
+ `;
720
+ const contextA = { bru: { getVar: jest.fn().mockReturnValue('A'), setVar: jest.fn() }, console };
721
+ const contextB = { bru: { getVar: jest.fn().mockReturnValue('B'), setVar: jest.fn() }, console };
722
+
723
+ await runScriptInNodeVm({ script, context: contextA, collectionPath, scriptingConfig });
724
+ await runScriptInNodeVm({ script, context: contextB, collectionPath, scriptingConfig });
725
+
726
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('seen', 'A');
727
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('seen', 'B');
728
+ expect(contextA.bru.getVar).toHaveBeenCalledTimes(1);
729
+ expect(contextB.bru.getVar).toHaveBeenCalledTimes(1);
730
+ });
731
+
732
+ it('should switch req dynamically but preserve the module-load URL snapshot', async () => {
733
+ makePkg(path.join(collectionPath, 'node_modules'), 'request-url-reader', {
734
+ 'index.js': `
735
+ const urlAtLoad = req.getUrl();
736
+ module.exports = {
737
+ read: () => req.getUrl(),
738
+ readCaptured: () => urlAtLoad
739
+ };
740
+ `
741
+ });
742
+
743
+ const script = `
744
+ const reader = require('request-url-reader');
745
+ bru.setVar('dynamicUrl', reader.read());
746
+ bru.setVar('capturedUrl', reader.readCaptured());
747
+ `;
748
+ const makeContext = (url) => ({
749
+ bru: { setVar: jest.fn() },
750
+ req: { getUrl: jest.fn().mockReturnValue(url) },
751
+ console
752
+ });
753
+ const contextA = makeContext('https://example.com/a');
754
+ const contextB = makeContext('https://example.com/b');
755
+
756
+ await runScriptInNodeVm({ script, context: contextA, collectionPath, scriptingConfig });
757
+ await runScriptInNodeVm({ script, context: contextB, collectionPath, scriptingConfig });
758
+
759
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('dynamicUrl', 'https://example.com/a');
760
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('dynamicUrl', 'https://example.com/b');
761
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('capturedUrl', 'https://example.com/a');
762
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('capturedUrl', 'https://example.com/b');
763
+ });
764
+
765
+ it.each([
766
+ ['the first-started script finishes first', 5, 40],
767
+ ['the first-started script finishes last', 40, 5]
768
+ ])('should keep interleaved executions bound to their own bru when %s', async (_, delayA, delayB) => {
769
+ makePkg(path.join(collectionPath, 'node_modules'), 'bru-reader-async', {
770
+ 'index.js': `module.exports = { read: (name) => bru.getVar(name) };`
771
+ });
772
+
773
+ const scriptFor = (delayMs) => `
774
+ const reader = require('bru-reader-async');
775
+ await new Promise((resolve) => setTimeout(resolve, ${delayMs}));
776
+ bru.setVar('seen', reader.read('who'));
777
+ `;
778
+ const contextA = { bru: { getVar: jest.fn().mockReturnValue('A'), setVar: jest.fn() }, console };
779
+ const contextB = { bru: { getVar: jest.fn().mockReturnValue('B'), setVar: jest.fn() }, console };
780
+
781
+ await Promise.all([
782
+ runScriptInNodeVm({ script: scriptFor(delayA), context: contextA, collectionPath, scriptingConfig }),
783
+ runScriptInNodeVm({ script: scriptFor(delayB), context: contextB, collectionPath, scriptingConfig })
784
+ ]);
785
+
786
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('seen', 'A');
787
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('seen', 'B');
788
+ expect(contextA.bru.getVar).toHaveBeenCalledTimes(1);
789
+ expect(contextB.bru.getVar).toHaveBeenCalledTimes(1);
790
+ });
791
+
792
+ it('should let a module that captured bru at load time talk to the current script', async () => {
793
+ makePkg(path.join(collectionPath, 'node_modules'), 'bru-capturer', {
794
+ 'index.js': `
795
+ const captured = bru;
796
+ const { getVar } = bru;
797
+ module.exports = {
798
+ viaCaptured: (name) => captured.getVar(name),
799
+ viaDestructured: (name) => getVar(name),
800
+ hasSetVar: () => 'setVar' in captured,
801
+ setThroughCaptured: (name, value) => { captured.setVar(name, value); },
802
+ types: () => typeof captured + '/' + typeof console + '/' + typeof test
803
+ };
804
+ `
805
+ });
806
+
807
+ const script = `
808
+ const capturer = require('bru-capturer');
809
+ capturer.setThroughCaptured('seen', capturer.viaCaptured('who') + capturer.viaDestructured('who'));
810
+ bru.setVar('hasSetVar', capturer.hasSetVar());
811
+ bru.setVar('types', capturer.types());
812
+ `;
813
+ const makeContext = (who) => ({
814
+ bru: { getVar: jest.fn().mockReturnValue(who), setVar: jest.fn() },
815
+ test: () => {},
816
+ console
817
+ });
818
+ const contextA = makeContext('A');
819
+ const contextB = makeContext('B');
820
+
821
+ await runScriptInNodeVm({ script, context: contextA, collectionPath, scriptingConfig });
822
+ await runScriptInNodeVm({ script, context: contextB, collectionPath, scriptingConfig });
823
+
824
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('seen', 'AA');
825
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('seen', 'BB');
826
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('hasSetVar', true);
827
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('types', 'object/object/function');
828
+ expect(contextA.bru.getVar).toHaveBeenCalledTimes(2);
829
+ expect(contextB.bru.getVar).toHaveBeenCalledTimes(2);
830
+ });
831
+
832
+ it('should preserve mutable singleton state across script executions', async () => {
833
+ makePkg(path.join(collectionPath, 'node_modules'), 'stateful-module', {
834
+ 'index.js': `
835
+ let count = 0;
836
+ module.exports = { next: () => ++count };
837
+ `
838
+ });
839
+
840
+ const script = `bru.setVar('count', require('stateful-module').next());`;
841
+ const contextA = { bru: { setVar: jest.fn() }, console };
842
+ const contextB = { bru: { setVar: jest.fn() }, console };
843
+
844
+ await runScriptInNodeVm({ script, context: contextA, collectionPath, scriptingConfig });
845
+ await runScriptInNodeVm({ script, context: contextB, collectionPath, scriptingConfig });
846
+
847
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('count', 1);
848
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('count', 2);
849
+ });
850
+
851
+ it('should preserve Bruno global identity and cross-realm behavior inside npm modules', async () => {
852
+ makePkg(path.join(collectionPath, 'node_modules'), 'identity-reader', {
853
+ 'index.js': `
854
+ module.exports = {
855
+ sameBru: () => bru === globalThis.bru,
856
+ readArray: (value) => Array.isArray(value)
857
+ };
858
+ `
859
+ });
860
+
861
+ const array = [];
862
+ const context = { bru: { setVar: jest.fn() }, array, console };
863
+ const script = `
864
+ const reader = require('identity-reader');
865
+ bru.setVar('sameBru', reader.sameBru());
866
+ bru.setVar('arrayIsArray', reader.readArray(array));
867
+ `;
868
+
869
+ await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig });
870
+
871
+ expect(context.bru.setVar).toHaveBeenCalledWith('sameBru', true);
872
+ expect(context.bru.setVar).toHaveBeenCalledWith('arrayIsArray', true);
873
+ });
874
+
875
+ it('should bind callbacks created by cached npm modules to their calling script', async () => {
876
+ makePkg(path.join(collectionPath, 'node_modules'), 'callback-runner', {
877
+ 'index.js': `
878
+ module.exports = {
879
+ runLater: (callback) => new Promise((resolve) => {
880
+ setTimeout(() => { callback(); resolve(); }, 5);
881
+ })
882
+ };
883
+ `
884
+ });
885
+
886
+ const scriptFor = (value) => `
887
+ await require('callback-runner').runLater(() => bru.setVar('value', '${value}'));
888
+ `;
889
+ const contextA = { bru: { setVar: jest.fn() }, console };
890
+ const contextB = { bru: { setVar: jest.fn() }, console };
891
+
892
+ await Promise.all([
893
+ runScriptInNodeVm({ script: scriptFor('A'), context: contextA, collectionPath, scriptingConfig }),
894
+ runScriptInNodeVm({ script: scriptFor('B'), context: contextB, collectionPath, scriptingConfig })
895
+ ]);
896
+
897
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('value', 'A');
898
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('value', 'B');
899
+ });
900
+
901
+ it('should keep bru available when req.onFail runs after the script ends', async () => {
902
+ let onFailHandler;
903
+ const req = {
904
+ onFail(callback) {
905
+ onFailHandler = callback;
906
+ }
907
+ };
908
+ const context = {
909
+ bru: { setVar: jest.fn() },
910
+ req,
911
+ console
912
+ };
913
+
914
+ await runScriptInNodeVm({
915
+ script: `
916
+ req.onFail(() => {
917
+ bru.setVar('token', 'after');
918
+ });
919
+ `,
920
+ context,
921
+ collectionPath,
922
+ scriptingConfig
923
+ });
924
+
925
+ expect(typeof onFailHandler).toBe('function');
926
+ onFailHandler(new Error('Connection failed'));
927
+ expect(context.bru.setVar).toHaveBeenCalledWith('token', 'after');
928
+ });
929
+
930
+ it('should restore req.onFail after a syntax-error run so a later run uses a fresh wrapper', async () => {
931
+ let onFailHandler;
932
+ const req = {
933
+ onFail(callback) {
934
+ onFailHandler = callback;
935
+ }
936
+ };
937
+ const originalOnFail = req.onFail;
938
+
939
+ await expect(
940
+ runScriptInNodeVm({
941
+ script: 'this is not valid js {{{',
942
+ context: { bru: {}, req, console },
943
+ collectionPath,
944
+ scriptingConfig
945
+ })
946
+ ).rejects.toThrow();
947
+
948
+ expect(req.onFail).toBe(originalOnFail);
949
+
950
+ const context = {
951
+ bru: { setVar: jest.fn() },
952
+ req,
953
+ console
954
+ };
955
+ await runScriptInNodeVm({
956
+ script: `
957
+ req.onFail(() => {
958
+ bru.setVar('token', 'after');
959
+ });
960
+ `,
961
+ context,
962
+ collectionPath,
963
+ scriptingConfig
964
+ });
965
+
966
+ expect(typeof onFailHandler).toBe('function');
967
+ onFailHandler(new Error('Connection failed'));
968
+ expect(context.bru.setVar).toHaveBeenCalledWith('token', 'after');
969
+ });
970
+
971
+ it('should not expose loader internals as script globals', async () => {
972
+ const context = {
973
+ bru: { setVar: jest.fn() },
974
+ console
975
+ };
976
+
977
+ await runScriptInNodeVm({
978
+ script: `
979
+ bru.setVar('vm', typeof __brunoVmContext);
980
+ bru.setVar('cache', typeof __brunoLocalModuleCache);
981
+ `,
982
+ context,
983
+ collectionPath,
984
+ scriptingConfig
985
+ });
986
+
987
+ expect(context.bru.setVar).toHaveBeenCalledWith('vm', 'undefined');
988
+ expect(context.bru.setVar).toHaveBeenCalledWith('cache', 'undefined');
989
+ });
990
+
991
+ it('should read a missing key as undefined and still call it once a later execution provides a function', async () => {
992
+ makePkg(path.join(collectionPath, 'node_modules'), 'helper-caller', {
993
+ 'index.js': `module.exports = { probe: () => typeof helper, run: () => helper('x') };`
994
+ });
995
+
996
+ const contextA = { bru: { setVar: jest.fn() }, helper: undefined, console };
997
+ await runScriptInNodeVm({
998
+ script: `bru.setVar('probe', require('helper-caller').probe());`,
999
+ context: contextA, collectionPath, scriptingConfig
1000
+ });
1001
+
1002
+ const helper = jest.fn().mockReturnValue('called');
1003
+ const contextB = { bru: { setVar: jest.fn() }, helper, console };
1004
+ await runScriptInNodeVm({
1005
+ script: `bru.setVar('ran', require('helper-caller').run());`,
1006
+ context: contextB, collectionPath, scriptingConfig
1007
+ });
1008
+
1009
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('probe', 'undefined');
1010
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('ran', 'called');
1011
+ expect(helper).toHaveBeenCalledWith('x');
1012
+ });
1013
+
1014
+ it('should preserve primitive custom globals for npm modules', async () => {
1015
+ makePkg(path.join(collectionPath, 'node_modules'), 'primitive-reader', {
1016
+ 'index.js': `
1017
+ module.exports = {
1018
+ read: () => [typeof scalar, scalar, typeof flag, flag].join(':')
1019
+ };
1020
+ `
1021
+ });
1022
+
1023
+ const context = {
1024
+ bru: { setVar: jest.fn() },
1025
+ scalar: 'value',
1026
+ flag: false,
1027
+ console
1028
+ };
1029
+
1030
+ await runScriptInNodeVm({
1031
+ script: `bru.setVar('result', require('primitive-reader').read());`,
1032
+ context,
1033
+ collectionPath,
1034
+ scriptingConfig
1035
+ });
1036
+
1037
+ expect(context.bru.setVar).toHaveBeenCalledWith('result', 'string:value:boolean:false');
1038
+ });
1039
+
1040
+ it('should re-evaluate a parent that snapshots a context-bound dependency at load time', async () => {
1041
+ makePkg(path.join(collectionPath, 'node_modules'), 'leaf-bru-snapshot', {
1042
+ 'index.js': `module.exports = { who: bru.getVar('who') };`
1043
+ });
1044
+ makePkg(path.join(collectionPath, 'node_modules'), 'parent-bru-snapshot', {
1045
+ 'index.js': `
1046
+ const leaf = require('leaf-bru-snapshot');
1047
+ module.exports = { who: leaf.who };
1048
+ `
1049
+ });
1050
+
1051
+ const script = `bru.setVar('seen', require('parent-bru-snapshot').who);`;
1052
+ const contextA = { bru: { getVar: jest.fn().mockReturnValue('A'), setVar: jest.fn() }, console };
1053
+ const contextB = { bru: { getVar: jest.fn().mockReturnValue('B'), setVar: jest.fn() }, console };
1054
+
1055
+ await runScriptInNodeVm({ script, context: contextA, collectionPath, scriptingConfig });
1056
+ await runScriptInNodeVm({ script, context: contextB, collectionPath, scriptingConfig });
1057
+
1058
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('seen', 'A');
1059
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('seen', 'B');
1060
+ expect(contextA.bru.getVar).toHaveBeenCalledTimes(1);
1061
+ expect(contextB.bru.getVar).toHaveBeenCalledTimes(1);
1062
+ expect(contextA.bru.setVar).toHaveBeenCalledTimes(1);
1063
+ expect(contextB.bru.setVar).toHaveBeenCalledTimes(1);
1064
+ });
1065
+
1066
+ it('should re-evaluate a three-level parent chain that snapshots bru at the leaf', async () => {
1067
+ makePkg(path.join(collectionPath, 'node_modules'), 'deep-leaf-bru', {
1068
+ 'index.js': `module.exports = { who: bru.getVar('who') };`
1069
+ });
1070
+ makePkg(path.join(collectionPath, 'node_modules'), 'deep-mid-bru', {
1071
+ 'index.js': `
1072
+ const leaf = require('deep-leaf-bru');
1073
+ module.exports = { who: leaf.who };
1074
+ `
1075
+ });
1076
+ makePkg(path.join(collectionPath, 'node_modules'), 'deep-root-bru', {
1077
+ 'index.js': `
1078
+ const mid = require('deep-mid-bru');
1079
+ module.exports = { who: mid.who };
1080
+ `
1081
+ });
1082
+
1083
+ const script = `bru.setVar('seen', require('deep-root-bru').who);`;
1084
+ const contextA = { bru: { getVar: jest.fn().mockReturnValue('A'), setVar: jest.fn() }, console };
1085
+ const contextB = { bru: { getVar: jest.fn().mockReturnValue('B'), setVar: jest.fn() }, console };
1086
+
1087
+ await runScriptInNodeVm({ script, context: contextA, collectionPath, scriptingConfig });
1088
+ await runScriptInNodeVm({ script, context: contextB, collectionPath, scriptingConfig });
1089
+
1090
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('seen', 'A');
1091
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('seen', 'B');
1092
+ expect(contextA.bru.getVar).toHaveBeenCalledTimes(1);
1093
+ expect(contextB.bru.getVar).toHaveBeenCalledTimes(1);
1094
+ expect(contextA.bru.setVar).toHaveBeenCalledTimes(1);
1095
+ expect(contextB.bru.setVar).toHaveBeenCalledTimes(1);
1096
+ });
1097
+
1098
+ it('should still share an inert transitive npm module tree across scripts', async () => {
1099
+ const marker = `_inertParentEval_${Date.now()}`;
1100
+ makePkg(path.join(collectionPath, 'node_modules'), 'inert-leaf', {
1101
+ 'index.js': `module.exports = { token: Symbol('inert') };`
1102
+ });
1103
+ makePkg(path.join(collectionPath, 'node_modules'), 'inert-parent', {
1104
+ 'index.js': `
1105
+ process.${marker} = (process.${marker} || 0) + 1;
1106
+ module.exports = { token: require('inert-leaf').token };
1107
+ `
1108
+ });
1109
+
1110
+ const script = `bru.setVar('token', require('inert-parent').token);`;
1111
+ const contextA = { bru: { setVar: jest.fn() }, console };
1112
+ const contextB = { bru: { setVar: jest.fn() }, console };
1113
+
1114
+ await runScriptInNodeVm({ script, context: contextA, collectionPath, scriptingConfig });
1115
+ await runScriptInNodeVm({ script, context: contextB, collectionPath, scriptingConfig });
1116
+
1117
+ expect(process[marker]).toBe(1);
1118
+ expect(contextA.bru.setVar.mock.calls[0][1]).toBe(contextB.bru.setVar.mock.calls[0][1]);
1119
+ delete process[marker];
1120
+ });
1121
+
1122
+ it('should re-evaluate a context-bound leaf required lazily from a shared parent', async () => {
1123
+ makePkg(path.join(collectionPath, 'node_modules'), 'lazy-leaf-bru', {
1124
+ 'index.js': `
1125
+ let calls = 0;
1126
+ calls += 1;
1127
+ const who = bru.getVar('who');
1128
+ module.exports = {
1129
+ calls: () => calls,
1130
+ who: () => who,
1131
+ liveWho: () => bru.getVar('who')
1132
+ };
1133
+ `
1134
+ });
1135
+ makePkg(path.join(collectionPath, 'node_modules'), 'lazy-parent-inert', {
1136
+ 'index.js': `
1137
+ module.exports = {
1138
+ loadLeaf: () => require('lazy-leaf-bru')
1139
+ };
1140
+ `
1141
+ });
1142
+
1143
+ const script = `
1144
+ const leaf = require('lazy-parent-inert').loadLeaf();
1145
+ bru.setVar('calls', leaf.calls());
1146
+ bru.setVar('who', leaf.who());
1147
+ bru.setVar('liveWho', leaf.liveWho());
1148
+ `;
1149
+ const makeContext = (who) => ({
1150
+ bru: { getVar: jest.fn().mockReturnValue(who), setVar: jest.fn() },
1151
+ console
1152
+ });
1153
+ const contextA = makeContext('A');
1154
+ const contextB = makeContext('B');
1155
+ const contextC = makeContext('C');
1156
+
1157
+ await runScriptInNodeVm({ script, context: contextA, collectionPath, scriptingConfig });
1158
+ await runScriptInNodeVm({ script, context: contextB, collectionPath, scriptingConfig });
1159
+ await runScriptInNodeVm({ script, context: contextC, collectionPath, scriptingConfig });
1160
+
1161
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('calls', 1);
1162
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('calls', 1);
1163
+ expect(contextC.bru.setVar).toHaveBeenCalledWith('calls', 1);
1164
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('who', 'A');
1165
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('who', 'B');
1166
+ expect(contextC.bru.setVar).toHaveBeenCalledWith('who', 'C');
1167
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('liveWho', 'A');
1168
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('liveWho', 'B');
1169
+ expect(contextC.bru.setVar).toHaveBeenCalledWith('liveWho', 'C');
1170
+ });
1171
+
1172
+ it('should re-evaluate both sides of a context-bound circular npm dependency', async () => {
1173
+ makePkg(path.join(collectionPath, 'node_modules'), 'cycle-a', {
1174
+ 'index.js': `
1175
+ let calls = 0;
1176
+ calls += 1;
1177
+ exports.calls = () => calls;
1178
+ exports.who = bru.getVar('who');
1179
+ const b = require('cycle-b');
1180
+ exports.fromB = () => b.aWho();
1181
+ `
1182
+ });
1183
+ makePkg(path.join(collectionPath, 'node_modules'), 'cycle-b', {
1184
+ 'index.js': `
1185
+ // Inert at load except for the cycle edge — must not stay shared with
1186
+ // a stale capture of cycle-a's exports across script runs.
1187
+ const a = require('cycle-a');
1188
+ exports.aWho = () => a.who;
1189
+ `
1190
+ });
1191
+
1192
+ const script = `
1193
+ const a = require('cycle-a');
1194
+ bru.setVar('calls', a.calls());
1195
+ bru.setVar('who', a.who);
1196
+ bru.setVar('fromB', a.fromB());
1197
+ `;
1198
+ const makeContext = (who) => ({
1199
+ bru: { getVar: jest.fn().mockReturnValue(who), setVar: jest.fn() },
1200
+ console
1201
+ });
1202
+ const contextA = makeContext('A');
1203
+ const contextB = makeContext('B');
1204
+
1205
+ await runScriptInNodeVm({ script, context: contextA, collectionPath, scriptingConfig });
1206
+ await runScriptInNodeVm({ script, context: contextB, collectionPath, scriptingConfig });
1207
+
1208
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('calls', 1);
1209
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('calls', 1);
1210
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('who', 'A');
1211
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('who', 'B');
1212
+ expect(contextA.bru.setVar).toHaveBeenCalledWith('fromB', 'A');
1213
+ expect(contextB.bru.setVar).toHaveBeenCalledWith('fromB', 'B');
1214
+ });
1215
+
1216
+ it('should keep collection-local modules per script context', async () => {
1217
+ const marker = `_localEvalCount_${Date.now()}`;
1218
+ fs.writeFileSync(
1219
+ path.join(collectionPath, 'local-counted.js'),
1220
+ `process.${marker} = (process.${marker} || 0) + 1; module.exports = {};`
1221
+ );
1222
+ const script = `require('./local-counted');`;
1223
+
1224
+ await runScriptInNodeVm({ script, context: { bru: {}, console }, collectionPath, scriptingConfig });
1225
+ await runScriptInNodeVm({ script, context: { bru: {}, console }, collectionPath, scriptingConfig });
1226
+
1227
+ expect(process[marker]).toBe(2);
1228
+ delete process[marker];
1229
+ });
1230
+ });
1231
+
336
1232
  describe('createCustomRequire - Node.js builtin modules', () => {
337
1233
  it('should load builtin modules (crypto)', async () => {
338
1234
  const script = `