@apiquest/fracture 1.0.4 → 1.0.6

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.
Files changed (87) hide show
  1. package/README.md +90 -2
  2. package/dist/CollectionRunner.d.ts +3 -0
  3. package/dist/CollectionRunner.d.ts.map +1 -1
  4. package/dist/CollectionRunner.js +249 -154
  5. package/dist/CollectionRunner.js.map +1 -1
  6. package/dist/CollectionValidator.d.ts.map +1 -1
  7. package/dist/CollectionValidator.js +11 -0
  8. package/dist/CollectionValidator.js.map +1 -1
  9. package/dist/ConsoleReporter.d.ts.map +1 -1
  10. package/dist/ConsoleReporter.js +9 -6
  11. package/dist/ConsoleReporter.js.map +1 -1
  12. package/dist/DagScheduler.d.ts.map +1 -1
  13. package/dist/DagScheduler.js +11 -0
  14. package/dist/DagScheduler.js.map +1 -1
  15. package/dist/LibraryLoader.d.ts +49 -0
  16. package/dist/LibraryLoader.d.ts.map +1 -0
  17. package/dist/LibraryLoader.js +198 -0
  18. package/dist/LibraryLoader.js.map +1 -0
  19. package/dist/PluginLoader.d.ts.map +1 -1
  20. package/dist/PluginLoader.js +9 -6
  21. package/dist/PluginLoader.js.map +1 -1
  22. package/dist/PluginManager.d.ts.map +1 -1
  23. package/dist/PluginManager.js +11 -7
  24. package/dist/PluginManager.js.map +1 -1
  25. package/dist/PluginResolver.d.ts +1 -1
  26. package/dist/PluginResolver.d.ts.map +1 -1
  27. package/dist/PluginResolver.js +1 -1
  28. package/dist/PluginResolver.js.map +1 -1
  29. package/dist/QuestAPI.d.ts.map +1 -1
  30. package/dist/QuestAPI.js +114 -217
  31. package/dist/QuestAPI.js.map +1 -1
  32. package/dist/ScriptEngine.d.ts +2 -1
  33. package/dist/ScriptEngine.d.ts.map +1 -1
  34. package/dist/ScriptEngine.js +15 -8
  35. package/dist/ScriptEngine.js.map +1 -1
  36. package/dist/TaskGraph.d.ts +2 -1
  37. package/dist/TaskGraph.d.ts.map +1 -1
  38. package/dist/TaskGraph.js +28 -26
  39. package/dist/TaskGraph.js.map +1 -1
  40. package/dist/VariableResolver.d.ts +1 -1
  41. package/dist/VariableResolver.js +10 -10
  42. package/dist/VariableResolver.js.map +1 -1
  43. package/dist/cli/index.js +35 -3
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/cli/plugin-commands.d.ts.map +1 -1
  46. package/dist/cli/plugin-commands.js +47 -81
  47. package/dist/cli/plugin-commands.js.map +1 -1
  48. package/dist/cli/plugin-installer.d.ts +48 -0
  49. package/dist/cli/plugin-installer.d.ts.map +1 -0
  50. package/dist/cli/plugin-installer.js +136 -0
  51. package/dist/cli/plugin-installer.js.map +1 -0
  52. package/dist/cli/plugin-registry.d.ts +17 -0
  53. package/dist/cli/plugin-registry.d.ts.map +1 -0
  54. package/dist/cli/plugin-registry.js +77 -0
  55. package/dist/cli/plugin-registry.js.map +1 -0
  56. package/package.json +1 -1
  57. package/tsconfig.json +1 -0
  58. package/tsconfig.test.json +3 -0
  59. package/dist/QuestAPI.types.d.ts +0 -35
  60. package/dist/QuestAPI.types.d.ts.map +0 -1
  61. package/dist/QuestAPI.types.js +0 -3
  62. package/dist/QuestAPI.types.js.map +0 -1
  63. package/src/CollectionAnalyzer.ts +0 -102
  64. package/src/CollectionRunner.ts +0 -1423
  65. package/src/CollectionRunner.types.ts +0 -9
  66. package/src/CollectionValidator.ts +0 -289
  67. package/src/ConsoleReporter.ts +0 -143
  68. package/src/CookieJar.ts +0 -258
  69. package/src/DagScheduler.ts +0 -439
  70. package/src/Logger.ts +0 -85
  71. package/src/PluginLoader.ts +0 -126
  72. package/src/PluginManager.ts +0 -208
  73. package/src/PluginResolver.ts +0 -154
  74. package/src/QuestAPI.ts +0 -764
  75. package/src/QuestAPI.types.ts +0 -33
  76. package/src/QuestTestAPI.ts +0 -164
  77. package/src/RequestFilter.ts +0 -224
  78. package/src/ScriptEngine.ts +0 -219
  79. package/src/ScriptValidator.ts +0 -428
  80. package/src/TaskGraph.ts +0 -598
  81. package/src/TestCounter.ts +0 -109
  82. package/src/VariableResolver.ts +0 -114
  83. package/src/cli/index.ts +0 -480
  84. package/src/cli/plugin-commands.ts +0 -342
  85. package/src/cli/plugin-discovery.ts +0 -44
  86. package/src/index.ts +0 -24
  87. package/src/utils.ts +0 -52
