@griffin-app/griffin-plan-executor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +152 -0
  2. package/dist/adapters/axios.d.ts +5 -0
  3. package/dist/adapters/axios.d.ts.map +1 -0
  4. package/dist/adapters/axios.js +36 -0
  5. package/dist/adapters/axios.js.map +1 -0
  6. package/dist/adapters/index.d.ts +3 -0
  7. package/dist/adapters/index.d.ts.map +1 -0
  8. package/dist/adapters/index.js +3 -0
  9. package/dist/adapters/index.js.map +1 -0
  10. package/dist/adapters/stub.d.ts +22 -0
  11. package/dist/adapters/stub.d.ts.map +1 -0
  12. package/dist/adapters/stub.js +36 -0
  13. package/dist/adapters/stub.js.map +1 -0
  14. package/dist/events/emitter.d.ts +68 -0
  15. package/dist/events/emitter.d.ts.map +1 -0
  16. package/dist/events/emitter.js +83 -0
  17. package/dist/events/emitter.js.map +1 -0
  18. package/dist/events/emitter.test.d.ts +2 -0
  19. package/dist/events/emitter.test.d.ts.map +1 -0
  20. package/dist/events/emitter.test.js +251 -0
  21. package/dist/events/emitter.test.js.map +1 -0
  22. package/dist/events/index.d.ts +3 -0
  23. package/dist/events/index.d.ts.map +1 -0
  24. package/dist/events/index.js +3 -0
  25. package/dist/events/index.js.map +1 -0
  26. package/dist/events/types.d.ts +109 -0
  27. package/dist/events/types.d.ts.map +1 -0
  28. package/dist/events/types.js +9 -0
  29. package/dist/events/types.js.map +1 -0
  30. package/dist/executor.d.ts +4 -0
  31. package/dist/executor.d.ts.map +1 -0
  32. package/dist/executor.js +732 -0
  33. package/dist/executor.js.map +1 -0
  34. package/dist/executor.test.d.ts +2 -0
  35. package/dist/executor.test.d.ts.map +1 -0
  36. package/dist/executor.test.js +1524 -0
  37. package/dist/executor.test.js.map +1 -0
  38. package/dist/index.d.ts +8 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +12 -0
  41. package/dist/index.js.map +1 -0
  42. package/dist/secrets/index.d.ts +14 -0
  43. package/dist/secrets/index.d.ts.map +1 -0
  44. package/dist/secrets/index.js +18 -0
  45. package/dist/secrets/index.js.map +1 -0
  46. package/dist/secrets/providers/aws.d.ts +63 -0
  47. package/dist/secrets/providers/aws.d.ts.map +1 -0
  48. package/dist/secrets/providers/aws.js +111 -0
  49. package/dist/secrets/providers/aws.js.map +1 -0
  50. package/dist/secrets/providers/env.d.ts +36 -0
  51. package/dist/secrets/providers/env.d.ts.map +1 -0
  52. package/dist/secrets/providers/env.js +37 -0
  53. package/dist/secrets/providers/env.js.map +1 -0
  54. package/dist/secrets/providers/index.d.ts +7 -0
  55. package/dist/secrets/providers/index.d.ts.map +1 -0
  56. package/dist/secrets/providers/index.js +7 -0
  57. package/dist/secrets/providers/index.js.map +1 -0
  58. package/dist/secrets/providers/vault.d.ts +75 -0
  59. package/dist/secrets/providers/vault.d.ts.map +1 -0
  60. package/dist/secrets/providers/vault.js +143 -0
  61. package/dist/secrets/providers/vault.js.map +1 -0
  62. package/dist/secrets/registry.d.ts +61 -0
  63. package/dist/secrets/registry.d.ts.map +1 -0
  64. package/dist/secrets/registry.js +182 -0
  65. package/dist/secrets/registry.js.map +1 -0
  66. package/dist/secrets/resolver.d.ts +40 -0
  67. package/dist/secrets/resolver.d.ts.map +1 -0
  68. package/dist/secrets/resolver.js +178 -0
  69. package/dist/secrets/resolver.js.map +1 -0
  70. package/dist/secrets/secrets.test.d.ts +2 -0
  71. package/dist/secrets/secrets.test.d.ts.map +1 -0
  72. package/dist/secrets/secrets.test.js +243 -0
  73. package/dist/secrets/secrets.test.js.map +1 -0
  74. package/dist/secrets/types.d.ts +71 -0
  75. package/dist/secrets/types.d.ts.map +1 -0
  76. package/dist/secrets/types.js +38 -0
  77. package/dist/secrets/types.js.map +1 -0
  78. package/dist/shared.d.ts +8 -0
  79. package/dist/shared.d.ts.map +1 -0
  80. package/dist/shared.js +30 -0
  81. package/dist/shared.js.map +1 -0
  82. package/dist/test-plan-types.d.ts +43 -0
  83. package/dist/test-plan-types.d.ts.map +1 -0
  84. package/dist/test-plan-types.js +2 -0
  85. package/dist/test-plan-types.js.map +1 -0
  86. package/dist/types.d.ts +77 -0
  87. package/dist/types.d.ts.map +1 -0
  88. package/dist/types.js +3 -0
  89. package/dist/types.js.map +1 -0
  90. package/package.json +35 -0
  91. package/src/adapters/axios.ts +41 -0
  92. package/src/adapters/index.ts +2 -0
  93. package/src/adapters/stub.ts +47 -0
  94. package/src/events/emitter.test.ts +316 -0
  95. package/src/events/emitter.ts +133 -0
  96. package/src/events/index.ts +2 -0
  97. package/src/events/types.ts +132 -0
  98. package/src/executor.test.ts +1674 -0
  99. package/src/executor.ts +986 -0
  100. package/src/index.ts +69 -0
  101. package/src/secrets/index.ts +41 -0
  102. package/src/secrets/providers/aws.ts +179 -0
  103. package/src/secrets/providers/env.ts +66 -0
  104. package/src/secrets/providers/index.ts +15 -0
  105. package/src/secrets/providers/vault.ts +257 -0
  106. package/src/secrets/registry.ts +234 -0
  107. package/src/secrets/resolver.ts +249 -0
  108. package/src/secrets/secrets.test.ts +318 -0
  109. package/src/secrets/types.ts +105 -0
  110. package/src/shared.ts +46 -0
  111. package/src/test-plan-types.ts +49 -0
  112. package/src/types.ts +95 -0
  113. package/tsconfig.json +20 -0
  114. package/vitest.config.ts +14 -0
