@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
@@ -1,1423 +0,0 @@
1
- import { EventEmitter } from 'events';
2
- import { randomUUID } from 'crypto';
3
- import type {
4
- Collection,
5
- RunOptions,
6
- RunResult,
7
- Request,
8
- RequestResult,
9
- ExecutionContext,
10
- RuntimeOptions,
11
- ExecutionRecord,
12
- TestResult,
13
- ScriptResult,
14
- CollectionRunnerOptions,
15
- ScopeFrame,
16
- Folder,
17
- EventEnvelope,
18
- CollectionInfo,
19
- Cookie,
20
- IProtocolPlugin,
21
- IAuthPlugin
22
- } from '@apiquest/types';
23
- import { ScriptType, LogLevel } from '@apiquest/types';
24
- import { VariableResolver } from './VariableResolver.js';
25
- import { PluginManager } from './PluginManager.js';
26
- import { PluginLoader } from './PluginLoader.js';
27
- import { PluginResolver, type ResolvedPlugin } from './PluginResolver.js';
28
- import { CollectionAnalyzer } from './CollectionAnalyzer.js';
29
- import { CollectionValidator } from './CollectionValidator.js';
30
- import { TestCounter } from './TestCounter.js';
31
- import { ScriptEngine } from './ScriptEngine.js';
32
- import { RequestFilter } from './RequestFilter.js';
33
- import { Logger } from './Logger.js';
34
- import { CookieJar } from './CookieJar.js';
35
- import { isNullOrEmpty, isNullOrWhitespace, hasItems } from './utils.js';
36
- import type { ErrorWithPhase } from './CollectionRunner.types.js';
37
- import { TaskGraph, type TaskNode } from './TaskGraph.js';
38
- import { DagScheduler, type DagExecutionCallbacks } from './DagScheduler.js';
39
-
40
- export class CollectionRunner extends EventEmitter {
41
- private variableResolver: VariableResolver;
42
- private pluginManager: PluginManager;
43
- private pluginResolver: PluginResolver;
44
- private pluginLoader: PluginLoader;
45
- private collectionAnalyzer: CollectionAnalyzer;
46
- private collectionValidator: CollectionValidator;
47
- private testCounter: TestCounter;
48
- private scriptEngine: ScriptEngine;
49
- private scriptQueue = Promise.resolve();
50
- private pluginResolutionPromise: Promise<ResolvedPlugin[]>;
51
- private resolvedPlugins: ResolvedPlugin[] = [];
52
- private logger: Logger;
53
- private abortController?: AbortController;
54
- private ownsController = false;
55
- private bailEnabled = false;
56
- private abortReason?: string;
57
- private shouldDelayNextRequest = false;
58
-
59
- constructor(options?: CollectionRunnerOptions) {
60
- super();
61
- const logLevel = options?.logLevel ?? LogLevel.INFO;
62
-
63
- this.logger = new Logger('CollectionRunner', logLevel, this);
64
-
65
- this.variableResolver = new VariableResolver(this.logger);
66
- this.pluginManager = new PluginManager(this.logger);
67
- this.pluginResolver = new PluginResolver(this.logger);
68
- this.pluginLoader = new PluginLoader(this.pluginManager, this.logger);
69
- this.collectionAnalyzer = new CollectionAnalyzer(this.logger);
70
- this.collectionValidator = new CollectionValidator(this.pluginManager, this.logger);
71
- this.testCounter = new TestCounter(this.pluginManager, this.logger);
72
- this.scriptEngine = new ScriptEngine(this.logger);
73
-
74
- // Phase 1: Resolve plugins if directories provided (fast - just scans, no loading)
75
- if (options?.pluginsDir !== undefined) {
76
- const dirs = Array.isArray(options.pluginsDir)
77
- ? options.pluginsDir
78
- : [options.pluginsDir];
79
-
80
- // Start plugin resolution (but don't block constructor)
81
- this.pluginResolutionPromise = this.pluginResolver.scanDirectories(dirs);
82
- } else {
83
- // No plugins to resolve
84
- this.pluginResolutionPromise = Promise.resolve([]);
85
- }
86
- }
87
-
88
- /**
89
- * Queue script execution to ensure sequential execution (thread-safe)
90
- */
91
- private async queueScript<T>(fn: () => Promise<T>): Promise<T> {
92
- if (this.abortController?.signal.aborted === true) {
93
- throw new Error('Script execution aborted');
94
- }
95
- const resultPromise = this.scriptQueue.then(fn);
96
- this.scriptQueue = resultPromise.then(() => {}, () => {});
97
- return resultPromise;
98
- }
99
-
100
- registerPlugin(plugin: IProtocolPlugin): void {
101
- this.pluginManager.registerPlugin(plugin);
102
- }
103
-
104
- registerAuthPlugin(plugin: IAuthPlugin): void {
105
- this.pluginManager.registerAuthPlugin(plugin);
106
- }
107
-
108
- private emitConsoleOutput(messages?: string[]): void {
109
- if (messages === undefined || messages.length === 0) return;
110
- for (const message of messages) {
111
- let level: 'log' | 'info' | 'warn' | 'error' = 'log';
112
- let cleanMessage = message;
113
- if (message.startsWith('[INFO] ')) {
114
- level = 'info';
115
- cleanMessage = message.replace('[INFO] ', '');
116
- } else if (message.startsWith('[WARN] ')) {
117
- level = 'warn';
118
- cleanMessage = message.replace('[WARN] ', '');
119
- } else if (message.startsWith('[ERROR] ')) {
120
- level = 'error';
121
- cleanMessage = message.replace('[ERROR] ', '');
122
- }
123
- this.emit('console', { id: randomUUID(), message: cleanMessage, level });
124
- }
125
- }
126
-
127
- /**
128
- * Subscribe to ALL events emitted by the runner (including custom plugin events)
129
- * Overrides emit to intercept all event emissions
130
- */
131
- onAll(handler: (eventType: string, data: unknown) => void): () => void {
132
- // Store original emit
133
- const originalEmit = this.emit.bind(this);
134
-
135
- // Override emit to intercept all events
136
- this.emit = function(event: string | symbol, ...args: unknown[]): boolean {
137
- // Call original handler first
138
- const result = originalEmit(event, ...args);
139
-
140
- // Skip internal EventEmitter events
141
- if (event !== 'newListener' && event !== 'removeListener') {
142
- handler(String(event), args[0]);
143
- }
144
-
145
- return result;
146
- } as typeof originalEmit;
147
-
148
- // Return cleanup function
149
- return () => {
150
- // Restore original emit
151
- this.emit = originalEmit;
152
- };
153
- }
154
-
155
- /**
156
- * Create event envelope for all events (except console)
157
- */
158
- private createEventEnvelope(
159
- collectionInfo: CollectionInfo,
160
- path: EventEnvelope['path'],
161
- context?: ExecutionContext,
162
- request?: Request
163
- ): EventEnvelope {
164
- const pathType = path === 'collection:/' ? 'collection' : path.startsWith('folder:/') ? 'folder' : 'request';
165
-
166
- const envelope: EventEnvelope = {
167
- id: randomUUID(),
168
- path,
169
- pathType,
170
- collectionInfo
171
- };
172
-
173
- // Add iteration info if available
174
- if (context !== undefined) {
175
- const currentRow = context.iterationData?.[context.iterationCurrent - 1];
176
-
177
- envelope.iteration = {
178
- current: context.iterationCurrent,
179
- total: context.iterationCount,
180
- source: context.iterationSource,
181
- rowIndex: currentRow !== undefined ? context.iterationCurrent - 1 : undefined,
182
- rowKeys: currentRow !== undefined ? Object.keys(currentRow) : undefined,
183
- row: currentRow
184
- };
185
- }
186
-
187
- // Add request if provided
188
- if (request !== undefined) {
189
- envelope.request = request;
190
- }
191
-
192
- return envelope;
193
- }
194
-
195
- private abort(reason: string): void {
196
- this.abortReason ??= reason;
197
- if (this.ownsController && this.abortController?.signal.aborted === false) {
198
- this.abortController.abort(reason);
199
- }
200
- }
201
-
202
- private isAborted(): boolean {
203
- return this.abortController?.signal.aborted === true;
204
- }
205
-
206
- private getAbortReason(): string | undefined {
207
- if (this.abortReason !== undefined) {
208
- return this.abortReason;
209
- }
210
- const signalReason = this.abortController?.signal.reason as unknown;
211
- if (signalReason !== undefined) {
212
- return String(signalReason);
213
- }
214
- return undefined;
215
- }
216
-
217
- async run(collection: Collection, options: RunOptions = {}): Promise<RunResult> {
218
- const startTime = new Date();
219
-
220
- // Phase 1: Wait for plugin resolution
221
- this.resolvedPlugins = await this.pluginResolutionPromise;
222
- this.logger.debug(`Plugin resolution complete: ${this.resolvedPlugins.length} plugins available`);
223
-
224
- // Analyze collection to determine required plugins
225
- const requirements = this.collectionAnalyzer.analyzeRequirements(collection);
226
- this.logger.debug(`Collection requires: protocols=[${Array.from(requirements.protocols)}], auth=[${Array.from(requirements.authTypes)}], providers=[${Array.from(requirements.valueProviders)}]`);
227
-
228
- // Phase 2: Load ONLY required plugins
229
- await this.pluginLoader.loadRequiredPlugins(this.resolvedPlugins, requirements);
230
- this.logger.debug('Required plugins loaded');
231
-
232
- this.logger.info(`Starting collection: ${collection.info.name}`);
233
- this.logger.debug(`Collection ID: ${collection.info.id}, Protocol: ${collection.protocol}`);
234
-
235
- // Validate and cache protocol plugin
236
- const protocolPlugin = this.pluginManager.getPlugin(collection.protocol);
237
- if (protocolPlugin === undefined) {
238
- throw new Error(
239
- `No plugin registered for protocol '${collection.protocol}'. ` +
240
- `Available protocols: ${this.pluginManager.getAllPlugins().flatMap(p => p.protocols).join(', ')}`
241
- );
242
- }
243
-
244
- // Merge runtime options (needed for validation)
245
- const runtimeOptions = this.mergeOptions(collection.options, options);
246
-
247
- if (options.signal !== undefined) {
248
- this.ownsController = false;
249
- this.abortController = {
250
- signal: options.signal,
251
- abort: () => {}
252
- } as AbortController;
253
- this.logger.info('Using external abort signal');
254
- } else {
255
- this.ownsController = true;
256
- this.abortController = new AbortController();
257
- this.logger.info('Created internal abort controller');
258
- }
259
-
260
- this.abortReason = undefined;
261
- this.bailEnabled = runtimeOptions.execution?.bail === true;
262
-
263
- // Get strict mode with precedence: RunOptions > Collection.options > Default (true)
264
- const strictMode = options.strictMode ?? collection.options?.strictMode ?? true;
265
-
266
- // PRE-RUN VALIDATION
267
- const validationResult = await this.collectionValidator.validateCollection(collection, runtimeOptions, strictMode);
268
-
269
- // Additional validation: cookie-jar-persist incompatible with parallel execution
270
- if (runtimeOptions.execution?.allowParallel === true && runtimeOptions.jar?.persist === true) {
271
- validationResult.valid = false;
272
- validationResult.errors ??= [];
273
- validationResult.errors.push({
274
- location: '/options/execution',
275
- message: 'Cookie jar persistence (jar.persist=true) is not allowed with parallel execution (allowParallel=true). ' +
276
- 'In parallel mode, cookies are cleared after each request to prevent race conditions.',
277
- source: 'schema' // Using schema as this is a configuration validation error
278
- });
279
- }
280
-
281
- // COUNT EXPECTED TESTS (only in strict mode for deterministic counting)
282
- const expectedTestCount = strictMode ? this.testCounter.countTests(collection) : -1;
283
-
284
- let collectionToRun = collection;
285
- if (runtimeOptions.filter !== undefined) {
286
- // Filter collection
287
- collectionToRun = RequestFilter.filterCollection(collection, {
288
- filter: runtimeOptions.filter,
289
- excludeDeps: Boolean(runtimeOptions.excludeDeps)
290
- });
291
-
292
- if (collectionToRun !== collection) {
293
- this.logger.debug('Collection filtered');
294
- }
295
- }
296
-
297
- // Determine test data (CLI options override collection testData)
298
- let iterationData = options.data ?? collectionToRun.testData ?? [];
299
-
300
- // Apply --iterations limits
301
- // 1. With data (CLI or collection): limit to first N rows
302
- // 2. Without data: repeat collection N times
303
- let iterationCount: number;
304
- if (options.iterations !== undefined && options.iterations > 0) {
305
- if (iterationData.length > 0) {
306
- // With data: limit to first N rows
307
- iterationData = iterationData.slice(0, options.iterations);
308
- iterationCount = iterationData.length;
309
- } else {
310
- // Without data: repeat collection N times
311
- iterationCount = options.iterations;
312
- }
313
- } else {
314
- // No --iterations specified: use all data or run once
315
- iterationCount = iterationData.length > 0 ? iterationData.length : 1;
316
- }
317
-
318
- // Determine iteration source for event envelopes
319
- const iterationSource: 'collection' | 'cli' | 'none' =
320
- options.data !== undefined ? 'cli' : (collection.testData !== undefined ? 'collection' : 'none');
321
-
322
- // Emit beforeRun with validation results and expected test count
323
- // Note: -1 means dynamic (can't determine), undefined means not calculated
324
- this.emit('beforeRun', {
325
- collectionInfo: {
326
- id: collection.info.id,
327
- name: collection.info.name,
328
- version: collection.info.version,
329
- description: collection.info.description
330
- },
331
- options,
332
- validationResult,
333
- expectedTestCount // Include -1 for dynamic tests
334
- });
335
-
336
- // STOP if validation failed
337
- if (validationResult.valid === false) {
338
- const endTime = new Date();
339
- return {
340
- collectionId: collection.info.id,
341
- collectionName: collection.info.name,
342
- startTime,
343
- endTime,
344
- duration: endTime.getTime() - startTime.getTime(),
345
- requestResults: [],
346
- totalTests: 0,
347
- passedTests: 0,
348
- failedTests: 0,
349
- skippedTests: 0,
350
- validationErrors: validationResult.errors,
351
- aborted: this.isAborted(),
352
- abortReason: this.getAbortReason()
353
- };
354
- }
355
-
356
- const requestResults: RequestResult[] = [];
357
-
358
- // Initialize cookie jar - always create one
359
- // If jar.persist = true, cookies carry across requests
360
- // If jar.persist = false (default), each request gets cookies from response only
361
- const cookieJar = new CookieJar(runtimeOptions.jar ?? { persist: false });
362
-
363
- // Inject initial cookies if provided in options
364
- if (runtimeOptions.cookies !== null && runtimeOptions.cookies !== undefined && runtimeOptions.cookies.length > 0) {
365
- for (const cookie of runtimeOptions.cookies) {
366
- // Skip cookies without domain - domain is required for RFC 6265 compliance
367
- if (cookie.domain === null || cookie.domain === undefined) {
368
- continue;
369
- }
370
- cookieJar.set(cookie.name, cookie.value, {
371
- domain: cookie.domain,
372
- path: cookie.path,
373
- expires: cookie.expires,
374
- httpOnly: cookie.httpOnly,
375
- secure: cookie.secure,
376
- sameSite: cookie.sameSite
377
- });
378
- }
379
- }
380
-
381
- // Initialize scope stack with collection scope
382
- const scopeStack: ScopeFrame[] = [{
383
- level: 'collection',
384
- id: collection.info.id,
385
- vars: {}
386
- }];
387
-
388
- // Execute collection pre-request script once before all iterations
389
- if (!isNullOrWhitespace(collection.collectionPreScript)) {
390
- // Emit event before collection pre-script execution
391
- const envelope = this.createEventEnvelope(collection.info, 'collection:/', undefined);
392
- this.emit('beforeCollectionPreScript', {
393
- ...envelope,
394
- path: 'collection:/' as const
395
- });
396
-
397
- const tempContext: ExecutionContext = {
398
- collectionInfo: collection.info,
399
- protocol: collection.protocol,
400
- collectionVariables: collection.variables ?? {},
401
- globalVariables: options.globalVariables ?? {},
402
- scopeStack: [...scopeStack], // Clone scope stack
403
- environment: options.environment,
404
- iterationCurrent: 1,
405
- iterationCount,
406
- iterationData,
407
- iterationSource,
408
- executionHistory: [],
409
- options: this.mergeOptions(collection.options, options),
410
- cookieJar,
411
- eventEmitter: this,
412
- protocolPlugin,
413
- abortSignal: this.abortController?.signal
414
- };
415
-
416
- const preScriptResult = await this.queueScript(() =>
417
- this.scriptEngine.execute(
418
- collection.collectionPreScript!,
419
- tempContext,
420
- ScriptType.CollectionPre,
421
- () => {} // noop - collection pre-scripts cannot have tests
422
- )
423
- );
424
- this.emitConsoleOutput(preScriptResult.consoleOutput);
425
-
426
- // Emit event after collection pre-script completion
427
- const afterEnvelope = this.createEventEnvelope(collection.info, 'collection:/', tempContext);
428
- this.emit('afterCollectionPreScript', {
429
- ...afterEnvelope,
430
- path: 'collection:/' as const,
431
- result: preScriptResult
432
- });
433
-
434
- if (preScriptResult.success === false) {
435
- throw new Error(`Collection pre-script error: ${preScriptResult.error}`);
436
- }
437
-
438
- // Update context variables for iterations
439
- options.globalVariables = tempContext.globalVariables;
440
- options.environment = tempContext.environment;
441
-
442
- // Update collection scope with any changes from script
443
- Object.assign(scopeStack[0].vars, tempContext.scopeStack[0].vars);
444
- }
445
-
446
- for (let i = 0; i < iterationCount; i++) {
447
- if (this.isAborted()) {
448
- this.logger.info('Run aborted - skipping remaining iterations');
449
- break;
450
- }
451
- const iterationStart = Date.now();
452
- const context: ExecutionContext = {
453
- collectionInfo: collection.info,
454
- protocol: collection.protocol,
455
- collectionVariables: collection.variables ?? {},
456
- globalVariables: options.globalVariables ?? {},
457
- scopeStack: [...scopeStack], // Clone scope stack for each iteration
458
- environment: options.environment,
459
- iterationCurrent: i + 1,
460
- iterationCount,
461
- iterationData,
462
- iterationSource,
463
- executionHistory: [],
464
- options: this.mergeOptions(collection.options, options),
465
- cookieJar,
466
- eventEmitter: this,
467
- protocolPlugin,
468
- abortSignal: this.abortController?.signal
469
- };
470
-
471
- // Emit beforeIteration event
472
- const iterationEnvelope = this.createEventEnvelope(collection.info, 'collection:/', context);
473
- this.emit('beforeIteration', {
474
- ...iterationEnvelope,
475
- iteration: iterationEnvelope.iteration!
476
- });
477
-
478
- // Use DAG-based execution (ALWAYS - concurrency=1 for sequential mode)
479
- await this.executeWithDAG(collectionToRun, context, requestResults);
480
-
481
- // Emit afterIteration event
482
- const iterationDuration = Date.now() - iterationStart;
483
- const afterIterationEnvelope = this.createEventEnvelope(collection.info, 'collection:/', context);
484
- this.emit('afterIteration', {
485
- ...afterIterationEnvelope,
486
- iteration: afterIterationEnvelope.iteration!,
487
- duration: iterationDuration
488
- });
489
-
490
- // Update collection scope with changes from iteration
491
- Object.assign(scopeStack[0].vars, context.scopeStack[0].vars);
492
-
493
- // Update global variables and environment for next iteration
494
- options.globalVariables = context.globalVariables;
495
- options.environment = context.environment;
496
- }
497
-
498
- // Execute collection post-request script once after all iterations
499
- if (!isNullOrWhitespace(collection.collectionPostScript)) {
500
- // Emit event before collection post-script execution
501
- const beforePostEnvelope = this.createEventEnvelope(collection.info, 'collection:/', undefined);
502
- this.emit('beforeCollectionPostScript', {
503
- ...beforePostEnvelope,
504
- path: 'collection:/' as const
505
- });
506
-
507
- const tempContext: ExecutionContext = {
508
- collectionInfo: collection.info,
509
- protocol: collection.protocol,
510
- collectionVariables: collection.variables ?? {},
511
- globalVariables: options.globalVariables ?? {},
512
- scopeStack: [...scopeStack], // Clone scope stack
513
- environment: options.environment,
514
- iterationCurrent: iterationCount,
515
- iterationCount,
516
- iterationData,
517
- iterationSource,
518
- executionHistory: [],
519
- options: this.mergeOptions(collection.options, options),
520
- cookieJar,
521
- eventEmitter: this,
522
- protocolPlugin,
523
- abortSignal: this.abortController?.signal
524
- };
525
-
526
- const postScriptResult = await this.queueScript(() =>
527
- this.scriptEngine.execute(
528
- collection.collectionPostScript!,
529
- tempContext,
530
- ScriptType.CollectionPost,
531
- () => {} // noop - collection post-scripts cannot have tests
532
- )
533
- );
534
- this.emitConsoleOutput(postScriptResult.consoleOutput);
535
-
536
- // Emit event for collection post-script completion
537
- const afterPostEnvelope = this.createEventEnvelope(collection.info, 'collection:/', tempContext);
538
- this.emit('afterCollectionPostScript', {
539
- ...afterPostEnvelope,
540
- path: 'collection:/' as const,
541
- result: postScriptResult
542
- });
543
-
544
- if (postScriptResult.success === false) {
545
- throw new Error(`Collection post-script error: ${postScriptResult.error}`);
546
- }
547
- }
548
-
549
- const endTime = new Date();
550
- const duration = endTime.getTime() - startTime.getTime();
551
-
552
- const totalTests = requestResults.reduce((sum, r) => sum + r.tests.length, 0);
553
- const passedTests = requestResults.reduce(
554
- (sum, r) => sum + r.tests.filter(t => t.passed && !t.skipped).length,
555
- 0
556
- );
557
- const failedTests = requestResults.reduce(
558
- (sum, r) => sum + r.tests.filter(t => !t.passed && !t.skipped).length,
559
- 0
560
- );
561
- const skippedTests = requestResults.reduce(
562
- (sum, r) => sum + r.tests.filter(t => t.skipped).length,
563
- 0
564
- );
565
-
566
- const result: RunResult = {
567
- collectionId: collection.info.id,
568
- collectionName: collection.info.name,
569
- startTime,
570
- endTime,
571
- duration,
572
- requestResults,
573
- totalTests,
574
- passedTests,
575
- failedTests,
576
- skippedTests,
577
- aborted: this.isAborted(),
578
- abortReason: this.getAbortReason()
579
- };
580
-
581
- this.emit('afterRun', {
582
- collectionInfo: collection.info,
583
- result
584
- });
585
- return result;
586
- }
587
-
588
- private resolveRequest(request: Request, context: ExecutionContext): void {
589
- // Resolve variables in request data
590
- request.data = this.variableResolver.resolveAll(request.data, context) as typeof request.data;
591
-
592
- // Resolve variables in auth data
593
- if (request.auth?.data !== undefined) {
594
- request.auth.data = this.variableResolver.resolveAll(request.auth.data, context) as Record<string, unknown>;
595
- }
596
- }
597
-
598
- private mergeOptions(
599
- collectionOptions?: RuntimeOptions,
600
- runOptions?: RunOptions
601
- ): RuntimeOptions {
602
- // Since RunOptions extends RuntimeOptions, merge is straightforward
603
- // RunOptions takes precedence over collectionOptions
604
- const merged: RuntimeOptions = {
605
- ...collectionOptions,
606
- ...runOptions,
607
- // Deep merge nested objects
608
- execution: {
609
- ...(collectionOptions?.execution ?? {}),
610
- ...(runOptions?.execution ?? {})
611
- },
612
- // Ensure defaults
613
- strictMode: runOptions?.strictMode ?? collectionOptions?.strictMode ?? true,
614
- // Include filter options (type narrow from undefined)
615
- filter: (runOptions?.filter ?? collectionOptions?.filter) !== undefined
616
- ? String(runOptions?.filter ?? collectionOptions?.filter)
617
- : undefined,
618
- excludeDeps: ((runOptions?.excludeDeps ?? collectionOptions?.excludeDeps) !== undefined)
619
- ? Boolean(runOptions?.excludeDeps ?? collectionOptions?.excludeDeps)
620
- : undefined
621
- };
622
-
623
- // Conditionally merge optional nested objects
624
- if (collectionOptions?.timeout !== undefined || runOptions?.timeout !== undefined) {
625
- merged.timeout = {
626
- ...(collectionOptions?.timeout ?? {}),
627
- ...(runOptions?.timeout ?? {})
628
- };
629
- }
630
-
631
- if (collectionOptions?.ssl !== undefined || runOptions?.ssl !== undefined) {
632
- merged.ssl = {
633
- ...(collectionOptions?.ssl ?? {}),
634
- ...(runOptions?.ssl ?? {})
635
- };
636
- }
637
-
638
- if (collectionOptions?.jar !== undefined || runOptions?.jar !== undefined) {
639
- merged.jar = {
640
- persist: runOptions?.jar?.persist ?? collectionOptions?.jar?.persist ?? false
641
- };
642
- }
643
-
644
- if (runOptions?.proxy !== null && runOptions?.proxy !== undefined) {
645
- merged.proxy = runOptions.proxy;
646
- } else if (collectionOptions?.proxy !== null && collectionOptions?.proxy !== undefined) {
647
- merged.proxy = collectionOptions.proxy;
648
- }
649
-
650
- // Merge cookies arrays (runOptions cookies + collectionOptions cookies)
651
- const collectionCookies = collectionOptions?.cookies ?? [];
652
- const runCookies = runOptions?.cookies ?? [];
653
- if (collectionCookies.length > 0 || runCookies.length > 0) {
654
- // RunOptions cookies override collection cookies with same name
655
- const cookieMap = new Map<string, Cookie>();
656
-
657
- // Add collection cookies first
658
- for (const cookie of collectionCookies) {
659
- cookieMap.set(cookie.name, cookie);
660
- }
661
-
662
- // Then add/override with run cookies
663
- for (const cookie of runCookies) {
664
- cookieMap.set(cookie.name, cookie);
665
- }
666
-
667
- merged.cookies = Array.from(cookieMap.values());
668
- }
669
-
670
- return merged;
671
- }
672
-
673
- /**
674
- * Evaluate condition expression at runtime
675
- * Returns true if condition passes, false if it fails
676
- */
677
- private async evaluateCondition(condition: string, context: ExecutionContext): Promise<boolean> {
678
- try {
679
- // Use workaround: store result in global variable
680
- const wrappedScript = `
681
- const __conditionResult = (${condition});
682
- quest.global.variables.set('__conditionResult', String(__conditionResult === true));
683
- `;
684
-
685
- const result = await this.scriptEngine.execute(
686
- wrappedScript,
687
- context,
688
- ScriptType.PreRequest,
689
- () => {}
690
- );
691
-
692
- if (result.success === false) {
693
- this.logger.warn(`Condition evaluation error: ${result.error}`);
694
- return false;
695
- }
696
-
697
- // Read result from global variables
698
- const conditionResult = context.globalVariables.__conditionResult === 'true';
699
-
700
- // Clean up temp variable
701
- delete context.globalVariables.__conditionResult;
702
-
703
- return conditionResult;
704
- } catch (error) {
705
- this.logger.error(`Failed to evaluate condition: ${condition}`, error);
706
- return false;
707
- }
708
- }
709
-
710
- // ========================================================================
711
- // DAG Execution Methods
712
- // ========================================================================
713
-
714
- /**
715
- * Execute collection using DAG-based execution
716
- */
717
- private async executeWithDAG(
718
- collection: Collection,
719
- context: ExecutionContext,
720
- results: RequestResult[]
721
- ): Promise<void> {
722
- // Determine execution mode
723
- const allowParallel = context.options.execution?.allowParallel === true;
724
-
725
- // Build TaskGraph from collection (DAG structure depends on execution mode)
726
- this.logger.debug(`Building TaskGraph from collection (parallel=${allowParallel})`);
727
- const taskGraph = new TaskGraph(this.logger);
728
- taskGraph.build(collection, allowParallel);
729
- this.logger.debug(`TaskGraph built: ${taskGraph.getNodes().size} nodes, ${taskGraph.getEdges().length} edges`);
730
-
731
- // Determine concurrency (0 defaults to 1)
732
- let maxConcurrency = allowParallel
733
- ? (context.options.execution?.maxConcurrency ?? 5)
734
- : 1; // Sequential mode uses concurrency=1
735
-
736
- // Treat 0 as 1 (invalid value)
737
- if (maxConcurrency === 0) {
738
- this.logger.warn('maxConcurrency=0 is invalid, defaulting to 1');
739
- maxConcurrency = 1;
740
- }
741
-
742
- this.logger.info(`DAG execution mode: ${maxConcurrency === 1 ? 'SEQUENTIAL' : `PARALLEL (concurrency=${maxConcurrency})`}`);
743
-
744
- // Create scheduler with callbacks
745
- const callbacks: DagExecutionCallbacks = {
746
- executeScript: this.executeScriptForDAG.bind(this),
747
- executeFolderEnter: this.executeFolderEnter.bind(this),
748
- executeFolderExit: this.executeFolderExit.bind(this),
749
- executeRequestIO: this.executeRequestIOForDAG.bind(this),
750
- evaluateCondition: this.evaluateCondition.bind(this),
751
- isAborted: this.isAborted.bind(this)
752
- };
753
-
754
- const scheduler = new DagScheduler(callbacks, maxConcurrency, this.logger);
755
-
756
- // Execute DAG
757
- this.logger.debug('Starting DAG execution');
758
- const dagResults = await scheduler.execute(taskGraph, context);
759
- this.logger.debug(`DAG execution complete: ${dagResults.length} requests executed`);
760
-
761
- results.push(...dagResults);
762
-
763
- // Check for script errors and stop execution if found
764
- for (const result of dagResults) {
765
- if (result.scriptError !== undefined && result.scriptError !== 'Skipped by condition' && result.scriptError !== 'Skipped by bail') {
766
- throw new Error(result.scriptError);
767
- }
768
- }
769
- }
770
-
771
- /**
772
- * Execute a script node (called by DagScheduler)
773
- */
774
- private async executeScriptForDAG(
775
- script: string,
776
- scriptType: ScriptType,
777
- context: ExecutionContext,
778
- node: TaskNode,
779
- flags: { skip: boolean; bail: boolean }
780
- ): Promise<ScriptResult> {
781
- if (flags.bail) {
782
- return { success: true, tests: [], consoleOutput: [] };
783
- }
784
-
785
- // Skip script execution but allow skipped assertions in post-request scripts
786
- if (flags.skip) {
787
- if (scriptType === ScriptType.FolderPre || scriptType === ScriptType.FolderPost) {
788
- return { success: true, tests: [], consoleOutput: [] };
789
- }
790
-
791
- // Collection scripts should not run during skip propagation
792
- if (scriptType === ScriptType.CollectionPre || scriptType === ScriptType.CollectionPost) {
793
- return { success: true, tests: [], consoleOutput: [] };
794
- }
795
-
796
- // Plugin event scripts are skipped without assertions
797
- if (scriptType === ScriptType.PluginEvent) {
798
- return { success: true, tests: [], consoleOutput: [] };
799
- }
800
- }
801
-
802
- // Handle different script types
803
- switch (scriptType) {
804
- case ScriptType.CollectionPre:
805
- return await this.executeCollectionPreScript(script, context);
806
-
807
- case ScriptType.CollectionPost:
808
- return await this.executeCollectionPostScript(script, context);
809
-
810
- case ScriptType.FolderPre:
811
- return await this.executeFolderPreScript(script, context, node, flags);
812
-
813
- case ScriptType.FolderPost:
814
- return await this.executeFolderPostScript(script, context, node, flags);
815
-
816
- case ScriptType.PluginEvent:
817
- return await this.executePluginEventScript(script, context, node);
818
-
819
- default:
820
- this.logger.error(`Unknown script type: ${scriptType as string}`);
821
- return {
822
- success: false,
823
- tests: [],
824
- consoleOutput: [],
825
- error: `Unknown script type: ${scriptType as string}`
826
- };
827
- }
828
- }
829
-
830
- private async executeCollectionPreScript(
831
- script: string,
832
- context: ExecutionContext
833
- ): Promise<ScriptResult> {
834
- const envelope = this.createEventEnvelope(context.collectionInfo, 'collection:/', undefined);
835
- this.emit('beforeCollectionPreScript', {
836
- ...envelope,
837
- script
838
- });
839
-
840
- const result = await this.scriptEngine.execute(
841
- script,
842
- context,
843
- ScriptType.CollectionPre,
844
- () => {} // Collection pre-scripts cannot have tests
845
- );
846
- this.emitConsoleOutput(result.consoleOutput);
847
-
848
- const afterEnvelope = this.createEventEnvelope(context.collectionInfo, 'collection:/', context);
849
- this.emit('afterCollectionPreScript', {
850
- ...afterEnvelope,
851
- result
852
- });
853
-
854
- return result;
855
- }
856
-
857
- private async executeCollectionPostScript(
858
- script: string,
859
- context: ExecutionContext
860
- ): Promise<ScriptResult> {
861
- const beforeEnvelope = this.createEventEnvelope(context.collectionInfo, 'collection:/', undefined);
862
- this.emit('beforeCollectionPostScript', {
863
- ...beforeEnvelope,
864
- script
865
- });
866
-
867
- const result = await this.scriptEngine.execute(
868
- script,
869
- context,
870
- ScriptType.CollectionPost,
871
- () => {} // Collection post-scripts cannot have tests
872
- );
873
- this.emitConsoleOutput(result.consoleOutput);
874
-
875
- const afterEnvelope = this.createEventEnvelope(context.collectionInfo, 'collection:/', context);
876
- this.emit('afterCollectionPostScript', {
877
- ...afterEnvelope,
878
- result
879
- });
880
-
881
- return result;
882
- }
883
-
884
- /**
885
- * Execute folder enter lifecycle: PUSH scope + emit beforeFolder
886
- * ALWAYS executes regardless of script existence
887
- */
888
- private async executeFolderEnter(
889
- node: TaskNode,
890
- context: ExecutionContext,
891
- flags: { skip: boolean; bail: boolean }
892
- ): Promise<void> {
893
- if (flags.skip || flags.bail) {
894
- return;
895
- }
896
- const folder = node.item as Folder;
897
-
898
- // PUSH folder scope
899
- context.scopeStack.push({
900
- level: 'folder',
901
- id: folder.id,
902
- vars: {}
903
- });
904
-
905
- // Emit beforeFolder event
906
- const beforeFolderEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context);
907
- this.emit('beforeFolder', beforeFolderEnvelope);
908
- }
909
-
910
- /**
911
- * Execute folder exit lifecycle: POP scope + emit afterFolder
912
- * ALWAYS executes regardless of script existence
913
- *
914
- * Note: If folder-enter was skipped due to condition, the scope was never pushed.
915
- * We only POP if the top of the stack matches this folder's ID.
916
- */
917
- private async executeFolderExit(
918
- node: TaskNode,
919
- context: ExecutionContext,
920
- flags: { skip: boolean; bail: boolean }
921
- ): Promise<void> {
922
- if (flags.skip || flags.bail) {
923
- return;
924
- }
925
- const folder = node.item as Folder;
926
-
927
- // Check if folder scope exists on stack before popping
928
- // (folder-enter may have been skipped due to condition)
929
- const topScope = context.scopeStack[context.scopeStack.length - 1];
930
- if (topScope?.level === 'folder' && topScope.id === folder.id) {
931
- // POP folder scope
932
- context.scopeStack.pop();
933
-
934
- // Emit afterFolder event
935
- const afterFolderEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context);
936
- this.emit('afterFolder', {
937
- ...afterFolderEnvelope,
938
- duration: 0
939
- });
940
- }
941
- // If scope doesn't match, folder-enter was skipped - no POP or event needed
942
- }
943
-
944
- private async executeFolderPreScript(
945
- script: string,
946
- context: ExecutionContext,
947
- node: TaskNode,
948
- flags: { skip: boolean; bail: boolean }
949
- ): Promise<ScriptResult> {
950
- if (flags.skip || flags.bail) {
951
- return { success: true, tests: [], consoleOutput: [] };
952
- }
953
- // Emit beforeFolderPreScript event
954
- const beforePreEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context);
955
- this.emit('beforeFolderPreScript', beforePreEnvelope);
956
-
957
- const result = await this.scriptEngine.execute(
958
- script,
959
- context,
960
- ScriptType.FolderPre,
961
- () => {} // Folder pre-scripts cannot have tests
962
- );
963
- this.emitConsoleOutput(result.consoleOutput);
964
-
965
- // Emit afterFolderPreScript event
966
- const afterPreEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context);
967
- this.emit('afterFolderPreScript', {
968
- ...afterPreEnvelope,
969
- result
970
- });
971
-
972
- return result;
973
- }
974
-
975
- private async executeFolderPostScript(
976
- script: string,
977
- context: ExecutionContext,
978
- node: TaskNode,
979
- flags: { skip: boolean; bail: boolean }
980
- ): Promise<ScriptResult> {
981
- if (flags.skip || flags.bail) {
982
- return { success: true, tests: [], consoleOutput: [] };
983
- }
984
- // Emit beforeFolderPostScript event
985
- const beforePostEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context);
986
- this.emit('beforeFolderPostScript', beforePostEnvelope);
987
-
988
- const result = await this.scriptEngine.execute(
989
- script,
990
- context,
991
- ScriptType.FolderPost,
992
- () => {} // Folder post-scripts cannot have tests
993
- );
994
- this.emitConsoleOutput(result.consoleOutput);
995
-
996
- // Emit afterFolderPostScript event
997
- const afterPostEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context);
998
- this.emit('afterFolderPostScript', {
999
- ...afterPostEnvelope,
1000
- result
1001
- });
1002
-
1003
- return result;
1004
- }
1005
-
1006
- private async executePluginEventScript(
1007
- script: string,
1008
- context: ExecutionContext,
1009
- node: TaskNode
1010
- ): Promise<ScriptResult> {
1011
- // Plugin event scripts should already have context.currentEvent set by the plugin
1012
- // We just need to execute the script through the queue (THIS FIXES THE BUG!)
1013
-
1014
- const request = context.currentRequest!;
1015
- const eventName = node.eventName!;
1016
-
1017
- const result = await this.scriptEngine.execute(
1018
- script,
1019
- context,
1020
- ScriptType.PluginEvent,
1021
- (test: TestResult) => {
1022
- // Emit assertion event for plugin event test
1023
- const eventDef = context.protocolPlugin.events?.find(e => e.name === eventName);
1024
- if (eventDef?.canHaveTests === true) {
1025
- const envelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1026
- this.emit('assertion', {
1027
- ...envelope,
1028
- test,
1029
- event: eventName
1030
- });
1031
- }
1032
-
1033
- // Trigger bail on failed test if enabled
1034
- if (this.bailEnabled && this.ownsController && !test.passed && !test.skipped) {
1035
- this.abort('Test failure (--bail)');
1036
- }
1037
- }
1038
- );
1039
-
1040
- return result;
1041
- }
1042
-
1043
- /**
1044
- * Execute request I/O (called by DagScheduler)
1045
- * This handles the full request lifecycle: pre-scripts → I/O → post-scripts
1046
- */
1047
- private async executeRequestIOForDAG(
1048
- node: TaskNode,
1049
- context: ExecutionContext,
1050
- flags: { skip: boolean; bail: boolean }
1051
- ): Promise<RequestResult> {
1052
- const request = node.item as Request;
1053
-
1054
- // NOTE: Do NOT set context.currentRequest here in parallel mode!
1055
- // It will be overwritten by other parallel requests before scripts execute.
1056
- // Instead, set it inside each queued script function.
1057
- context.currentPath = node.path;
1058
-
1059
- // Apply execution.delay between requests (not before first, not if parallel, not if skipped)
1060
- if (!flags.skip && !flags.bail) {
1061
- const delay = context.options?.execution?.delay ?? 0;
1062
- const isParallel = context.options?.execution?.allowParallel ?? false;
1063
-
1064
- if (this.shouldDelayNextRequest && delay > 0 && !isParallel) {
1065
- this.logger.debug(`Delaying ${delay}ms before request`);
1066
- await new Promise(resolve => setTimeout(resolve, delay));
1067
- }
1068
-
1069
- // Mark that subsequent requests should be delayed
1070
- this.shouldDelayNextRequest = true;
1071
- }
1072
-
1073
- if (flags.bail) {
1074
- const skippedResult: RequestResult = {
1075
- requestId: request.id,
1076
- requestName: request.name,
1077
- path: node.path,
1078
- success: true,
1079
- tests: [],
1080
- duration: 0,
1081
- iteration: context.iterationCurrent,
1082
- scriptError: 'Skipped by bail'
1083
- };
1084
- return skippedResult;
1085
- }
1086
-
1087
- if (flags.skip) {
1088
- const skippedResult: RequestResult = {
1089
- requestId: request.id,
1090
- requestName: request.name,
1091
- path: node.path,
1092
- success: true,
1093
- tests: [],
1094
- duration: 0,
1095
- iteration: context.iterationCurrent,
1096
- scriptError: 'Skipped by condition'
1097
- };
1098
- return skippedResult;
1099
- }
1100
-
1101
- // Emit beforeItem event
1102
- const beforeItemEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1103
- this.emit('beforeItem', {
1104
- ...beforeItemEnvelope,
1105
- request,
1106
- path: node.path
1107
- });
1108
-
1109
- // PUSH request scope
1110
- context.scopeStack.push({
1111
- level: 'request',
1112
- id: request.id,
1113
- vars: {}
1114
- });
1115
-
1116
- try {
1117
- // Execute all pre-request scripts (inherited + request-level) through queue
1118
- if (node.inheritedPreScripts !== undefined && node.inheritedPreScripts.length > 0) {
1119
- await this.queueScript(async () => {
1120
- // Set currentRequest inside the queued function to avoid race conditions in parallel execution
1121
- context.currentRequest = request;
1122
- this.logger.debug(`Executing pre-script for request: id=${request.id}, name=${request.name}`);
1123
- for (const script of node.inheritedPreScripts!) {
1124
- // Emit beforePreScript event
1125
- const beforePreEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1126
- this.emit('beforePreScript', {
1127
- ...beforePreEnvelope,
1128
- request,
1129
- path: node.path
1130
- });
1131
-
1132
- const preScriptResult = await this.scriptEngine.execute(
1133
- script,
1134
- context,
1135
- ScriptType.PreRequest,
1136
- () => {} // Pre-request scripts cannot have tests
1137
- );
1138
- this.emitConsoleOutput(preScriptResult.consoleOutput);
1139
-
1140
- // Emit afterPreScript event
1141
- const afterPreEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1142
- this.emit('afterPreScript', {
1143
- ...afterPreEnvelope,
1144
- request,
1145
- path: node.path,
1146
- result: preScriptResult
1147
- });
1148
-
1149
- if (preScriptResult.success === false) {
1150
- const error = new Error(
1151
- `Pre-request script error: ${preScriptResult.error}`
1152
- ) as ErrorWithPhase;
1153
- error.phase = 'prerequest';
1154
- throw error;
1155
- }
1156
- }
1157
- });
1158
- }
1159
-
1160
- // HTTP execution NOT queued - runs in parallel
1161
- // Set currentRequest before I/O phase
1162
- context.currentRequest = request;
1163
-
1164
- // Apply effective auth from node (collection/folder auth inheritance)
1165
- // Request auth > Folder auth > Collection auth
1166
- if (node.effectiveAuth !== undefined) {
1167
- request.auth = node.effectiveAuth;
1168
- }
1169
-
1170
- this.resolveRequest(request, context);
1171
-
1172
- // Track plugin event tests and indices
1173
- const pluginEventTests: TestResult[] = [];
1174
- const eventIndices = new Map<string, number>();
1175
-
1176
- // Create emitEvent callback for plugin event execution during I/O
1177
- // Plugin events fire DURING request execution (e.g., WebSocket onMessage)
1178
- // They must be queued/serialized and complete before request finishes
1179
- const emitEvent = async (eventName: string, eventData: unknown): Promise<void> => {
1180
- this.logger.trace(`Plugin event emitted: ${eventName}`, { hasScripts: request.data.scripts !== undefined });
1181
-
1182
- // Find matching script (validation ensures at most one per event type)
1183
- const eventScript = request.data.scripts?.find(s => s.event === eventName);
1184
- this.logger.trace(`Event script found: ${eventScript !== undefined}`);
1185
- if (eventScript === undefined) return;
1186
-
1187
- // Get current event index (starts at 0, increments per event type)
1188
- const currentIndex = eventIndices.get(eventName) ?? 0;
1189
-
1190
- // Set event context (wrapped in try/finally to prevent state leak)
1191
- try {
1192
- context.currentEvent = {
1193
- eventName: eventName,
1194
- requestId: request.id,
1195
- timestamp: new Date(),
1196
- data: eventData,
1197
- index: currentIndex
1198
- };
1199
-
1200
- // Execute plugin event script through the queue (serialized)
1201
- const result = await this.queueScript(async () => {
1202
- return await this.scriptEngine.execute(
1203
- eventScript.script,
1204
- context,
1205
- ScriptType.PluginEvent,
1206
- (test: TestResult) => {
1207
- // Emit assertion event for plugin event test
1208
- const eventDef = context.protocolPlugin.events?.find(e => e.name === eventName);
1209
- if (eventDef?.canHaveTests === true) {
1210
- const envelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1211
- this.emit('assertion', {
1212
- ...envelope,
1213
- test,
1214
- event: eventName
1215
- });
1216
- }
1217
-
1218
- // Trigger bail on failed test if enabled
1219
- if (this.bailEnabled && this.ownsController && !test.passed && !test.skipped) {
1220
- this.abort('Test failure (--bail)');
1221
- }
1222
- }
1223
- );
1224
- });
1225
-
1226
- this.emitConsoleOutput(result.consoleOutput);
1227
-
1228
- // Collect test results - add directly to pluginEventTests array
1229
- if (result.tests !== undefined && result.tests.length > 0) {
1230
- this.logger.trace(`Plugin event tests collected: ${result.tests.length}`);
1231
- pluginEventTests.push(...result.tests);
1232
- } else {
1233
- this.logger.trace('No tests in plugin event result');
1234
- }
1235
-
1236
- // Handle script errors (log but don't throw - allow other events to continue)
1237
- if (!isNullOrEmpty(result.error)) {
1238
- this.logger.error(`Plugin event script error (${eventName}):`, result.error);
1239
- }
1240
- } finally {
1241
- // Always reset event context to prevent state leak
1242
- context.currentEvent = undefined;
1243
-
1244
- // Increment event index for next event of same type
1245
- eventIndices.set(eventName, currentIndex + 1);
1246
- }
1247
- };
1248
-
1249
- // Emit beforeRequest event
1250
- const beforeRequestEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1251
- this.emit('beforeRequest', {
1252
- ...beforeRequestEnvelope,
1253
- request,
1254
- path: node.path
1255
- });
1256
-
1257
- const response = await this.pluginManager.execute(
1258
- context.protocol,
1259
- request, // Use request directly instead of context.currentRequest
1260
- context,
1261
- context.options,
1262
- emitEvent
1263
- );
1264
- context.currentResponse = response;
1265
-
1266
- // Emit afterRequest event with duration from plugin (excludes delay)
1267
- const afterRequestEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1268
- this.emit('afterRequest', {
1269
- ...afterRequestEnvelope,
1270
- request,
1271
- response,
1272
- duration: response.duration
1273
- });
1274
-
1275
- // Add preliminary execution record to history
1276
- const executionRecord: ExecutionRecord = {
1277
- id: request.id,
1278
- name: request.name,
1279
- path: node.path,
1280
- iteration: context.iterationCurrent,
1281
- response,
1282
- tests: [...pluginEventTests],
1283
- timestamp: new Date().toISOString()
1284
- };
1285
-
1286
- context.executionHistory.push(executionRecord);
1287
-
1288
- // Execute all post-request scripts (request-level + inherited) through queue
1289
- let scriptResult: ScriptResult = { success: true, tests: [], consoleOutput: [] };
1290
-
1291
- this.logger.debug(`Post-scripts for ${request.id}: ${node.inheritedPostScripts?.length ?? 0}`);
1292
-
1293
- if (node.inheritedPostScripts !== undefined && node.inheritedPostScripts.length > 0) {
1294
- scriptResult = await this.queueScript(async () => {
1295
- // Set currentRequest inside the queued function to avoid race conditions in parallel execution
1296
- context.currentRequest = request;
1297
- const combinedResult: ScriptResult = { success: true, tests: [], consoleOutput: [] };
1298
-
1299
- for (const script of node.inheritedPostScripts!) {
1300
- // Emit beforePostScript event
1301
- const beforePostEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1302
- this.emit('beforePostScript', {
1303
- ...beforePostEnvelope,
1304
- request,
1305
- path: node.path,
1306
- response
1307
- });
1308
-
1309
- const postScriptResult = await this.scriptEngine.execute(
1310
- script,
1311
- context,
1312
- ScriptType.PostRequest,
1313
- (test: TestResult) => {
1314
- // Emit assertion event
1315
- const envelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1316
- this.emit('assertion', {
1317
- ...envelope,
1318
- test,
1319
- response
1320
- });
1321
-
1322
- // Trigger bail on failed test
1323
- if (this.bailEnabled && this.ownsController && !test.passed && !test.skipped) {
1324
- this.abort('Test failure (--bail)');
1325
- }
1326
- }
1327
- );
1328
- this.emitConsoleOutput(postScriptResult.consoleOutput);
1329
-
1330
- // Emit afterPostScript event
1331
- const afterPostEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1332
- this.emit('afterPostScript', {
1333
- ...afterPostEnvelope,
1334
- request,
1335
- path: node.path,
1336
- response,
1337
- result: postScriptResult
1338
- });
1339
-
1340
- if (postScriptResult.success === false) {
1341
- const error = new Error(
1342
- `Post-request script error: ${postScriptResult.error}`
1343
- ) as ErrorWithPhase;
1344
- error.phase = 'postrequest';
1345
- throw error;
1346
- }
1347
-
1348
- combinedResult.tests.push(...postScriptResult.tests);
1349
- combinedResult.consoleOutput.push(...postScriptResult.consoleOutput);
1350
- }
1351
-
1352
- return combinedResult;
1353
- });
1354
- }
1355
-
1356
- // Update execution record with all tests
1357
- const allTests = [...pluginEventTests, ...scriptResult.tests];
1358
- executionRecord.tests = allTests;
1359
-
1360
- const result: RequestResult = {
1361
- requestId: request.id,
1362
- requestName: request.name,
1363
- path: node.path,
1364
- success: isNullOrEmpty(response.error),
1365
- response,
1366
- tests: allTests,
1367
- duration: response.duration,
1368
- iteration: context.iterationCurrent
1369
- };
1370
-
1371
- // Emit afterItem event
1372
- const afterItemEnvelope = this.createEventEnvelope(context.collectionInfo, node.path, context, request);
1373
- this.emit('afterItem', {
1374
- ...afterItemEnvelope,
1375
- request,
1376
- path: node.path,
1377
- response,
1378
- result
1379
- });
1380
-
1381
- // Clear cookies if persist is false
1382
- if (context.options.jar?.persist !== true) {
1383
- context.cookieJar.clear();
1384
- }
1385
-
1386
- // POP request scope
1387
- context.scopeStack.pop();
1388
-
1389
- return result;
1390
- } catch (error) {
1391
- const err = error as ErrorWithPhase & { message?: string };
1392
-
1393
- const result: RequestResult = {
1394
- requestId: request.id,
1395
- requestName: request.name,
1396
- path: node.path,
1397
- success: false,
1398
- tests: [],
1399
- duration: context.currentResponse?.duration ?? 0,
1400
- scriptError: err.message ?? String(error),
1401
- iteration: context.iterationCurrent
1402
- };
1403
-
1404
- const phase = err.phase ?? 'request';
1405
- this.emit('exception', {
1406
- id: randomUUID(),
1407
- error,
1408
- phase,
1409
- request,
1410
- path: node.path,
1411
- response: context.currentResponse
1412
- });
1413
-
1414
- // POP request scope even on error
1415
- context.scopeStack.pop();
1416
-
1417
- // Abort execution to prevent further requests from running (fail-fast)
1418
- this.abort(`Script error in ${phase}: ${result.scriptError}`);
1419
-
1420
- return result;
1421
- }
1422
- }
1423
- }