@@ -15,6 +15,7 @@ import { CookieJar } from './CookieJar.js';
15
15
  import { isNullOrEmpty, isNullOrWhitespace } from './utils.js';
16
16
  import { TaskGraph } from './TaskGraph.js';
17
17
  import { DagScheduler } from './DagScheduler.js';
18
+ import { LibraryLoader } from './LibraryLoader.js';
18
19
  export class CollectionRunner extends EventEmitter {
19
20
  variableResolver;
20
21
  pluginManager;
@@ -33,6 +34,9 @@ export class CollectionRunner extends EventEmitter {
33
34
  bailEnabled = false;
34
35
  abortReason;
35
36
  shouldDelayNextRequest = false;
37
+ libraryLoader;
38
+ loadedLibraries = new Map();
39
+ folderScopeById = new Map();
36
40
  constructor(options) {
37
41
  super();
38
42
  const logLevel = options?.logLevel ?? LogLevel.INFO;
@@ -45,6 +49,7 @@ export class CollectionRunner extends EventEmitter {
45
49
  this.collectionValidator = new CollectionValidator(this.pluginManager, this.logger);
46
50
  this.testCounter = new TestCounter(this.pluginManager, this.logger);
47
51
  this.scriptEngine = new ScriptEngine(this.logger);
52
+ this.libraryLoader = new LibraryLoader(this.logger);
48
53
  // Phase 1: Resolve plugins if directories provided (fast - just scans, no loading)
49
54
  if (options?.pluginsDir !== undefined) {
50
55
  const dirs = Array.isArray(options.pluginsDir)
@@ -178,7 +183,7 @@ export class CollectionRunner extends EventEmitter {
178
183
  // Phase 2: Load ONLY required plugins
179
184
  await this.pluginLoader.loadRequiredPlugins(this.resolvedPlugins, requirements);
180
185
  this.logger.debug('Required plugins loaded');
181
- this.logger.info(`Starting collection: ${collection.info.name}`);
186
+ this.logger.debug(`Starting collection: ${collection.info.name}`);
182
187
  this.logger.debug(`Collection ID: ${collection.info.id}, Protocol: ${collection.protocol}`);
183
188
  // Validate and cache protocol plugin
184
189
  const protocolPlugin = this.pluginManager.getPlugin(collection.protocol);
@@ -187,7 +192,21 @@ export class CollectionRunner extends EventEmitter {
187
192
  `Available protocols: ${this.pluginManager.getAllPlugins().flatMap(p => p.protocols).join(', ')}`);
188
193
  }
189
194
  // Merge runtime options (needed for validation)
190
- const runtimeOptions = this.mergeOptions(collection.options, options);
195
+ const runtimeOptions = this.mergeOptions(collection.options, options, { includeMeta: true, includeProxy: true });
196
+ // Validate external libraries flag
197
+ if (runtimeOptions.libraries !== undefined && runtimeOptions.libraries.length > 0) {
198
+ if (options.allowExternalLibraries !== true) {
199
+ throw new Error(`Collection defines external libraries but --allow-external-libraries flag is not enabled. ` +
200
+ `External libraries (npm/file/cdn) pose security risks and must be explicitly allowed. ` +
201
+ `Use --allow-external-libraries to enable this feature.`);
202
+ }
203
+ this.logger.debug(`External libraries enabled: ${runtimeOptions.libraries.length} libraries to load`);
204
+ // Load external libraries
205
+ this.loadedLibraries = await this.libraryLoader.loadLibraries(runtimeOptions.libraries);
206
+ // Recreate script engine with loaded libraries
207
+ this.scriptEngine = new ScriptEngine(this.logger, this.loadedLibraries);
208
+ this.logger.debug(`Loaded ${this.loadedLibraries.size} external libraries`);
209
+ }
191
210
  if (options.signal !== undefined) {
192
211
  this.ownsController = false;
193
212
  this.abortController = {
@@ -199,7 +218,7 @@ export class CollectionRunner extends EventEmitter {
199
218
  else {
200
219
  this.ownsController = true;
201
220
  this.abortController = new AbortController();
202
- this.logger.info('Created internal abort controller');
221
+ this.logger.debug('Created internal abort controller');
203
222
  }
204
223
  this.abortReason = undefined;
205
224
  this.bailEnabled = runtimeOptions.execution?.bail === true;
@@ -308,12 +327,13 @@ export class CollectionRunner extends EventEmitter {
308
327
  });
309
328
  }
310
329
  }
311
- // Initialize scope stack with collection scope
312
- const scopeStack = [{
313
- level: 'collection',
314
- id: collection.info.id,
315
- vars: {}
316
- }];
330
+ // Initialize collection scope (root of scope chain)
331
+ const collectionScope = {
332
+ level: 'collection',
333
+ id: collection.info.id,
334
+ vars: {}
335
+ };
336
+ this.folderScopeById = new Map();
317
337
  // Execute collection pre-request script once before all iterations
318
338
  if (!isNullOrWhitespace(collection.collectionPreScript)) {
319
339
  // Emit event before collection pre-script execution
@@ -327,14 +347,14 @@ export class CollectionRunner extends EventEmitter {
327
347
  protocol: collection.protocol,
328
348
  collectionVariables: collection.variables ?? {},
329
349
  globalVariables: options.globalVariables ?? {},
330
- scopeStack: [...scopeStack], // Clone scope stack
350
+ scope: collectionScope,
331
351
  environment: options.environment,
332
352
  iterationCurrent: 1,
333
353
  iterationCount,
334
354
  iterationData,
335
355
  iterationSource,
336
356
  executionHistory: [],
337
- options: this.mergeOptions(collection.options, options),
357
+ options: this.mergeOptions(collection.options, options, { includeMeta: true, includeProxy: true }),
338
358
  cookieJar,
339
359
  eventEmitter: this,
340
360
  protocolPlugin,
@@ -357,7 +377,7 @@ export class CollectionRunner extends EventEmitter {
357
377
  options.globalVariables = tempContext.globalVariables;
358
378
  options.environment = tempContext.environment;
359
379
  // Update collection scope with any changes from script
360
- Object.assign(scopeStack[0].vars, tempContext.scopeStack[0].vars);
380
+ Object.assign(collectionScope.vars, tempContext.scope.vars);
361
381
  }
362
382
  for (let i = 0; i < iterationCount; i++) {
363
383
  if (this.isAborted()) {
@@ -370,14 +390,18 @@ export class CollectionRunner extends EventEmitter {
370
390
  protocol: collection.protocol,
371
391
  collectionVariables: collection.variables ?? {},
372
392
  globalVariables: options.globalVariables ?? {},
373
- scopeStack: [...scopeStack], // Clone scope stack for each iteration
393
+ scope: {
394
+ level: 'collection',
395
+ id: collectionScope.id,
396
+ vars: { ...collectionScope.vars }
397
+ },
374
398
  environment: options.environment,
375
399
  iterationCurrent: i + 1,
376
400
  iterationCount,
377
401
  iterationData,
378
402
  iterationSource,
379
403
  executionHistory: [],
380
- options: this.mergeOptions(collection.options, options),
404
+ options: this.mergeOptions(collection.options, options, { includeMeta: true, includeProxy: true }),
381
405
  cookieJar,
382
406
  eventEmitter: this,
383
407
  protocolPlugin,
@@ -400,49 +424,54 @@ export class CollectionRunner extends EventEmitter {
400
424
  duration: iterationDuration
401
425
  });
402
426
  // Update collection scope with changes from iteration
403
- Object.assign(scopeStack[0].vars, context.scopeStack[0].vars);
427
+ Object.assign(collectionScope.vars, context.scope.vars);
404
428
  // Update global variables and environment for next iteration
405
429
  options.globalVariables = context.globalVariables;
406
430
  options.environment = context.environment;
407
431
  }
408
432
  // Execute collection post-request script once after all iterations
409
433
  if (!isNullOrWhitespace(collection.collectionPostScript)) {
410
- // Emit event before collection post-script execution
411
- const beforePostEnvelope = this.createEventEnvelope(collection.info, 'collection:/', undefined);
412
- this.emit('beforeCollectionPostScript', {
413
- ...beforePostEnvelope,
414
- path: 'collection:/'
415
- });
416
- const tempContext = {
417
- collectionInfo: collection.info,
418
- protocol: collection.protocol,
419
- collectionVariables: collection.variables ?? {},
420
- globalVariables: options.globalVariables ?? {},
421
- scopeStack: [...scopeStack], // Clone scope stack
422
- environment: options.environment,
423
- iterationCurrent: iterationCount,
424
- iterationCount,
425
- iterationData,
426
- iterationSource,
427
- executionHistory: [],
428
- options: this.mergeOptions(collection.options, options),
429
- cookieJar,
430
- eventEmitter: this,
431
- protocolPlugin,
432
- abortSignal: this.abortController?.signal
433
- };
434
- const postScriptResult = await this.queueScript(() => this.scriptEngine.execute(collection.collectionPostScript, tempContext, ScriptType.CollectionPost, () => { } // noop - collection post-scripts cannot have tests
435
- ));
436
- this.emitConsoleOutput(postScriptResult.consoleOutput);
437
- // Emit event for collection post-script completion
438
- const afterPostEnvelope = this.createEventEnvelope(collection.info, 'collection:/', tempContext);
439
- this.emit('afterCollectionPostScript', {
440
- ...afterPostEnvelope,
441
- path: 'collection:/',
442
- result: postScriptResult
443
- });
444
- if (postScriptResult.success === false) {
445
- throw new Error(`Collection post-script error: ${postScriptResult.error}`);
434
+ if (this.isAborted()) {
435
+ this.logger.warn('Skipping collection post-script due to abort');
436
+ }
437
+ else {
438
+ // Emit event before collection post-script execution
439
+ const beforePostEnvelope = this.createEventEnvelope(collection.info, 'collection:/', undefined);
440
+ this.emit('beforeCollectionPostScript', {
441
+ ...beforePostEnvelope,
442
+ path: 'collection:/'
443
+ });
444
+ const tempContext = {
445
+ collectionInfo: collection.info,
446
+ protocol: collection.protocol,
447
+ collectionVariables: collection.variables ?? {},
448
+ globalVariables: options.globalVariables ?? {},
449
+ scope: collectionScope,
450
+ environment: options.environment,
451
+ iterationCurrent: iterationCount,
452
+ iterationCount,
453
+ iterationData,
454
+ iterationSource,
455
+ executionHistory: [],
456
+ options: this.mergeOptions(collection.options, options, { includeMeta: true, includeProxy: true }),
457
+ cookieJar,
458
+ eventEmitter: this,
459
+ protocolPlugin,
460
+ abortSignal: this.abortController?.signal
461
+ };
462
+ const postScriptResult = await this.queueScript(() => this.scriptEngine.execute(collection.collectionPostScript, tempContext, ScriptType.CollectionPost, () => { } // noop - collection post-scripts cannot have tests
463
+ ));
464
+ this.emitConsoleOutput(postScriptResult.consoleOutput);
465
+ // Emit event for collection post-script completion
466
+ const afterPostEnvelope = this.createEventEnvelope(collection.info, 'collection:/', tempContext);
467
+ this.emit('afterCollectionPostScript', {
468
+ ...afterPostEnvelope,
469
+ path: 'collection:/',
470
+ result: postScriptResult
471
+ });
472
+ if (postScriptResult.success === false) {
473
+ throw new Error(`Collection post-script error: ${postScriptResult.error}`);
474
+ }
446
475
  }
447
476
  }
448
477
  const endTime = new Date();
@@ -479,63 +508,75 @@ export class CollectionRunner extends EventEmitter {
479
508
  request.auth.data = this.variableResolver.resolveAll(request.auth.data, context);
480
509
  }
481
510
  }
482
- mergeOptions(collectionOptions, runOptions) {
483
- // Since RunOptions extends RuntimeOptions, merge is straightforward
484
- // RunOptions takes precedence over collectionOptions
511
+ mergeOptions(baseOptions, overrideOptions, options) {
512
+ const includeMeta = options?.includeMeta ?? true;
513
+ const includeProxy = options?.includeProxy ?? true;
514
+ // overrideOptions takes precedence over baseOptions
485
515
  const merged = {
486
- ...collectionOptions,
487
- ...runOptions,
516
+ ...baseOptions,
517
+ ...overrideOptions,
488
518
  // Deep merge nested objects
489
519
  execution: {
490
- ...(collectionOptions?.execution ?? {}),
491
- ...(runOptions?.execution ?? {})
492
- },
493
- // Ensure defaults
494
- strictMode: runOptions?.strictMode ?? collectionOptions?.strictMode ?? true,
495
- // Include filter options (type narrow from undefined)
496
- filter: (runOptions?.filter ?? collectionOptions?.filter) !== undefined
497
- ? String(runOptions?.filter ?? collectionOptions?.filter)
498
- : undefined,
499
- excludeDeps: ((runOptions?.excludeDeps ?? collectionOptions?.excludeDeps) !== undefined)
500
- ? Boolean(runOptions?.excludeDeps ?? collectionOptions?.excludeDeps)
501
- : undefined
520
+ ...(baseOptions?.execution ?? {}),
521
+ ...(overrideOptions?.execution ?? {})
522
+ }
502
523
  };
524
+ if (includeMeta) {
525
+ merged.strictMode = overrideOptions?.strictMode ?? baseOptions?.strictMode ?? true;
526
+ merged.filter = (overrideOptions?.filter ?? baseOptions?.filter) !== undefined
527
+ ? String(overrideOptions?.filter ?? baseOptions?.filter)
528
+ : undefined;
529
+ merged.excludeDeps = ((overrideOptions?.excludeDeps ?? baseOptions?.excludeDeps) !== undefined)
530
+ ? Boolean(overrideOptions?.excludeDeps ?? baseOptions?.excludeDeps)
531
+ : undefined;
532
+ }
503
533
  // Conditionally merge optional nested objects
504
- if (collectionOptions?.timeout !== undefined || runOptions?.timeout !== undefined) {
534
+ if (baseOptions?.timeout !== undefined || overrideOptions?.timeout !== undefined) {
505
535
  merged.timeout = {
506
- ...(collectionOptions?.timeout ?? {}),
507
- ...(runOptions?.timeout ?? {})
536
+ ...(baseOptions?.timeout ?? {}),
537
+ ...(overrideOptions?.timeout ?? {})
508
538
  };
509
539
  }
510
- if (collectionOptions?.ssl !== undefined || runOptions?.ssl !== undefined) {
540
+ if (baseOptions?.ssl !== undefined || overrideOptions?.ssl !== undefined) {
511
541
  merged.ssl = {
512
- ...(collectionOptions?.ssl ?? {}),
513
- ...(runOptions?.ssl ?? {})
542
+ ...(baseOptions?.ssl ?? {}),
543
+ ...(overrideOptions?.ssl ?? {})
514
544
  };
515
545
  }
516
- if (collectionOptions?.jar !== undefined || runOptions?.jar !== undefined) {
546
+ if (baseOptions?.jar !== undefined || overrideOptions?.jar !== undefined) {
517
547
  merged.jar = {
518
- persist: runOptions?.jar?.persist ?? collectionOptions?.jar?.persist ?? false
548
+ persist: overrideOptions?.jar?.persist ?? baseOptions?.jar?.persist ?? false
519
549
  };
520
550
  }
521
- if (runOptions?.proxy !== null && runOptions?.proxy !== undefined) {
522
- merged.proxy = runOptions.proxy;
551
+ if (includeProxy) {
552
+ if (overrideOptions?.proxy !== null && overrideOptions?.proxy !== undefined) {
553
+ merged.proxy = overrideOptions.proxy;
554
+ }
555
+ else if (baseOptions?.proxy !== null && baseOptions?.proxy !== undefined) {
556
+ merged.proxy = baseOptions.proxy;
557
+ }
523
558
  }
524
- else if (collectionOptions?.proxy !== null && collectionOptions?.proxy !== undefined) {
525
- merged.proxy = collectionOptions.proxy;
559
+ if (includeMeta) {
560
+ merged.strictMode = overrideOptions?.strictMode ?? baseOptions?.strictMode ?? true;
561
+ merged.filter = (overrideOptions?.filter ?? baseOptions?.filter) !== undefined
562
+ ? String(overrideOptions?.filter ?? baseOptions?.filter)
563
+ : undefined;
564
+ merged.excludeDeps = ((overrideOptions?.excludeDeps ?? baseOptions?.excludeDeps) !== undefined)
565
+ ? Boolean(overrideOptions?.excludeDeps ?? baseOptions?.excludeDeps)
566
+ : undefined;
526
567
  }
527
- // Merge cookies arrays (runOptions cookies + collectionOptions cookies)
528
- const collectionCookies = collectionOptions?.cookies ?? [];
529
- const runCookies = runOptions?.cookies ?? [];
530
- if (collectionCookies.length > 0 || runCookies.length > 0) {
531
- // RunOptions cookies override collection cookies with same name
568
+ // Merge cookies arrays (override cookies + base cookies)
569
+ const baseCookies = baseOptions?.cookies ?? [];
570
+ const overrideCookies = overrideOptions?.cookies ?? [];
571
+ if (baseCookies.length > 0 || overrideCookies.length > 0) {
572
+ // Override cookies override base cookies with same name
532
573
  const cookieMap = new Map();
533
- // Add collection cookies first
534
- for (const cookie of collectionCookies) {
574
+ // Add base cookies first
575
+ for (const cookie of baseCookies) {
535
576
  cookieMap.set(cookie.name, cookie);
536
577
  }
537
- // Then add/override with run cookies
538
- for (const cookie of runCookies) {
578
+ // Then add/override with override cookies
579
+ for (const cookie of overrideCookies) {
539
580
  cookieMap.set(cookie.name, cookie);
540
581
  }
541
582
  merged.cookies = Array.from(cookieMap.values());
@@ -548,12 +589,14 @@ export class CollectionRunner extends EventEmitter {
548
589
  */
549
590
  async evaluateCondition(condition, context) {
550
591
  try {
551
- // Use workaround: store result in global variable
552
- const wrappedScript = `
553
- const __conditionResult = (${condition});
554
- quest.global.variables.set('__conditionResult', String(__conditionResult === true));
555
- `;
556
- const result = await this.scriptEngine.execute(wrappedScript, context, ScriptType.PreRequest, () => { });
592
+ // Use workaround: store result in global variable (serialized)
593
+ const result = await this.queueScript(async () => {
594
+ const wrappedScript = `
595
+ const __conditionResult = (${condition});
596
+ quest.global.variables.set('__conditionResult', String(__conditionResult === true));
597
+ `;
598
+ return await this.scriptEngine.execute(wrappedScript, context, ScriptType.PreRequest, () => { });
599
+ });
557
600
  if (result.success === false) {
558
601
  this.logger.warn(`Condition evaluation error: ${result.error}`);
559
602
  return false;
@@ -699,12 +742,19 @@ export class CollectionRunner extends EventEmitter {
699
742
  return;
700
743
  }
701
744
  const folder = node.item;
702
- // PUSH folder scope
703
- context.scopeStack.push({
745
+ const parentScope = node.parentFolderItemId !== undefined
746
+ ? this.folderScopeById.get(node.parentFolderItemId)
747
+ : context.scope;
748
+ if (parentScope === undefined) {
749
+ throw new Error(`Missing parent scope for folder '${folder.id}'`);
750
+ }
751
+ const folderScope = {
704
752
  level: 'folder',
705
753
  id: folder.id,
706
- vars: {}
707
- });
754
+ vars: {},
755
+ parent: parentScope
756
+ };
757
+ this.folderScopeById.set(folder.id, folderScope);
708
758
  // Emit beforeFolder event
709
759
  const beforeFolderEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context);
710
760
  this.emit('beforeFolder', beforeFolderEnvelope);
@@ -721,12 +771,9 @@ export class CollectionRunner extends EventEmitter {
721
771
  return;
722
772
  }
723
773
  const folder = node.item;
724
- // Check if folder scope exists on stack before popping
725
- // (folder-enter may have been skipped due to condition)
726
- const topScope = context.scopeStack[context.scopeStack.length - 1];
727
- if (topScope?.level === 'folder' && topScope.id === folder.id) {
728
- // POP folder scope
729
- context.scopeStack.pop();
774
+ const folderScope = this.folderScopeById.get(folder.id);
775
+ if (folderScope !== undefined) {
776
+ this.folderScopeById.delete(folder.id);
730
777
  // Emit afterFolder event
731
778
  const afterFolderEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context);
732
779
  this.emit('afterFolder', {
@@ -734,7 +781,6 @@ export class CollectionRunner extends EventEmitter {
734
781
  duration: 0
735
782
  });
736
783
  }
737
- // If scope doesn't match, folder-enter was skipped - no POP or event needed
738
784
  }
739
785
  async executeFolderPreScript(script, context, node, flags) {
740
786
  if (flags.skip || flags.bail) {
@@ -824,6 +870,13 @@ export class CollectionRunner extends EventEmitter {
824
870
  success: true,
825
871
  tests: [],
826
872
  duration: 0,
873
+ summary: {
874
+ outcome: 'success',
875
+ code: 'skipped',
876
+ label: 'Skipped',
877
+ message: 'Skipped by bail',
878
+ duration: 0
879
+ },
827
880
  iteration: context.iterationCurrent,
828
881
  scriptError: 'Skipped by bail'
829
882
  };
@@ -837,6 +890,13 @@ export class CollectionRunner extends EventEmitter {
837
890
  success: true,
838
891
  tests: [],
839
892
  duration: 0,
893
+ summary: {
894
+ outcome: 'success',
895
+ code: 'skipped',
896
+ label: 'Skipped',
897
+ message: 'Skipped by condition',
898
+ duration: 0
899
+ },
840
900
  iteration: context.iterationCurrent,
841
901
  scriptError: 'Skipped by condition'
842
902
  };
@@ -849,32 +909,62 @@ export class CollectionRunner extends EventEmitter {
849
909
  request,
850
910
  path: node.path
851
911
  });
852
- // PUSH request scope
853
- context.scopeStack.push({
854
- level: 'request',
855
- id: request.id,
856
- vars: {}
857
- });
912
+ const parentScope = node.parentFolderItemId !== undefined
913
+ ? this.folderScopeById.get(node.parentFolderItemId)
914
+ : context.scope;
915
+ if (parentScope === undefined) {
916
+ throw new Error(`Missing parent scope for request '${request.id}'`);
917
+ }
918
+ const requestOptions = this.mergeOptions(context.options, request.options, { includeMeta: false, includeProxy: false });
919
+ const requestContext = {
920
+ ...context,
921
+ scope: {
922
+ level: 'request',
923
+ id: request.id,
924
+ vars: {},
925
+ parent: parentScope
926
+ },
927
+ options: requestOptions
928
+ };
929
+ // For persist=false, use a per-request cookie jar to avoid cross-request leakage
930
+ if (requestContext.options.jar?.persist !== true) {
931
+ requestContext.cookieJar = new CookieJar({ persist: false });
932
+ // Inject initial cookies for this request (merged from collection/run/request options)
933
+ const initialCookies = requestContext.options.cookies ?? [];
934
+ for (const cookie of initialCookies) {
935
+ if (cookie.domain === null || cookie.domain === undefined) {
936
+ continue;
937
+ }
938
+ requestContext.cookieJar.set(cookie.name, cookie.value, {
939
+ domain: cookie.domain,
940
+ path: cookie.path,
941
+ expires: cookie.expires,
942
+ httpOnly: cookie.httpOnly,
943
+ secure: cookie.secure,
944
+ sameSite: cookie.sameSite
945
+ });
946
+ }
947
+ }
858
948
  try {
859
949
  // Execute all pre-request scripts (inherited + request-level) through queue
860
950
  if (node.inheritedPreScripts !== undefined && node.inheritedPreScripts.length > 0) {
861
951
  await this.queueScript(async () => {
862
952
  // Set currentRequest inside the queued function to avoid race conditions in parallel execution
863
- context.currentRequest = request;
953
+ requestContext.currentRequest = request;
864
954
  this.logger.debug(`Executing pre-script for request: id=${request.id}, name=${request.name}`);
865
955
  for (const script of node.inheritedPreScripts) {
866
956
  // Emit beforePreScript event
867
- const beforePreEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
957
+ const beforePreEnvelope = this.createEventEnvelope(requestContext.collectionInfo, node.path, requestContext, request);
868
958
  this.emit('beforePreScript', {
869
959
  ...beforePreEnvelope,
870
960
  request,
871
961
  path: node.path
872
962
  });
873
- const preScriptResult = await this.scriptEngine.execute(script, context, ScriptType.PreRequest, () => { } // Pre-request scripts cannot have tests
963
+ const preScriptResult = await this.scriptEngine.execute(script, requestContext, ScriptType.PreRequest, () => { } // Pre-request scripts cannot have tests
874
964
  );
875
965
  this.emitConsoleOutput(preScriptResult.consoleOutput);
876
966
  // Emit afterPreScript event
877
- const afterPreEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
967
+ const afterPreEnvelope = this.createEventEnvelope(requestContext.collectionInfo, node.path, requestContext, request);
878
968
  this.emit('afterPreScript', {
879
969
  ...afterPreEnvelope,
880
970
  request,
@@ -891,13 +981,13 @@ export class CollectionRunner extends EventEmitter {
891
981
  }
892
982
  // HTTP execution NOT queued - runs in parallel
893
983
  // Set currentRequest before I/O phase
894
- context.currentRequest = request;
984
+ requestContext.currentRequest = request;
895
985
  // Apply effective auth from node (collection/folder auth inheritance)
896
986
  // Request auth > Folder auth > Collection auth
897
987
  if (node.effectiveAuth !== undefined) {
898
988
  request.auth = node.effectiveAuth;
899
989
  }
900
- this.resolveRequest(request, context);
990
+ this.resolveRequest(request, requestContext);
901
991
  // Track plugin event tests and indices
902
992
  const pluginEventTests = [];
903
993
  const eventIndices = new Map();
@@ -915,20 +1005,21 @@ export class CollectionRunner extends EventEmitter {
915
1005
  const currentIndex = eventIndices.get(eventName) ?? 0;
916
1006
  // Set event context (wrapped in try/finally to prevent state leak)
917
1007
  try {
918
- context.currentEvent = {
919
- eventName: eventName,
920
- requestId: request.id,
921
- timestamp: new Date(),
922
- data: eventData,
923
- index: currentIndex
924
- };
925
1008
  // Execute plugin event script through the queue (serialized)
926
1009
  const result = await this.queueScript(async () => {
927
- return await this.scriptEngine.execute(eventScript.script, context, ScriptType.PluginEvent, (test) => {
1010
+ requestContext.currentEvent = {
1011
+ eventName: eventName,
1012
+ requestId: request.id,
1013
+ timestamp: new Date(),
1014
+ data: eventData,
1015
+ index: currentIndex
1016
+ };
1017
+ requestContext.currentRequest = request;
1018
+ return await this.scriptEngine.execute(eventScript.script, requestContext, ScriptType.PluginEvent, (test) => {
928
1019
  // Emit assertion event for plugin event test
929
- const eventDef = context.protocolPlugin.events?.find(e => e.name === eventName);
1020
+ const eventDef = requestContext.protocolPlugin.events?.find(e => e.name === eventName);
930
1021
  if (eventDef?.canHaveTests === true) {
931
- const envelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1022
+ const envelope = this.createEventEnvelope(requestContext.collectionInfo, node.path, requestContext, request);
932
1023
  this.emit('assertion', {
933
1024
  ...envelope,
934
1025
  test,
@@ -957,60 +1048,60 @@ export class CollectionRunner extends EventEmitter {
957
1048
  }
958
1049
  finally {
959
1050
  // Always reset event context to prevent state leak
960
- context.currentEvent = undefined;
1051
+ requestContext.currentEvent = undefined;
961
1052
  // Increment event index for next event of same type
962
1053
  eventIndices.set(eventName, currentIndex + 1);
963
1054
  }
964
1055
  };
965
1056
  // Emit beforeRequest event
966
- const beforeRequestEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1057
+ const beforeRequestEnvelope = this.createEventEnvelope(requestContext.collectionInfo, node.path, requestContext, request);
967
1058
  this.emit('beforeRequest', {
968
1059
  ...beforeRequestEnvelope,
969
1060
  request,
970
1061
  path: node.path
971
1062
  });
972
1063
  const response = await this.pluginManager.execute(context.protocol, request, // Use request directly instead of context.currentRequest
973
- context, context.options, emitEvent);
974
- context.currentResponse = response;
1064
+ requestContext, requestContext.options, emitEvent);
1065
+ requestContext.currentResponse = response;
975
1066
  // Emit afterRequest event with duration from plugin (excludes delay)
976
- const afterRequestEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1067
+ const afterRequestEnvelope = this.createEventEnvelope(requestContext.collectionInfo, node.path, requestContext, request);
977
1068
  this.emit('afterRequest', {
978
1069
  ...afterRequestEnvelope,
979
1070
  request,
980
1071
  response,
981
- duration: response.duration
1072
+ duration: response.summary.duration
982
1073
  });
983
1074
  // Add preliminary execution record to history
984
1075
  const executionRecord = {
985
1076
  id: request.id,
986
1077
  name: request.name,
987
1078
  path: node.path,
988
- iteration: context.iterationCurrent,
1079
+ iteration: requestContext.iterationCurrent,
989
1080
  response,
990
1081
  tests: [...pluginEventTests],
991
1082
  timestamp: new Date().toISOString()
992
1083
  };
993
- context.executionHistory.push(executionRecord);
1084
+ requestContext.executionHistory.push(executionRecord);
994
1085
  // Execute all post-request scripts (request-level + inherited) through queue
995
1086
  let scriptResult = { success: true, tests: [], consoleOutput: [] };
996
1087
  this.logger.debug(`Post-scripts for ${request.id}: ${node.inheritedPostScripts?.length ?? 0}`);
997
1088
  if (node.inheritedPostScripts !== undefined && node.inheritedPostScripts.length > 0) {
998
1089
  scriptResult = await this.queueScript(async () => {
999
1090
  // Set currentRequest inside the queued function to avoid race conditions in parallel execution
1000
- context.currentRequest = request;
1091
+ requestContext.currentRequest = request;
1001
1092
  const combinedResult = { success: true, tests: [], consoleOutput: [] };
1002
1093
  for (const script of node.inheritedPostScripts) {
1003
1094
  // Emit beforePostScript event
1004
- const beforePostEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1095
+ const beforePostEnvelope = this.createEventEnvelope(requestContext.collectionInfo, node.path, requestContext, request);
1005
1096
  this.emit('beforePostScript', {
1006
1097
  ...beforePostEnvelope,
1007
1098
  request,
1008
1099
  path: node.path,
1009
1100
  response
1010
1101
  });
1011
- const postScriptResult = await this.scriptEngine.execute(script, context, ScriptType.PostRequest, (test) => {
1102
+ const postScriptResult = await this.scriptEngine.execute(script, requestContext, ScriptType.PostRequest, (test) => {
1012
1103
  // Emit assertion event
1013
- const envelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1104
+ const envelope = this.createEventEnvelope(requestContext.collectionInfo, node.path, requestContext, request);
1014
1105
  this.emit('assertion', {
1015
1106
  ...envelope,
1016
1107
  test,
@@ -1023,7 +1114,7 @@ export class CollectionRunner extends EventEmitter {
1023
1114
  });
1024
1115
  this.emitConsoleOutput(postScriptResult.consoleOutput);
1025
1116
  // Emit afterPostScript event
1026
- const afterPostEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1117
+ const afterPostEnvelope = this.createEventEnvelope(requestContext.collectionInfo, node.path, requestContext, request);
1027
1118
  this.emit('afterPostScript', {
1028
1119
  ...afterPostEnvelope,
1029
1120
  request,
@@ -1049,14 +1140,15 @@ export class CollectionRunner extends EventEmitter {
1049
1140
  requestId: request.id,
1050
1141
  requestName: request.name,
1051
1142
  path: node.path,
1052
- success: isNullOrEmpty(response.error),
1143
+ success: response.summary.outcome === 'success',
1053
1144
  response,
1054
1145
  tests: allTests,
1055
- duration: response.duration,
1056
- iteration: context.iterationCurrent
1146
+ duration: response.summary.duration ?? 0,
1147
+ summary: response.summary,
1148
+ iteration: requestContext.iterationCurrent
1057
1149
  };
1058
1150
  // Emit afterItem event
1059
- const afterItemEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1151
+ const afterItemEnvelope = this.createEventEnvelope(requestContext.collectionInfo, node.path, requestContext, request);
1060
1152
  this.emit('afterItem', {
1061
1153
  ...afterItemEnvelope,
1062
1154
  request,
@@ -1065,11 +1157,9 @@ export class CollectionRunner extends EventEmitter {
1065
1157
  result
1066
1158
  });
1067
1159
  // Clear cookies if persist is false
1068
- if (context.options.jar?.persist !== true) {
1069
- context.cookieJar.clear();
1160
+ if (requestContext.options.jar?.persist !== true) {
1161
+ requestContext.cookieJar.clear();
1070
1162
  }
1071
- // POP request scope
1072
- context.scopeStack.pop();
1073
1163
  return result;
1074
1164
  }
1075
1165
  catch (error) {
@@ -1080,9 +1170,16 @@ export class CollectionRunner extends EventEmitter {
1080
1170
  path: node.path,
1081
1171
  success: false,
1082
1172
  tests: [],
1083
- duration: context.currentResponse?.duration ?? 0,
1173
+ duration: requestContext.currentResponse?.summary?.duration ?? 0,
1174
+ summary: requestContext.currentResponse?.summary ?? {
1175
+ outcome: 'error',
1176
+ code: 'script',
1177
+ label: 'Script Error',
1178
+ message: err.message ?? String(error),
1179
+ duration: requestContext.currentResponse?.summary?.duration ?? 0
1180
+ },
1084
1181
  scriptError: err.message ?? String(error),
1085
- iteration: context.iterationCurrent
1182
+ iteration: requestContext.iterationCurrent
1086
1183
  };
1087
1184
  const phase = err.phase ?? 'request';
1088
1185
  this.emit('exception', {
@@ -1091,10 +1188,8 @@ export class CollectionRunner extends EventEmitter {
1091
1188
  phase,
1092
1189
  request,
1093
1190
  path: node.path,
1094
- response: context.currentResponse
1191
+ response: requestContext.currentResponse
1095
1192
  });
1096
- // POP request scope even on error
1097
- context.scopeStack.pop();
1098
1193
  // Abort execution to prevent further requests from running (fail-fast)
1099
1194
  this.abort(`Script error in ${phase}: ${result.scriptError}`);
1100
1195
  return result;