@@ -0,0 +1,986 @@
1
+ import {
2
+ TestPlanV1,
3
+ Node,
4
+ Endpoint,
5
+ Wait,
6
+ Assertions,
7
+ JSONAssertion,
8
+ Assertion,
9
+ } from "griffin/types";
10
+
11
+ import { HttpMethod, ResponseFormat, NodeType } from "griffin/schema";
12
+
13
+ import { UnaryPredicate, BinaryPredicateOperator } from "griffin";
14
+
15
+ import type {
16
+ ExecutionOptions,
17
+ ExecutionResult,
18
+ NodeResult,
19
+ EndpointResult,
20
+ WaitResult,
21
+ JSONValue,
22
+ NodeResponseData,
23
+ } from "./types.js";
24
+ import { START, END } from "./types.js";
25
+ import { createStateGraph, graphStore, StateGraphRegistry } from "ts-edge";
26
+ import type { ExecutionEvent, BaseEvent } from "./events/index.js";
27
+ import { randomUUID } from "crypto";
28
+ import {
29
+ resolveSecretsInPlan,
30
+ planHasSecrets,
31
+ SecretResolutionError,
32
+ } from "./secrets/index.js";
33
+
34
+ // Define context type that matches ts-edge's GraphNodeExecuteContext (not exported from library)
35
+ interface NodeExecuteContext {
36
+ stream: (chunk: string) => void;
37
+ metadata: Record<string, unknown>;
38
+ }
39
+
40
+ /**
41
+ * Execution context that tracks event emission state throughout a plan execution.
42
+ * Maintains sequence counter and provides event creation helpers.
43
+ */
44
+ class ExecutionContext {
45
+ private seq = 0;
46
+
47
+ constructor(
48
+ public readonly executionId: string,
49
+ public readonly plan: TestPlanV1,
50
+ private readonly emitter?: ExecutionOptions["eventEmitter"],
51
+ ) {}
52
+
53
+ /**
54
+ * Create base event properties with auto-incrementing sequence number.
55
+ */
56
+ private createBaseEvent(): BaseEvent {
57
+ return {
58
+ eventId: randomUUID(),
59
+ seq: this.seq++,
60
+ timestamp: Date.now(),
61
+ planId: this.plan.id,
62
+ executionId: this.executionId,
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Emit an event (best-effort, non-blocking).
68
+ * Accepts an event object without base properties (those are added automatically).
69
+ */
70
+ emit(event: Partial<ExecutionEvent>): void {
71
+ if (!this.emitter) return;
72
+
73
+ const fullEvent = {
74
+ ...this.createBaseEvent(),
75
+ ...event,
76
+ } as ExecutionEvent;
77
+
78
+ try {
79
+ this.emitter.emit(fullEvent);
80
+ } catch (error) {
81
+ // Don't let event emission errors break execution
82
+ console.error("Error emitting event:", error);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Emit an error event with proper error formatting.
88
+ */
89
+ emitError(error: unknown, context?: string): void {
90
+ const errorName = error instanceof Error ? error.constructor.name : "Error";
91
+ const message = error instanceof Error ? error.message : String(error);
92
+ const stack = error instanceof Error ? error.stack : undefined;
93
+
94
+ this.emit({
95
+ type: "ERROR",
96
+ errorName,
97
+ message,
98
+ context,
99
+ // Only include stack in local mode (best-effort detection)
100
+ ...(stack && { stack }),
101
+ });
102
+ }
103
+
104
+ /**
105
+ * Convert NodeType enum to string for events.
106
+ */
107
+ static nodeTypeToString(type: NodeType): "endpoint" | "wait" | "assertion" {
108
+ switch (type) {
109
+ case NodeType.ENDPOINT:
110
+ return "endpoint";
111
+ case NodeType.WAIT:
112
+ return "wait";
113
+ case NodeType.ASSERTION:
114
+ return "assertion";
115
+ }
116
+ }
117
+ }
118
+ // Dynamic state graph type for runtime-constructed graphs
119
+ // - ExecutionState: the shared state type
120
+ // - string: node names are arbitrary strings (not known at compile time)
121
+ // - never: no nodes are pre-marked as "connected" (having outgoing edges)
122
+ type DynamicStateGraph = StateGraphRegistry<ExecutionState, string, never>;
123
+
124
+ function httpMethodToString(method: HttpMethod): string {
125
+ const methodMap: Record<HttpMethod, string> = {
126
+ [HttpMethod.GET]: "GET",
127
+ [HttpMethod.POST]: "POST",
128
+ [HttpMethod.PUT]: "PUT",
129
+ [HttpMethod.DELETE]: "DELETE",
130
+ [HttpMethod.PATCH]: "PATCH",
131
+ [HttpMethod.HEAD]: "HEAD",
132
+ [HttpMethod.OPTIONS]: "OPTIONS",
133
+ [HttpMethod.CONNECT]: "CONNECT",
134
+ [HttpMethod.TRACE]: "TRACE",
135
+ };
136
+ return methodMap[method];
137
+ }
138
+
139
+ // State shared across all nodes during execution
140
+ interface ExecutionState {
141
+ responses: Record<string, NodeResponseData>;
142
+ results: NodeResult[];
143
+ errors: string[];
144
+ executionContext: ExecutionContext;
145
+ }
146
+
147
+ function buildNode(
148
+ plan: TestPlanV1,
149
+ node: Node,
150
+ options: ExecutionOptions,
151
+ ): {
152
+ name: string;
153
+ execute: (
154
+ state: ExecutionState,
155
+ context: NodeExecuteContext,
156
+ ) => Promise<ExecutionState>;
157
+ } {
158
+ switch (node.type) {
159
+ case NodeType.ENDPOINT: {
160
+ return {
161
+ name: node.id,
162
+ execute: async (
163
+ state: ExecutionState,
164
+ tsEdgeContext: NodeExecuteContext,
165
+ ): Promise<ExecutionState> => {
166
+ const { responses, results, errors, executionContext } = state;
167
+ const nodeStartTime = Date.now();
168
+
169
+ // Emit NODE_START event
170
+ executionContext.emit({
171
+ type: "NODE_START",
172
+ nodeId: node.id,
173
+ nodeType: ExecutionContext.nodeTypeToString(node.type),
174
+ });
175
+
176
+ // Handle NODE_STREAM events from ts-edge
177
+ const originalStream = tsEdgeContext.stream;
178
+ tsEdgeContext.stream = (chunk: string) => {
179
+ executionContext.emit({
180
+ type: "NODE_STREAM",
181
+ nodeId: node.id,
182
+ chunk,
183
+ });
184
+ originalStream(chunk);
185
+ };
186
+
187
+ const result = await executeEndpoint(
188
+ node.id,
189
+ node,
190
+ options,
191
+ executionContext,
192
+ );
193
+
194
+ // Store complete response data for downstream assertions
195
+ if (result.success && result.response !== undefined) {
196
+ responses[node.id] = {
197
+ body: result.response,
198
+ headers: result.headers || {},
199
+ status: result.status || 0,
200
+ };
201
+ }
202
+
203
+ // Record result in state
204
+ results.push({
205
+ nodeId: node.id,
206
+ success: result.success,
207
+ response: result.response,
208
+ error: result.error,
209
+ duration_ms: result.duration_ms,
210
+ });
211
+
212
+ // Track errors
213
+ if (!result.success && result.error) {
214
+ errors.push(`${node.id}: ${result.error}`);
215
+ }
216
+
217
+ // Emit NODE_END event
218
+ executionContext.emit({
219
+ type: "NODE_END",
220
+ nodeId: node.id,
221
+ nodeType: ExecutionContext.nodeTypeToString(node.type),
222
+ success: result.success,
223
+ duration_ms: Date.now() - nodeStartTime,
224
+ error: result.error,
225
+ });
226
+
227
+ return { responses, results, errors, executionContext };
228
+ },
229
+ };
230
+ }
231
+ case NodeType.WAIT: {
232
+ return {
233
+ name: node.id,
234
+ execute: async (
235
+ state: ExecutionState,
236
+ tsEdgeContext: NodeExecuteContext,
237
+ ): Promise<ExecutionState> => {
238
+ const { responses, results, errors, executionContext } = state;
239
+ const nodeStartTime = Date.now();
240
+
241
+ // Emit NODE_START event
242
+ executionContext.emit({
243
+ type: "NODE_START",
244
+ nodeId: node.id,
245
+ nodeType: ExecutionContext.nodeTypeToString(node.type),
246
+ });
247
+
248
+ const result = await executeWait(node.id, node, executionContext);
249
+
250
+ // Record result in state
251
+ results.push({
252
+ nodeId: node.id,
253
+ success: result.success,
254
+ duration_ms: result.duration_ms,
255
+ });
256
+
257
+ // Emit NODE_END event
258
+ executionContext.emit({
259
+ type: "NODE_END",
260
+ nodeId: node.id,
261
+ nodeType: ExecutionContext.nodeTypeToString(node.type),
262
+ success: result.success,
263
+ duration_ms: Date.now() - nodeStartTime,
264
+ });
265
+
266
+ return { responses, results, errors, executionContext };
267
+ },
268
+ };
269
+ }
270
+ case NodeType.ASSERTION: {
271
+ return {
272
+ name: node.id,
273
+ execute: async (
274
+ state: ExecutionState,
275
+ tsEdgeContext: NodeExecuteContext,
276
+ ): Promise<ExecutionState> => {
277
+ const { responses, results, errors, executionContext } = state;
278
+ const nodeStartTime = Date.now();
279
+
280
+ // Emit NODE_START event
281
+ executionContext.emit({
282
+ type: "NODE_START",
283
+ nodeId: node.id,
284
+ nodeType: ExecutionContext.nodeTypeToString(node.type),
285
+ });
286
+
287
+ const result = await executeAssertions(
288
+ node.id,
289
+ node,
290
+ responses,
291
+ executionContext,
292
+ );
293
+
294
+ // Record result in state
295
+ results.push(result);
296
+
297
+ // Track errors
298
+ if (!result.success && result.error) {
299
+ errors.push(`${node.id}: ${result.error}`);
300
+ }
301
+
302
+ // Emit NODE_END event
303
+ executionContext.emit({
304
+ type: "NODE_END",
305
+ nodeId: node.id,
306
+ nodeType: ExecutionContext.nodeTypeToString(node.type),
307
+ success: result.success,
308
+ duration_ms: Date.now() - nodeStartTime,
309
+ error: result.error,
310
+ });
311
+
312
+ return { responses, results, errors, executionContext };
313
+ },
314
+ };
315
+ }
316
+ }
317
+ }
318
+
319
+ function buildGraph(
320
+ plan: TestPlanV1,
321
+ options: ExecutionOptions,
322
+ executionContext: ExecutionContext,
323
+ ): DynamicStateGraph {
324
+ // Create a state store for execution
325
+ const store = graphStore<ExecutionState>(() => ({
326
+ responses: {},
327
+ results: [],
328
+ errors: [],
329
+ executionContext,
330
+ }));
331
+
332
+ const graph: DynamicStateGraph = createStateGraph(store)
333
+ .addNode({
334
+ name: START,
335
+ execute: () => ({}),
336
+ })
337
+ .addNode({
338
+ name: END,
339
+ execute: () => ({}),
340
+ }) as DynamicStateGraph;
341
+
342
+ // Add all nodes - cast back to DynamicStateGraph to maintain our dynamic type
343
+ const graphWithNodes = plan.nodes.reduce<DynamicStateGraph>(
344
+ (g, node) => g.addNode(buildNode(plan, node, options)) as DynamicStateGraph,
345
+ graph,
346
+ );
347
+
348
+ // Add all edges
349
+ // Cast the edge method to accept string arguments since ts-edge expects literal types
350
+ // but we have runtime strings from the plan
351
+ const graphWithEdges = plan.edges.reduce<DynamicStateGraph>((g, edge) => {
352
+ const addEdge = g.edge as (from: string, to: string) => DynamicStateGraph;
353
+ return addEdge(edge.from, edge.to);
354
+ }, graphWithNodes);
355
+
356
+ return graphWithEdges;
357
+ }
358
+ export async function executePlanV1(
359
+ plan: TestPlanV1,
360
+ options: ExecutionOptions,
361
+ ): Promise<ExecutionResult> {
362
+ const startTime = Date.now();
363
+
364
+ // Generate or use provided executionId
365
+ const executionId = options.executionId || randomUUID();
366
+
367
+ // Create execution context for event emission
368
+ const executionContext = new ExecutionContext(
369
+ executionId,
370
+ plan,
371
+ options.eventEmitter,
372
+ );
373
+
374
+ try {
375
+ // Resolve secrets if the plan contains any
376
+ let resolvedPlan = plan;
377
+ if (planHasSecrets(plan)) {
378
+ if (!options.secretRegistry) {
379
+ throw new SecretResolutionError(
380
+ "Plan contains secret references but no secret registry was provided",
381
+ { provider: "unknown", ref: "unknown" },
382
+ );
383
+ }
384
+
385
+ executionContext.emit({
386
+ type: "NODE_START",
387
+ nodeId: "__SECRETS__",
388
+ nodeType: "endpoint", // Using endpoint as closest match
389
+ });
390
+
391
+ try {
392
+ resolvedPlan = await resolveSecretsInPlan(plan, options.secretRegistry);
393
+
394
+ executionContext.emit({
395
+ type: "NODE_END",
396
+ nodeId: "__SECRETS__",
397
+ nodeType: "endpoint",
398
+ success: true,
399
+ duration_ms: Date.now() - startTime,
400
+ });
401
+ } catch (error) {
402
+ executionContext.emit({
403
+ type: "NODE_END",
404
+ nodeId: "__SECRETS__",
405
+ nodeType: "endpoint",
406
+ success: false,
407
+ duration_ms: Date.now() - startTime,
408
+ error: error instanceof Error ? error.message : String(error),
409
+ });
410
+ throw error;
411
+ }
412
+ }
413
+
414
+ // Emit PLAN_START event
415
+ executionContext.emit({
416
+ type: "PLAN_START",
417
+ planName: resolvedPlan.name,
418
+ planVersion: resolvedPlan.version,
419
+ nodeCount: resolvedPlan.nodes.length,
420
+ edgeCount: resolvedPlan.edges.length,
421
+ });
422
+
423
+ // Build execution graph (state-based)
424
+ const graph = buildGraph(resolvedPlan, options, executionContext);
425
+
426
+ // Compile and run the state graph
427
+ const app = graph.compile(START, END);
428
+ const graphResult = await app.run();
429
+
430
+ // Extract final state - the output is the ExecutionState
431
+ if (!graphResult.isOk) {
432
+ const errorMessage = graphResult.error.message;
433
+ executionContext.emitError(graphResult.error, "graph_execution");
434
+
435
+ const finalResults = graphResult.output?.results || [];
436
+ const finalErrors = graphResult.output?.errors || [errorMessage];
437
+
438
+ // Emit PLAN_END event
439
+ executionContext.emit({
440
+ type: "PLAN_END",
441
+ success: false,
442
+ totalDuration_ms: Date.now() - startTime,
443
+ nodeResultCount: finalResults.length,
444
+ errorCount: finalErrors.length,
445
+ errors: finalErrors,
446
+ });
447
+
448
+ // Flush events before returning
449
+ await options.eventEmitter?.flush?.();
450
+
451
+ return {
452
+ success: false,
453
+ results: finalResults,
454
+ errors: finalErrors,
455
+ totalDuration_ms: Date.now() - startTime,
456
+ };
457
+ }
458
+
459
+ const finalState = graphResult.output;
460
+ const success = finalState.errors.length === 0;
461
+
462
+ // Emit PLAN_END event
463
+ executionContext.emit({
464
+ type: "PLAN_END",
465
+ success,
466
+ totalDuration_ms: Date.now() - startTime,
467
+ nodeResultCount: finalState.results.length,
468
+ errorCount: finalState.errors.length,
469
+ errors: finalState.errors,
470
+ });
471
+
472
+ // Flush events before returning
473
+ await options.eventEmitter?.flush?.();
474
+
475
+ return {
476
+ success,
477
+ results: finalState.results,
478
+ errors: finalState.errors,
479
+ totalDuration_ms: Date.now() - startTime,
480
+ };
481
+ } catch (error: unknown) {
482
+ // Catch any unexpected errors
483
+ executionContext.emitError(error, "unexpected_error");
484
+
485
+ // Emit PLAN_END event
486
+ executionContext.emit({
487
+ type: "PLAN_END",
488
+ success: false,
489
+ totalDuration_ms: Date.now() - startTime,
490
+ nodeResultCount: 0,
491
+ errorCount: 1,
492
+ errors: [error instanceof Error ? error.message : String(error)],
493
+ });
494
+
495
+ // Flush events before throwing
496
+ await options.eventEmitter?.flush?.();
497
+
498
+ throw error;
499
+ }
500
+ }
501
+
502
+ async function executeEndpoint(
503
+ nodeId: string,
504
+ endpoint: Endpoint,
505
+ options: ExecutionOptions,
506
+ context: ExecutionContext,
507
+ ): Promise<EndpointResult> {
508
+ const startTime = Date.now();
509
+
510
+ // Only JSON response format is currently supported
511
+ if (endpoint.response_format !== ResponseFormat.JSON) {
512
+ throw new Error(
513
+ `Unsupported response format: ${ResponseFormat[endpoint.response_format]}. Only JSON is currently supported.`,
514
+ );
515
+ }
516
+
517
+ const baseUrl = await options.targetResolver(endpoint.base.key);
518
+ if (!baseUrl) {
519
+ throw new Error(
520
+ `Failed to resolve target "${endpoint.base.key}". Target not found in runner configuration.`,
521
+ );
522
+ }
523
+
524
+ const url = `${baseUrl}${endpoint.path}`;
525
+
526
+ // TODO: Add retry configuration from plan (node-level or plan-level)
527
+ // For now, we always attempt once (attempt: 1)
528
+ const attempt = 1;
529
+
530
+ // After secret resolution, headers are guaranteed to be plain strings
531
+ // Cast is safe because resolveSecretsInPlan substitutes all SecretRefs
532
+ const resolvedHeaders = endpoint.headers as
533
+ | Record<string, string>
534
+ | undefined;
535
+
536
+ // Emit HTTP_REQUEST event before making the call
537
+ context.emit({
538
+ type: "HTTP_REQUEST",
539
+ nodeId,
540
+ attempt,
541
+ method: httpMethodToString(endpoint.method),
542
+ url,
543
+ headers: resolvedHeaders,
544
+ hasBody: endpoint.body !== undefined,
545
+ });
546
+
547
+ try {
548
+ const response = await options.httpClient.request({
549
+ method: httpMethodToString(endpoint.method),
550
+ url,
551
+ headers: resolvedHeaders,
552
+ body: endpoint.body,
553
+ timeout: options.timeout || 30000,
554
+ });
555
+
556
+ const duration_ms = Date.now() - startTime;
557
+
558
+ // Emit HTTP_RESPONSE event after receiving response
559
+ context.emit({
560
+ type: "HTTP_RESPONSE",
561
+ nodeId,
562
+ attempt,
563
+ status: response.status,
564
+ statusText: response.statusText,
565
+ duration_ms,
566
+ hasBody: response.data !== undefined,
567
+ });
568
+
569
+ // Parse JSON response if it's a string, otherwise use as-is
570
+ const parsedResponse: JSONValue =
571
+ typeof response.data === "string"
572
+ ? JSON.parse(response.data)
573
+ : response.data;
574
+
575
+ return {
576
+ success: true,
577
+ response: parsedResponse,
578
+ headers: response.headers || {},
579
+ status: response.status,
580
+ duration_ms,
581
+ };
582
+ } catch (error: unknown) {
583
+ const duration_ms = Date.now() - startTime;
584
+ const errorMessage = error instanceof Error ? error.message : String(error);
585
+
586
+ // Emit failed HTTP_RESPONSE event
587
+ context.emit({
588
+ type: "HTTP_RESPONSE",
589
+ nodeId,
590
+ attempt,
591
+ status: 0,
592
+ statusText: "Error",
593
+ duration_ms,
594
+ hasBody: false,
595
+ });
596
+
597
+ return {
598
+ success: false,
599
+ error: errorMessage,
600
+ duration_ms,
601
+ };
602
+ }
603
+ }
604
+
605
+ async function executeWait(
606
+ nodeId: string,
607
+ waitNode: Wait,
608
+ context: ExecutionContext,
609
+ ): Promise<WaitResult> {
610
+ const startTime = Date.now();
611
+
612
+ context.emit({
613
+ type: "WAIT_START",
614
+ nodeId,
615
+ duration_ms: waitNode.duration_ms,
616
+ });
617
+
618
+ await new Promise((resolve) => setTimeout(resolve, waitNode.duration_ms));
619
+ return {
620
+ success: true,
621
+ duration_ms: Date.now() - startTime,
622
+ };
623
+ }
624
+
625
+ /**
626
+ * Extract value from response data using JSONPath
627
+ */
628
+ function extractValue(
629
+ responses: Record<string, NodeResponseData>,
630
+ nodeId: string,
631
+ accessor: "body" | "headers" | "status",
632
+ path: string[],
633
+ ): unknown {
634
+ const nodeData = responses[nodeId];
635
+ if (!nodeData) {
636
+ throw new Error(`No response data found for node: ${nodeId}`);
637
+ }
638
+
639
+ let value: unknown;
640
+ switch (accessor) {
641
+ case "body":
642
+ value = nodeData.body;
643
+ break;
644
+ case "headers":
645
+ value = nodeData.headers;
646
+ break;
647
+ case "status":
648
+ value = nodeData.status;
649
+ break;
650
+ }
651
+
652
+ // Navigate JSONPath
653
+ for (const segment of path) {
654
+ if (value === null || value === undefined) {
655
+ return undefined;
656
+ }
657
+ if (
658
+ typeof value === "object" &&
659
+ segment in (value as Record<string, unknown>)
660
+ ) {
661
+ value = (value as Record<string, unknown>)[segment];
662
+ } else {
663
+ return undefined;
664
+ }
665
+ }
666
+
667
+ return value;
668
+ }
669
+
670
+ function evaluateAssertion(
671
+ assertion: Assertion,
672
+ responses: Record<string, NodeResponseData>,
673
+ ): { passed: boolean; message: string } {
674
+ switch (assertion.assertionType) {
675
+ case ResponseFormat.JSON:
676
+ return evaluateJSONAssertion(assertion, responses);
677
+ case ResponseFormat.XML:
678
+ throw new Error(`XML assertions are not supported yet`);
679
+ case ResponseFormat.TEXT:
680
+ throw new Error(`Text assertions are not supported yet`);
681
+ }
682
+ }
683
+
684
+ /**
685
+ * Evaluate a single assertion
686
+ */
687
+ function evaluateJSONAssertion(
688
+ assertion: JSONAssertion,
689
+ responses: Record<string, NodeResponseData>,
690
+ ): { passed: boolean; message: string } {
691
+ const { nodeId, accessor, path, predicate } = assertion;
692
+
693
+ const value = extractValue(responses, nodeId, accessor, path);
694
+ const pathStr = path.length > 0 ? path.join(".") : accessor;
695
+
696
+ // Check if predicate is unary or binary
697
+ if (typeof predicate === "string" || typeof predicate === "number") {
698
+ // Unary predicate (enum value)
699
+ return evaluateUnaryPredicate(
700
+ value,
701
+ predicate as UnaryPredicate,
702
+ nodeId,
703
+ accessor,
704
+ pathStr,
705
+ );
706
+ } else {
707
+ // Binary predicate (object with operator and expected)
708
+ return evaluateBinaryPredicate(value, predicate, nodeId, accessor, pathStr);
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Evaluate unary predicates
714
+ */
715
+ function evaluateUnaryPredicate(
716
+ value: unknown,
717
+ predicate: UnaryPredicate,
718
+ nodeId: string,
719
+ accessor: string,
720
+ pathStr: string,
721
+ ): { passed: boolean; message: string } {
722
+ switch (predicate) {
723
+ case UnaryPredicate.IS_NULL:
724
+ return {
725
+ passed: value === null,
726
+ message:
727
+ value === null
728
+ ? `${nodeId}.${accessor}.${pathStr} is null`
729
+ : `Expected ${nodeId}.${accessor}.${pathStr} to be null, got ${JSON.stringify(value)}`,
730
+ };
731
+
732
+ case UnaryPredicate.IS_NOT_NULL:
733
+ return {
734
+ passed: value !== null && value !== undefined,
735
+ message:
736
+ value !== null && value !== undefined
737
+ ? `${nodeId}.${accessor}.${pathStr} is not null`
738
+ : `Expected ${nodeId}.${accessor}.${pathStr} to not be null`,
739
+ };
740
+
741
+ case UnaryPredicate.IS_TRUE:
742
+ return {
743
+ passed: value === true,
744
+ message:
745
+ value === true
746
+ ? `${nodeId}.${accessor}.${pathStr} is true`
747
+ : `Expected ${nodeId}.${accessor}.${pathStr} to be true, got ${JSON.stringify(value)}`,
748
+ };
749
+
750
+ case UnaryPredicate.IS_FALSE:
751
+ return {
752
+ passed: value === false,
753
+ message:
754
+ value === false
755
+ ? `${nodeId}.${accessor}.${pathStr} is false`
756
+ : `Expected ${nodeId}.${accessor}.${pathStr} to be false, got ${JSON.stringify(value)}`,
757
+ };
758
+
759
+ case UnaryPredicate.IS_EMPTY: {
760
+ const isEmpty =
761
+ value === "" ||
762
+ (Array.isArray(value) && value.length === 0) ||
763
+ (typeof value === "object" &&
764
+ value !== null &&
765
+ Object.keys(value).length === 0);
766
+ return {
767
+ passed: isEmpty,
768
+ message: isEmpty
769
+ ? `${nodeId}.${accessor}.${pathStr} is empty`
770
+ : `Expected ${nodeId}.${accessor}.${pathStr} to be empty, got ${JSON.stringify(value)}`,
771
+ };
772
+ }
773
+
774
+ case UnaryPredicate.IS_NOT_EMPTY: {
775
+ const isNotEmpty =
776
+ value !== "" &&
777
+ !(Array.isArray(value) && value.length === 0) &&
778
+ !(
779
+ typeof value === "object" &&
780
+ value !== null &&
781
+ Object.keys(value).length === 0
782
+ );
783
+ return {
784
+ passed: isNotEmpty,
785
+ message: isNotEmpty
786
+ ? `${nodeId}.${accessor}.${pathStr} is not empty`
787
+ : `Expected ${nodeId}.${accessor}.${pathStr} to not be empty`,
788
+ };
789
+ }
790
+
791
+ default:
792
+ throw new Error(`Unknown unary predicate: ${predicate}`);
793
+ }
794
+ }
795
+
796
+ /**
797
+ * Evaluate binary predicates
798
+ */
799
+ function evaluateBinaryPredicate(
800
+ value: unknown,
801
+ predicate: { operator: BinaryPredicateOperator; expected: unknown },
802
+ nodeId: string,
803
+ accessor: string,
804
+ pathStr: string,
805
+ ): { passed: boolean; message: string } {
806
+ const { operator, expected } = predicate;
807
+
808
+ switch (operator) {
809
+ case BinaryPredicateOperator.EQUAL: {
810
+ const isEqual = JSON.stringify(value) === JSON.stringify(expected);
811
+ return {
812
+ passed: isEqual,
813
+ message: isEqual
814
+ ? `${nodeId}.${accessor}.${pathStr} equals ${JSON.stringify(expected)}`
815
+ : `Expected ${nodeId}.${accessor}.${pathStr} to equal ${JSON.stringify(expected)}, got ${JSON.stringify(value)}`,
816
+ };
817
+ }
818
+
819
+ case BinaryPredicateOperator.NOT_EQUAL: {
820
+ const isNotEqual = JSON.stringify(value) !== JSON.stringify(expected);
821
+ return {
822
+ passed: isNotEqual,
823
+ message: isNotEqual
824
+ ? `${nodeId}.${accessor}.${pathStr} does not equal ${JSON.stringify(expected)}`
825
+ : `Expected ${nodeId}.${accessor}.${pathStr} to not equal ${JSON.stringify(expected)}`,
826
+ };
827
+ }
828
+
829
+ case BinaryPredicateOperator.GREATER_THAN: {
830
+ const isGT = typeof value === "number" && value > (expected as number);
831
+ return {
832
+ passed: isGT,
833
+ message: isGT
834
+ ? `${nodeId}.${accessor}.${pathStr} (${value}) > ${expected}`
835
+ : `Expected ${nodeId}.${accessor}.${pathStr} to be greater than ${expected}, got ${JSON.stringify(value)}`,
836
+ };
837
+ }
838
+
839
+ case BinaryPredicateOperator.LESS_THAN: {
840
+ const isLT = typeof value === "number" && value < (expected as number);
841
+ return {
842
+ passed: isLT,
843
+ message: isLT
844
+ ? `${nodeId}.${accessor}.${pathStr} (${value}) < ${expected}`
845
+ : `Expected ${nodeId}.${accessor}.${pathStr} to be less than ${expected}, got ${JSON.stringify(value)}`,
846
+ };
847
+ }
848
+
849
+ case BinaryPredicateOperator.GREATER_THAN_OR_EQUAL: {
850
+ const isGTE = typeof value === "number" && value >= (expected as number);
851
+ return {
852
+ passed: isGTE,
853
+ message: isGTE
854
+ ? `${nodeId}.${accessor}.${pathStr} (${value}) >= ${expected}`
855
+ : `Expected ${nodeId}.${accessor}.${pathStr} to be >= ${expected}, got ${JSON.stringify(value)}`,
856
+ };
857
+ }
858
+
859
+ case BinaryPredicateOperator.LESS_THAN_OR_EQUAL: {
860
+ const isLTE = typeof value === "number" && value <= (expected as number);
861
+ return {
862
+ passed: isLTE,
863
+ message: isLTE
864
+ ? `${nodeId}.${accessor}.${pathStr} (${value}) <= ${expected}`
865
+ : `Expected ${nodeId}.${accessor}.${pathStr} to be <= ${expected}, got ${JSON.stringify(value)}`,
866
+ };
867
+ }
868
+
869
+ case BinaryPredicateOperator.CONTAINS: {
870
+ const contains =
871
+ typeof value === "string" && value.includes(expected as string);
872
+ return {
873
+ passed: contains,
874
+ message: contains
875
+ ? `${nodeId}.${accessor}.${pathStr} contains "${expected}"`
876
+ : `Expected ${nodeId}.${accessor}.${pathStr} to contain "${expected}", got "${value}"`,
877
+ };
878
+ }
879
+
880
+ case BinaryPredicateOperator.NOT_CONTAINS: {
881
+ const notContains =
882
+ typeof value === "string" && !value.includes(expected as string);
883
+ return {
884
+ passed: notContains,
885
+ message: notContains
886
+ ? `${nodeId}.${accessor}.${pathStr} does not contain "${expected}"`
887
+ : `Expected ${nodeId}.${accessor}.${pathStr} to not contain "${expected}", got "${value}"`,
888
+ };
889
+ }
890
+
891
+ case BinaryPredicateOperator.STARTS_WITH: {
892
+ const startsWith =
893
+ typeof value === "string" && value.startsWith(expected as string);
894
+ return {
895
+ passed: startsWith,
896
+ message: startsWith
897
+ ? `${nodeId}.${accessor}.${pathStr} starts with "${expected}"`
898
+ : `Expected ${nodeId}.${accessor}.${pathStr} to start with "${expected}", got "${value}"`,
899
+ };
900
+ }
901
+
902
+ case BinaryPredicateOperator.NOT_STARTS_WITH: {
903
+ const notStartsWith =
904
+ typeof value === "string" && !value.startsWith(expected as string);
905
+ return {
906
+ passed: notStartsWith,
907
+ message: notStartsWith
908
+ ? `${nodeId}.${accessor}.${pathStr} does not start with "${expected}"`
909
+ : `Expected ${nodeId}.${accessor}.${pathStr} to not start with "${expected}", got "${value}"`,
910
+ };
911
+ }
912
+
913
+ case BinaryPredicateOperator.ENDS_WITH: {
914
+ const endsWith =
915
+ typeof value === "string" && value.endsWith(expected as string);
916
+ return {
917
+ passed: endsWith,
918
+ message: endsWith
919
+ ? `${nodeId}.${accessor}.${pathStr} ends with "${expected}"`
920
+ : `Expected ${nodeId}.${accessor}.${pathStr} to end with "${expected}", got "${value}"`,
921
+ };
922
+ }
923
+
924
+ case BinaryPredicateOperator.NOT_ENDS_WITH: {
925
+ const notEndsWith =
926
+ typeof value === "string" && !value.endsWith(expected as string);
927
+ return {
928
+ passed: notEndsWith,
929
+ message: notEndsWith
930
+ ? `${nodeId}.${accessor}.${pathStr} does not end with "${expected}"`
931
+ : `Expected ${nodeId}.${accessor}.${pathStr} to not end with "${expected}", got "${value}"`,
932
+ };
933
+ }
934
+
935
+ default:
936
+ throw new Error(`Unknown binary predicate operator: ${operator}`);
937
+ }
938
+ }
939
+
940
+ async function executeAssertions(
941
+ nodeId: string,
942
+ assertionNode: Assertions,
943
+ responses: Record<string, NodeResponseData>,
944
+ context: ExecutionContext,
945
+ ): Promise<NodeResult> {
946
+ const startTime = Date.now();
947
+ const errors: string[] = [];
948
+
949
+ for (let i = 0; i < assertionNode.assertions.length; i++) {
950
+ const assertion = assertionNode.assertions[i];
951
+
952
+ try {
953
+ const result = evaluateAssertion(assertion, responses);
954
+
955
+ context.emit({
956
+ type: "ASSERTION_RESULT",
957
+ nodeId,
958
+ assertionIndex: i,
959
+ passed: result.passed,
960
+ message: result.message,
961
+ });
962
+
963
+ if (!result.passed) {
964
+ errors.push(result.message);
965
+ }
966
+ } catch (error: unknown) {
967
+ const message = error instanceof Error ? error.message : String(error);
968
+ errors.push(`Assertion ${i} failed: ${message}`);
969
+
970
+ context.emit({
971
+ type: "ASSERTION_RESULT",
972
+ nodeId,
973
+ assertionIndex: i,
974
+ passed: false,
975
+ message,
976
+ });
977
+ }
978
+ }
979
+
980
+ return {
981
+ nodeId,
982
+ success: errors.length === 0,
983
+ error: errors.length > 0 ? errors.join("; ") : undefined,
984
+ duration_ms: Date.now() - startTime,
985
+ };
986
+ }