@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,1674 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { executePlanV1 } from "./executor.js";
3
+ import { StubAdapter } from "./adapters/stub.js";
4
+ import { NodeType, HttpMethod, ResponseFormat } from "griffin/schema";
5
+ import { TestPlanV1 } from "griffin/types";
6
+ import { START, END, type ExecutionOptions } from "./types.js";
7
+ import { LocalEventEmitter, type ExecutionEvent } from "./events";
8
+
9
+ function stubTargetResolver(key: string): Promise<string | undefined> {
10
+ return Promise.resolve(`https://${key}.example.com`);
11
+ }
12
+
13
+ describe("executePlanV1", () => {
14
+ let stubClient: StubAdapter;
15
+ let options: ExecutionOptions;
16
+
17
+ beforeEach(() => {
18
+ stubClient = new StubAdapter();
19
+ options = {
20
+ mode: "local",
21
+ httpClient: stubClient,
22
+ timeout: 5000,
23
+ targetResolver: stubTargetResolver,
24
+ };
25
+ });
26
+
27
+ describe("Single Endpoint Execution", () => {
28
+ it("should execute a simple GET request successfully", async () => {
29
+ const plan: TestPlanV1 = {
30
+ id: "test-plan-1",
31
+ name: "Simple GET Test",
32
+ environment: "default",
33
+ version: "1.0",
34
+ nodes: [
35
+ {
36
+ id: "get-users",
37
+ type: NodeType.ENDPOINT,
38
+ method: HttpMethod.GET,
39
+ path: "/users",
40
+ base: { type: "target", key: "api" },
41
+ response_format: ResponseFormat.JSON,
42
+ },
43
+ ],
44
+ edges: [
45
+ {
46
+ from: START,
47
+ to: "get-users",
48
+ },
49
+ {
50
+ from: "get-users",
51
+ to: END,
52
+ },
53
+ ],
54
+ };
55
+
56
+ stubClient.addStub({
57
+ match: "https://api.example.com/users",
58
+ response: {
59
+ status: 200,
60
+ statusText: "OK",
61
+ data: { users: [{ id: 1, name: "Alice" }] },
62
+ },
63
+ });
64
+
65
+ const result = await executePlanV1(plan, options);
66
+
67
+ expect(result.success).toBe(true);
68
+ expect(result.errors).toHaveLength(0);
69
+ expect(result.results).toHaveLength(1);
70
+ expect(result.results[0].nodeId).toBe("get-users");
71
+ expect(result.results[0].success).toBe(true);
72
+ expect(result.results[0].response).toEqual({
73
+ users: [{ id: 1, name: "Alice" }],
74
+ });
75
+ expect(result.results[0].duration_ms).toBeGreaterThanOrEqual(0);
76
+ });
77
+
78
+ it("should execute a POST request with body and headers", async () => {
79
+ const plan: TestPlanV1 = {
80
+ id: "test-plan-2",
81
+ name: "POST Test",
82
+ version: "1.0",
83
+ environment: "default",
84
+ nodes: [
85
+ {
86
+ id: "create-user",
87
+ type: NodeType.ENDPOINT,
88
+ method: HttpMethod.POST,
89
+ base: { type: "target", key: "api" },
90
+ path: "/users",
91
+ headers: {
92
+ "Content-Type": "application/json",
93
+ Authorization: "Bearer token123",
94
+ },
95
+ body: { name: "Bob", email: "bob@example.com" },
96
+ response_format: ResponseFormat.JSON,
97
+ },
98
+ ],
99
+ edges: [
100
+ {
101
+ from: START,
102
+ to: "create-user",
103
+ },
104
+ {
105
+ from: "create-user",
106
+ to: END,
107
+ },
108
+ ],
109
+ };
110
+
111
+ stubClient.addStub({
112
+ match: (req) =>
113
+ req.url === "https://api.example.com/users" && req.method === "POST",
114
+ response: {
115
+ status: 201,
116
+ statusText: "Created",
117
+ data: { id: 2, name: "Bob", email: "bob@example.com" },
118
+ },
119
+ });
120
+
121
+ const result = await executePlanV1(plan, options);
122
+
123
+ expect(result.success).toBe(true);
124
+ expect(result.results[0].response).toEqual({
125
+ id: 2,
126
+ name: "Bob",
127
+ email: "bob@example.com",
128
+ });
129
+ });
130
+
131
+ it("should handle JSON string responses by parsing them", async () => {
132
+ const plan: TestPlanV1 = {
133
+ id: "test-plan-3",
134
+ name: "JSON String Response Test",
135
+ version: "1.0",
136
+ environment: "default",
137
+ nodes: [
138
+ {
139
+ id: "get-data",
140
+ type: NodeType.ENDPOINT,
141
+ method: HttpMethod.GET,
142
+ path: "/data",
143
+ base: { type: "target", key: "api" },
144
+ response_format: ResponseFormat.JSON,
145
+ },
146
+ ],
147
+ edges: [
148
+ {
149
+ from: START,
150
+ to: "get-data",
151
+ },
152
+ {
153
+ from: "get-data",
154
+ to: END,
155
+ },
156
+ ],
157
+ };
158
+
159
+ stubClient.addStub({
160
+ match: "https://api.example.com/data",
161
+ response: {
162
+ status: 200,
163
+ statusText: "OK",
164
+ data: '{"message":"hello"}',
165
+ },
166
+ });
167
+
168
+ const result = await executePlanV1(plan, options);
169
+
170
+ expect(result.success).toBe(true);
171
+ expect(result.results[0].response).toEqual({ message: "hello" });
172
+ });
173
+
174
+ it("should override endpoint_host with baseUrl option", async () => {
175
+ const plan: TestPlanV1 = {
176
+ id: "test-plan-4",
177
+ name: "BaseUrl Override Test",
178
+ version: "1.0",
179
+ environment: "default",
180
+ nodes: [
181
+ {
182
+ id: "get-users",
183
+ type: NodeType.ENDPOINT,
184
+ method: HttpMethod.GET,
185
+ base: { type: "target", key: "api" },
186
+ path: "/users",
187
+ response_format: ResponseFormat.JSON,
188
+ },
189
+ ],
190
+ edges: [
191
+ {
192
+ from: START,
193
+ to: "get-users",
194
+ },
195
+ {
196
+ from: "get-users",
197
+ to: END,
198
+ },
199
+ ],
200
+ };
201
+
202
+ stubClient.addStub({
203
+ match: "https://api.example.com/users",
204
+ response: {
205
+ status: 200,
206
+ statusText: "OK",
207
+ data: { users: [] },
208
+ },
209
+ });
210
+
211
+ const result = await executePlanV1(plan, options);
212
+
213
+ expect(result.success).toBe(true);
214
+ });
215
+ });
216
+
217
+ describe("Multiple HTTP Methods", () => {
218
+ it("should handle PUT requests", async () => {
219
+ const plan: TestPlanV1 = {
220
+ id: "test-plan-5",
221
+ name: "PUT Test",
222
+ version: "1.0",
223
+ environment: "default",
224
+ nodes: [
225
+ {
226
+ id: "update-user",
227
+ type: NodeType.ENDPOINT,
228
+ method: HttpMethod.PUT,
229
+ path: "/users/1",
230
+ base: { type: "target", key: "api" },
231
+ body: { name: "Updated Name" },
232
+ response_format: ResponseFormat.JSON,
233
+ },
234
+ ],
235
+ edges: [
236
+ {
237
+ from: START,
238
+ to: "update-user",
239
+ },
240
+ {
241
+ from: "update-user",
242
+ to: END,
243
+ },
244
+ ],
245
+ };
246
+
247
+ stubClient.addStub({
248
+ match: "https://api.example.com/users/1",
249
+ response: {
250
+ status: 200,
251
+ statusText: "OK",
252
+ data: { id: 1, name: "Updated Name" },
253
+ },
254
+ });
255
+
256
+ const result = await executePlanV1(plan, options);
257
+
258
+ expect(result.success).toBe(true);
259
+ expect(result.results[0].response).toEqual({
260
+ id: 1,
261
+ name: "Updated Name",
262
+ });
263
+ });
264
+
265
+ it("should handle DELETE requests", async () => {
266
+ const plan: TestPlanV1 = {
267
+ id: "test-plan-6",
268
+ name: "DELETE Test",
269
+ version: "1.0",
270
+ environment: "default",
271
+ nodes: [
272
+ {
273
+ id: "delete-user",
274
+ type: NodeType.ENDPOINT,
275
+ method: HttpMethod.DELETE,
276
+ path: "/users/1",
277
+ base: { type: "target", key: "api" },
278
+ response_format: ResponseFormat.JSON,
279
+ },
280
+ ],
281
+ edges: [
282
+ {
283
+ from: START,
284
+ to: "delete-user",
285
+ },
286
+ {
287
+ from: "delete-user",
288
+ to: END,
289
+ },
290
+ ],
291
+ };
292
+
293
+ stubClient.addStub({
294
+ match: "https://api.example.com/users/1",
295
+ response: {
296
+ status: 204,
297
+ statusText: "No Content",
298
+ data: null,
299
+ },
300
+ });
301
+
302
+ const result = await executePlanV1(plan, options);
303
+
304
+ expect(result.success).toBe(true);
305
+ expect(result.results[0].response).toBeNull();
306
+ });
307
+
308
+ it("should handle PATCH requests", async () => {
309
+ const plan: TestPlanV1 = {
310
+ id: "test-plan-7",
311
+ name: "PATCH Test",
312
+ version: "1.0",
313
+ environment: "default",
314
+ nodes: [
315
+ {
316
+ id: "patch-user",
317
+ type: NodeType.ENDPOINT,
318
+ method: HttpMethod.PATCH,
319
+ path: "/users/1",
320
+ base: { type: "target", key: "api" },
321
+ body: { email: "newemail@example.com" },
322
+ response_format: ResponseFormat.JSON,
323
+ },
324
+ ],
325
+ edges: [
326
+ {
327
+ from: START,
328
+ to: "patch-user",
329
+ },
330
+ {
331
+ from: "patch-user",
332
+ to: END,
333
+ },
334
+ ],
335
+ };
336
+
337
+ stubClient.addStub({
338
+ match: "https://api.example.com/users/1",
339
+ response: {
340
+ status: 200,
341
+ statusText: "OK",
342
+ data: { id: 1, email: "newemail@example.com" },
343
+ },
344
+ });
345
+
346
+ const result = await executePlanV1(plan, options);
347
+
348
+ expect(result.success).toBe(true);
349
+ });
350
+ });
351
+
352
+ describe("Sequential Execution", () => {
353
+ it("should execute two endpoints in sequence", async () => {
354
+ const plan: TestPlanV1 = {
355
+ id: "test-plan-8",
356
+ name: "Sequential Test",
357
+ version: "1.0",
358
+ environment: "default",
359
+ nodes: [
360
+ {
361
+ id: "first",
362
+ type: NodeType.ENDPOINT,
363
+ method: HttpMethod.GET,
364
+ path: "/first",
365
+ base: { type: "target", key: "api" },
366
+ response_format: ResponseFormat.JSON,
367
+ },
368
+ {
369
+ id: "second",
370
+ type: NodeType.ENDPOINT,
371
+ method: HttpMethod.GET,
372
+ path: "/second",
373
+ base: { type: "target", key: "api" },
374
+ response_format: ResponseFormat.JSON,
375
+ },
376
+ ],
377
+ edges: [
378
+ {
379
+ from: START,
380
+ to: "first",
381
+ },
382
+ {
383
+ from: "first",
384
+ to: "second",
385
+ },
386
+ {
387
+ from: "second",
388
+ to: END,
389
+ },
390
+ ],
391
+ };
392
+
393
+ stubClient
394
+ .addStub({
395
+ match: "https://api.example.com/first",
396
+ response: {
397
+ status: 200,
398
+ statusText: "OK",
399
+ data: { step: 1 },
400
+ },
401
+ })
402
+ .addStub({
403
+ match: "https://api.example.com/second",
404
+ response: {
405
+ status: 200,
406
+ statusText: "OK",
407
+ data: { step: 2 },
408
+ },
409
+ });
410
+
411
+ const result = await executePlanV1(plan, options);
412
+
413
+ expect(result.success).toBe(true);
414
+ expect(result.results).toHaveLength(2);
415
+ expect(result.results[0].nodeId).toBe("first");
416
+ expect(result.results[1].nodeId).toBe("second");
417
+ expect(result.results[0].response).toEqual({ step: 1 });
418
+ expect(result.results[1].response).toEqual({ step: 2 });
419
+ });
420
+
421
+ it("should execute a linear chain of multiple endpoints", async () => {
422
+ const plan: TestPlanV1 = {
423
+ id: "test-plan-9",
424
+ name: "Multi-Step Linear Test",
425
+ version: "1.0",
426
+ environment: "default",
427
+ nodes: [
428
+ {
429
+ id: "step1",
430
+ type: NodeType.ENDPOINT,
431
+ method: HttpMethod.GET,
432
+ path: "/step1",
433
+ base: { type: "target", key: "api" },
434
+ response_format: ResponseFormat.JSON,
435
+ },
436
+ {
437
+ id: "step2",
438
+ type: NodeType.ENDPOINT,
439
+ method: HttpMethod.GET,
440
+ path: "/step2",
441
+ base: { type: "target", key: "api" },
442
+ response_format: ResponseFormat.JSON,
443
+ },
444
+ {
445
+ id: "step3",
446
+ type: NodeType.ENDPOINT,
447
+ method: HttpMethod.GET,
448
+ path: "/step3",
449
+ base: { type: "target", key: "api" },
450
+ response_format: ResponseFormat.JSON,
451
+ },
452
+ {
453
+ id: "step4",
454
+ type: NodeType.ENDPOINT,
455
+ method: HttpMethod.GET,
456
+ path: "/step4",
457
+ base: { type: "target", key: "api" },
458
+ response_format: ResponseFormat.JSON,
459
+ },
460
+ ],
461
+ edges: [
462
+ {
463
+ from: START,
464
+ to: "step1",
465
+ },
466
+ {
467
+ from: "step1",
468
+ to: "step2",
469
+ },
470
+ {
471
+ from: "step2",
472
+ to: "step3",
473
+ },
474
+ {
475
+ from: "step3",
476
+ to: "step4",
477
+ },
478
+ {
479
+ from: "step4",
480
+ to: END,
481
+ },
482
+ ],
483
+ };
484
+
485
+ stubClient
486
+ .addStub({
487
+ match: /\/step1$/,
488
+ response: { status: 200, statusText: "OK", data: { step: 1 } },
489
+ })
490
+ .addStub({
491
+ match: /\/step2$/,
492
+ response: { status: 200, statusText: "OK", data: { step: 2 } },
493
+ })
494
+ .addStub({
495
+ match: /\/step3$/,
496
+ response: { status: 200, statusText: "OK", data: { step: 3 } },
497
+ })
498
+ .addStub({
499
+ match: /\/step4$/,
500
+ response: { status: 200, statusText: "OK", data: { step: 4 } },
501
+ });
502
+
503
+ const result = await executePlanV1(plan, options);
504
+
505
+ expect(result.success).toBe(true);
506
+ expect(result.results).toHaveLength(4);
507
+ expect(result.results[0].response).toEqual({ step: 1 });
508
+ expect(result.results[1].response).toEqual({ step: 2 });
509
+ expect(result.results[2].response).toEqual({ step: 3 });
510
+ expect(result.results[3].response).toEqual({ step: 4 });
511
+ });
512
+ });
513
+
514
+ describe("Wait Node", () => {
515
+ it("should execute a wait node successfully", async () => {
516
+ const plan: TestPlanV1 = {
517
+ id: "test-plan-10",
518
+ name: "Wait Test",
519
+ version: "1.0",
520
+ environment: "default",
521
+ nodes: [
522
+ {
523
+ id: "wait-node",
524
+ type: NodeType.WAIT,
525
+ duration_ms: 100,
526
+ },
527
+ ],
528
+ edges: [
529
+ {
530
+ from: START,
531
+ to: "wait-node",
532
+ },
533
+ {
534
+ from: "wait-node",
535
+ to: END,
536
+ },
537
+ ],
538
+ };
539
+
540
+ const startTime = Date.now();
541
+ const result = await executePlanV1(plan, options);
542
+ const endTime = Date.now();
543
+
544
+ expect(result.success).toBe(true);
545
+ expect(result.results).toHaveLength(1);
546
+ expect(result.results[0].nodeId).toBe("wait-node");
547
+ expect(result.results[0].success).toBe(true);
548
+ expect(result.results[0].duration_ms).toBeGreaterThanOrEqual(100);
549
+ expect(endTime - startTime).toBeGreaterThanOrEqual(100);
550
+ });
551
+
552
+ it("should execute endpoints with waits in between", async () => {
553
+ const plan: TestPlanV1 = {
554
+ id: "test-plan-11",
555
+ name: "Endpoint-Wait-Endpoint Test",
556
+ version: "1.0",
557
+ environment: "default",
558
+ nodes: [
559
+ {
560
+ id: "first-request",
561
+ type: NodeType.ENDPOINT,
562
+ method: HttpMethod.GET,
563
+ path: "/first",
564
+ base: { type: "target", key: "api" },
565
+ response_format: ResponseFormat.JSON,
566
+ },
567
+ {
568
+ id: "wait",
569
+ type: NodeType.WAIT,
570
+ duration_ms: 50,
571
+ },
572
+ {
573
+ id: "second-request",
574
+ type: NodeType.ENDPOINT,
575
+ method: HttpMethod.GET,
576
+ path: "/second",
577
+ base: { type: "target", key: "api" },
578
+ response_format: ResponseFormat.JSON,
579
+ },
580
+ ],
581
+ edges: [
582
+ {
583
+ from: START,
584
+ to: "first-request",
585
+ },
586
+ {
587
+ from: "first-request",
588
+ to: "wait",
589
+ },
590
+ {
591
+ from: "wait",
592
+ to: "second-request",
593
+ },
594
+ {
595
+ from: "second-request",
596
+ to: END,
597
+ },
598
+ ],
599
+ };
600
+
601
+ stubClient
602
+ .addStub({
603
+ match: /\/first$/,
604
+ response: { status: 200, statusText: "OK", data: { step: 1 } },
605
+ })
606
+ .addStub({
607
+ match: /\/second$/,
608
+ response: { status: 200, statusText: "OK", data: { step: 2 } },
609
+ });
610
+
611
+ const result = await executePlanV1(plan, options);
612
+
613
+ expect(result.success).toBe(true);
614
+ expect(result.results).toHaveLength(3);
615
+ expect(result.results[1].nodeId).toBe("wait");
616
+ expect(result.results[1].duration_ms).toBeGreaterThanOrEqual(50);
617
+ });
618
+ });
619
+
620
+ describe("Assertion Node", () => {
621
+ it("should execute an assertion node (currently no-op)", async () => {
622
+ const plan: TestPlanV1 = {
623
+ id: "test-plan-12",
624
+ name: "Assertion Test",
625
+ version: "1.0",
626
+ environment: "default",
627
+ nodes: [
628
+ {
629
+ id: "get-data",
630
+ type: NodeType.ENDPOINT,
631
+ method: HttpMethod.GET,
632
+ path: "/data",
633
+ base: { type: "target", key: "api" },
634
+ response_format: ResponseFormat.JSON,
635
+ },
636
+ {
637
+ id: "assert",
638
+ type: NodeType.ASSERTION,
639
+ assertions: [],
640
+ },
641
+ ],
642
+ edges: [
643
+ {
644
+ from: START,
645
+ to: "get-data",
646
+ },
647
+ {
648
+ from: "get-data",
649
+ to: "assert",
650
+ },
651
+ {
652
+ from: "assert",
653
+ to: END,
654
+ },
655
+ ],
656
+ };
657
+
658
+ stubClient.addStub({
659
+ match: "https://api.example.com/data",
660
+ response: {
661
+ status: 200,
662
+ statusText: "OK",
663
+ data: { value: 42 },
664
+ },
665
+ });
666
+
667
+ const result = await executePlanV1(plan, options);
668
+
669
+ expect(result.success).toBe(true);
670
+ expect(result.results).toHaveLength(2);
671
+ expect(result.results[1].nodeId).toBe("assert");
672
+ expect(result.results[1].success).toBe(true);
673
+ });
674
+ });
675
+
676
+ describe("Error Handling", () => {
677
+ it("should handle failed HTTP requests gracefully", async () => {
678
+ const plan: TestPlanV1 = {
679
+ id: "test-plan-13",
680
+ name: "Failed Request Test",
681
+ version: "1.0",
682
+ environment: "default",
683
+ nodes: [
684
+ {
685
+ id: "failing-request",
686
+ type: NodeType.ENDPOINT,
687
+ method: HttpMethod.GET,
688
+ path: "/fail",
689
+ base: { type: "target", key: "api" },
690
+ response_format: ResponseFormat.JSON,
691
+ },
692
+ ],
693
+ edges: [
694
+ {
695
+ from: START,
696
+ to: "failing-request",
697
+ },
698
+ {
699
+ from: "failing-request",
700
+ to: END,
701
+ },
702
+ ],
703
+ };
704
+
705
+ // Don't add a stub - this will cause the request to fail
706
+
707
+ const result = await executePlanV1(plan, options);
708
+
709
+ expect(result.success).toBe(false);
710
+ expect(result.errors).toHaveLength(1);
711
+ expect(result.errors[0]).toContain("failing-request");
712
+ expect(result.errors[0]).toContain("No stub matched request");
713
+ expect(result.results[0].success).toBe(false);
714
+ expect(result.results[0].error).toBeDefined();
715
+ });
716
+
717
+ it("should handle disconnected nodes gracefully", async () => {
718
+ const plan: TestPlanV1 = {
719
+ id: "test-plan-14",
720
+ name: "Disconnected Nodes Test",
721
+ version: "1.0",
722
+ environment: "default",
723
+ nodes: [
724
+ {
725
+ id: "node1",
726
+ type: NodeType.ENDPOINT,
727
+ method: HttpMethod.GET,
728
+ path: "/path",
729
+ base: { type: "target", key: "api" },
730
+ response_format: ResponseFormat.JSON,
731
+ },
732
+ {
733
+ id: "node2",
734
+ type: NodeType.ENDPOINT,
735
+ method: HttpMethod.GET,
736
+ path: "/path",
737
+ base: { type: "target", key: "api" },
738
+ response_format: ResponseFormat.JSON,
739
+ },
740
+ ],
741
+ edges: [
742
+ { from: START, to: END }, // Direct path that skips nodes
743
+ { from: "node1", to: "node2" }, // Circular - disconnected from main flow
744
+ { from: "node2", to: "node1" },
745
+ ],
746
+ };
747
+
748
+ stubClient.addStub({
749
+ match: "https://api.example.com/path",
750
+ response: {
751
+ status: 200,
752
+ statusText: "OK",
753
+ data: {},
754
+ },
755
+ });
756
+
757
+ const result = await executePlanV1(plan, options);
758
+
759
+ // Graph can execute but disconnected nodes are not executed
760
+ expect(result.success).toBe(true);
761
+ expect(result.results).toHaveLength(0); // No nodes executed
762
+ });
763
+
764
+ it("should continue execution after a failed node", async () => {
765
+ const plan: TestPlanV1 = {
766
+ id: "test-plan-15",
767
+ name: "Continue After Failure Test",
768
+ version: "1.0",
769
+ environment: "default",
770
+ nodes: [
771
+ {
772
+ id: "success-node",
773
+ type: NodeType.ENDPOINT,
774
+ method: HttpMethod.GET,
775
+ path: "/success",
776
+ base: { type: "target", key: "api" },
777
+ response_format: ResponseFormat.JSON,
778
+ },
779
+ {
780
+ id: "fail-node",
781
+ type: NodeType.ENDPOINT,
782
+ method: HttpMethod.GET,
783
+ path: "/fail",
784
+ base: { type: "target", key: "api" },
785
+ response_format: ResponseFormat.JSON,
786
+ },
787
+ ],
788
+ edges: [
789
+ {
790
+ from: START,
791
+ to: "success-node",
792
+ },
793
+ {
794
+ from: "success-node",
795
+ to: "fail-node",
796
+ },
797
+ {
798
+ from: "fail-node",
799
+ to: END,
800
+ },
801
+ ],
802
+ };
803
+
804
+ stubClient.addStub({
805
+ match: /\/success$/,
806
+ response: { status: 200, statusText: "OK", data: { ok: true } },
807
+ });
808
+ // No stub for /fail - it will fail
809
+
810
+ const result = await executePlanV1(plan, options);
811
+
812
+ expect(result.success).toBe(false);
813
+ expect(result.results).toHaveLength(2);
814
+ expect(result.results[0].success).toBe(true);
815
+ expect(result.results[1].success).toBe(false);
816
+ });
817
+ });
818
+
819
+ describe("Response Storage", () => {
820
+ it("should store successful responses for downstream nodes", async () => {
821
+ const plan: TestPlanV1 = {
822
+ id: "test-plan-16",
823
+ name: "Response Storage Test",
824
+ version: "1.0",
825
+ environment: "default",
826
+ nodes: [
827
+ {
828
+ id: "get-user-id",
829
+ type: NodeType.ENDPOINT,
830
+ method: HttpMethod.GET,
831
+ path: "/user",
832
+ base: { type: "target", key: "api" },
833
+ response_format: ResponseFormat.JSON,
834
+ },
835
+ {
836
+ id: "get-profile",
837
+ type: NodeType.ENDPOINT,
838
+ method: HttpMethod.GET,
839
+ path: "/profile",
840
+ base: { type: "target", key: "api" },
841
+ response_format: ResponseFormat.JSON,
842
+ },
843
+ ],
844
+ edges: [
845
+ {
846
+ from: START,
847
+ to: "get-user-id",
848
+ },
849
+ {
850
+ from: "get-user-id",
851
+ to: "get-profile",
852
+ },
853
+ {
854
+ from: "get-profile",
855
+ to: END,
856
+ },
857
+ ],
858
+ };
859
+
860
+ stubClient
861
+ .addStub({
862
+ match: /\/user$/,
863
+ response: {
864
+ status: 200,
865
+ statusText: "OK",
866
+ data: { userId: 123 },
867
+ },
868
+ })
869
+ .addStub({
870
+ match: /\/profile$/,
871
+ response: {
872
+ status: 200,
873
+ statusText: "OK",
874
+ data: { name: "John Doe", age: 30 },
875
+ },
876
+ });
877
+
878
+ const result = await executePlanV1(plan, options);
879
+
880
+ expect(result.success).toBe(true);
881
+ expect(result.results[0].response).toEqual({ userId: 123 });
882
+ expect(result.results[1].response).toEqual({ name: "John Doe", age: 30 });
883
+ });
884
+
885
+ it("should not store failed responses", async () => {
886
+ const plan: TestPlanV1 = {
887
+ id: "test-plan-17",
888
+ name: "Failed Response Not Stored Test",
889
+ version: "1.0",
890
+ environment: "default",
891
+ nodes: [
892
+ {
893
+ id: "failing-endpoint",
894
+ type: NodeType.ENDPOINT,
895
+ method: HttpMethod.GET,
896
+ path: "/fail",
897
+ base: { type: "target", key: "api" },
898
+ response_format: ResponseFormat.JSON,
899
+ },
900
+ ],
901
+ edges: [
902
+ {
903
+ from: START,
904
+ to: "failing-endpoint",
905
+ },
906
+ {
907
+ from: "failing-endpoint",
908
+ to: END,
909
+ },
910
+ ],
911
+ };
912
+
913
+ // No stub configured - will fail
914
+
915
+ const result = await executePlanV1(plan, options);
916
+
917
+ expect(result.success).toBe(false);
918
+ expect(result.results[0].response).toBeUndefined();
919
+ });
920
+ });
921
+
922
+ describe("Timing and Performance", () => {
923
+ it("should track total execution duration", async () => {
924
+ const plan: TestPlanV1 = {
925
+ id: "test-plan-18",
926
+ name: "Timing Test",
927
+ version: "1.0",
928
+ environment: "default",
929
+ nodes: [
930
+ {
931
+ id: "endpoint",
932
+ type: NodeType.ENDPOINT,
933
+ method: HttpMethod.GET,
934
+ path: "/data",
935
+ base: { type: "target", key: "api" },
936
+ response_format: ResponseFormat.JSON,
937
+ },
938
+ ],
939
+ edges: [
940
+ {
941
+ from: START,
942
+ to: "endpoint",
943
+ },
944
+ {
945
+ from: "endpoint",
946
+ to: END,
947
+ },
948
+ ],
949
+ };
950
+
951
+ stubClient.addStub({
952
+ match: "https://api.example.com/data",
953
+ response: {
954
+ status: 200,
955
+ statusText: "OK",
956
+ data: { test: true },
957
+ },
958
+ });
959
+
960
+ const result = await executePlanV1(plan, options);
961
+
962
+ expect(result.totalDuration_ms).toBeGreaterThanOrEqual(0);
963
+ expect(result.totalDuration_ms).toBeGreaterThanOrEqual(
964
+ result.results[0].duration_ms,
965
+ );
966
+ });
967
+
968
+ it("should track individual node durations", async () => {
969
+ const plan: TestPlanV1 = {
970
+ id: "test-plan-19",
971
+ name: "Node Duration Test",
972
+ version: "1.0",
973
+ environment: "default",
974
+ nodes: [
975
+ {
976
+ id: "node1",
977
+ type: NodeType.ENDPOINT,
978
+ method: HttpMethod.GET,
979
+ path: "/1",
980
+ base: { type: "target", key: "api" },
981
+ response_format: ResponseFormat.JSON,
982
+ },
983
+ {
984
+ id: "wait",
985
+ type: NodeType.WAIT,
986
+ duration_ms: 50,
987
+ },
988
+ {
989
+ id: "node2",
990
+ type: NodeType.ENDPOINT,
991
+ method: HttpMethod.GET,
992
+ path: "/2",
993
+ base: { type: "target", key: "api" },
994
+ response_format: ResponseFormat.JSON,
995
+ },
996
+ ],
997
+ edges: [
998
+ {
999
+ from: START,
1000
+ to: "node1",
1001
+ },
1002
+ {
1003
+ from: "node1",
1004
+ to: "wait",
1005
+ },
1006
+ {
1007
+ from: "wait",
1008
+ to: "node2",
1009
+ },
1010
+ {
1011
+ from: "node2",
1012
+ to: END,
1013
+ },
1014
+ ],
1015
+ };
1016
+
1017
+ stubClient
1018
+ .addStub({
1019
+ match: /\/1$/,
1020
+ response: { status: 200, statusText: "OK", data: {} },
1021
+ })
1022
+ .addStub({
1023
+ match: /\/2$/,
1024
+ response: { status: 200, statusText: "OK", data: {} },
1025
+ });
1026
+
1027
+ const result = await executePlanV1(plan, options);
1028
+
1029
+ expect(result.success).toBe(true);
1030
+ result.results.forEach((nodeResult) => {
1031
+ expect(nodeResult.duration_ms).toBeGreaterThanOrEqual(0);
1032
+ });
1033
+ expect(result.results[1].duration_ms).toBeGreaterThanOrEqual(50);
1034
+ });
1035
+ });
1036
+
1037
+ describe("Edge Cases", () => {
1038
+ it("should handle empty plan (no nodes)", async () => {
1039
+ const plan: TestPlanV1 = {
1040
+ id: "test-plan-20",
1041
+ name: "Empty Plan Test",
1042
+ version: "1.0",
1043
+ environment: "default",
1044
+ nodes: [],
1045
+ edges: [
1046
+ {
1047
+ from: START,
1048
+ to: END,
1049
+ },
1050
+ ],
1051
+ };
1052
+
1053
+ const result = await executePlanV1(plan, options);
1054
+
1055
+ // Empty plan with just START->END should succeed with no results
1056
+ expect(result.success).toBe(true);
1057
+ expect(result.results).toHaveLength(0);
1058
+ expect(result.errors).toHaveLength(0);
1059
+ });
1060
+
1061
+ it("should handle single node with no edges", async () => {
1062
+ const plan: TestPlanV1 = {
1063
+ id: "test-plan-21",
1064
+ name: "Single Node Test",
1065
+ version: "1.0",
1066
+ environment: "default",
1067
+ nodes: [
1068
+ {
1069
+ id: "only-node",
1070
+ type: NodeType.ENDPOINT,
1071
+ method: HttpMethod.GET,
1072
+ path: "/single",
1073
+ base: { type: "target", key: "api" },
1074
+ response_format: ResponseFormat.JSON,
1075
+ },
1076
+ ],
1077
+ edges: [
1078
+ {
1079
+ from: START,
1080
+ to: "only-node",
1081
+ },
1082
+ {
1083
+ from: "only-node",
1084
+ to: END,
1085
+ },
1086
+ ],
1087
+ };
1088
+
1089
+ stubClient.addStub({
1090
+ match: "https://api.example.com/single",
1091
+ response: {
1092
+ status: 200,
1093
+ statusText: "OK",
1094
+ data: { alone: true },
1095
+ },
1096
+ });
1097
+
1098
+ const result = await executePlanV1(plan, options);
1099
+
1100
+ expect(result.success).toBe(true);
1101
+ expect(result.results).toHaveLength(1);
1102
+ });
1103
+
1104
+ it("should handle complex response data types", async () => {
1105
+ const plan: TestPlanV1 = {
1106
+ id: "test-plan-22",
1107
+ name: "Complex Data Test",
1108
+ version: "1.0",
1109
+ environment: "default",
1110
+ nodes: [
1111
+ {
1112
+ id: "complex",
1113
+ type: NodeType.ENDPOINT,
1114
+ method: HttpMethod.GET,
1115
+ path: "/complex",
1116
+ base: { type: "target", key: "api" },
1117
+ response_format: ResponseFormat.JSON,
1118
+ },
1119
+ ],
1120
+ edges: [
1121
+ {
1122
+ from: START,
1123
+ to: "complex",
1124
+ },
1125
+ {
1126
+ from: "complex",
1127
+ to: END,
1128
+ },
1129
+ ],
1130
+ };
1131
+
1132
+ const complexData = {
1133
+ string: "test",
1134
+ number: 42,
1135
+ boolean: true,
1136
+ null: null,
1137
+ array: [1, 2, 3],
1138
+ nested: {
1139
+ deep: {
1140
+ value: "nested",
1141
+ },
1142
+ },
1143
+ };
1144
+
1145
+ stubClient.addStub({
1146
+ match: "https://api.example.com/complex",
1147
+ response: {
1148
+ status: 200,
1149
+ statusText: "OK",
1150
+ data: complexData,
1151
+ },
1152
+ });
1153
+
1154
+ const result = await executePlanV1(plan, options);
1155
+
1156
+ expect(result.success).toBe(true);
1157
+ expect(result.results[0].response).toEqual(complexData);
1158
+ });
1159
+ });
1160
+
1161
+ describe("Event Emission", () => {
1162
+ let emitter: LocalEventEmitter;
1163
+ let events: ExecutionEvent[];
1164
+
1165
+ beforeEach(() => {
1166
+ emitter = new LocalEventEmitter();
1167
+ events = [];
1168
+ emitter.subscribe((event) => events.push(event));
1169
+ });
1170
+
1171
+ it("should emit PLAN_START and PLAN_END events", async () => {
1172
+ const plan: TestPlanV1 = {
1173
+ id: "event-test-1",
1174
+ name: "Event Test Plan",
1175
+ version: "1.0",
1176
+ environment: "default",
1177
+ nodes: [
1178
+ {
1179
+ id: "node-1",
1180
+ type: NodeType.ENDPOINT,
1181
+ method: HttpMethod.GET,
1182
+ path: "/test",
1183
+ base: { type: "target", key: "api" },
1184
+ response_format: ResponseFormat.JSON,
1185
+ },
1186
+ ],
1187
+ edges: [
1188
+ {
1189
+ from: START,
1190
+ to: "node-1",
1191
+ },
1192
+ {
1193
+ from: "node-1",
1194
+ to: END,
1195
+ },
1196
+ ],
1197
+ };
1198
+
1199
+ stubClient.addStub({
1200
+ match: "https://api.example.com/test",
1201
+ response: { status: 200, statusText: "OK", data: { ok: true } },
1202
+ });
1203
+
1204
+ await executePlanV1(plan, {
1205
+ ...options,
1206
+ eventEmitter: emitter,
1207
+ });
1208
+
1209
+ const planStartEvents = events.filter((e) => e.type === "PLAN_START");
1210
+ const planEndEvents = events.filter((e) => e.type === "PLAN_END");
1211
+
1212
+ expect(planStartEvents).toHaveLength(1);
1213
+ expect(planEndEvents).toHaveLength(1);
1214
+
1215
+ const planStart = planStartEvents[0];
1216
+ expect(planStart).toMatchObject({
1217
+ type: "PLAN_START",
1218
+ planId: "event-test-1",
1219
+ planName: "Event Test Plan",
1220
+ planVersion: "1.0",
1221
+ nodeCount: 1,
1222
+ edgeCount: 2,
1223
+ });
1224
+
1225
+ const planEnd = planEndEvents[0];
1226
+ expect(planEnd).toMatchObject({
1227
+ type: "PLAN_END",
1228
+ success: true,
1229
+ nodeResultCount: 1,
1230
+ errorCount: 0,
1231
+ });
1232
+ });
1233
+
1234
+ it("should emit NODE_START and NODE_END events for each node", async () => {
1235
+ const plan: TestPlanV1 = {
1236
+ id: "event-test-2",
1237
+ name: "Multi-Node Event Test",
1238
+ version: "1.0",
1239
+ environment: "default",
1240
+ nodes: [
1241
+ {
1242
+ id: "endpoint-1",
1243
+ type: NodeType.ENDPOINT,
1244
+ method: HttpMethod.GET,
1245
+ path: "/first",
1246
+ base: { type: "target", key: "api" },
1247
+ response_format: ResponseFormat.JSON,
1248
+ },
1249
+ {
1250
+ id: "wait-1",
1251
+ type: NodeType.WAIT,
1252
+ duration_ms: 10,
1253
+ },
1254
+ {
1255
+ id: "endpoint-2",
1256
+ type: NodeType.ENDPOINT,
1257
+ method: HttpMethod.GET,
1258
+ path: "/second",
1259
+ base: { type: "target", key: "api" },
1260
+ response_format: ResponseFormat.JSON,
1261
+ },
1262
+ ],
1263
+ edges: [
1264
+ {
1265
+ from: START,
1266
+ to: "endpoint-1",
1267
+ },
1268
+ {
1269
+ from: "endpoint-1",
1270
+ to: "wait-1",
1271
+ },
1272
+ {
1273
+ from: "wait-1",
1274
+ to: "endpoint-2",
1275
+ },
1276
+ {
1277
+ from: "endpoint-2",
1278
+ to: END,
1279
+ },
1280
+ ],
1281
+ };
1282
+
1283
+ stubClient
1284
+ .addStub({
1285
+ match: /\/first$/,
1286
+ response: { status: 200, statusText: "OK", data: { step: 1 } },
1287
+ })
1288
+ .addStub({
1289
+ match: /\/second$/,
1290
+ response: { status: 200, statusText: "OK", data: { step: 2 } },
1291
+ });
1292
+
1293
+ await executePlanV1(plan, {
1294
+ ...options,
1295
+ eventEmitter: emitter,
1296
+ });
1297
+
1298
+ const nodeStartEvents = events.filter((e) => e.type === "NODE_START");
1299
+ const nodeEndEvents = events.filter((e) => e.type === "NODE_END");
1300
+
1301
+ expect(nodeStartEvents).toHaveLength(3);
1302
+ expect(nodeEndEvents).toHaveLength(3);
1303
+
1304
+ // Check node types
1305
+ expect(nodeStartEvents[0]).toMatchObject({
1306
+ nodeId: "endpoint-1",
1307
+ nodeType: "endpoint",
1308
+ });
1309
+ expect(nodeStartEvents[1]).toMatchObject({
1310
+ nodeId: "wait-1",
1311
+ nodeType: "wait",
1312
+ });
1313
+ expect(nodeStartEvents[2]).toMatchObject({
1314
+ nodeId: "endpoint-2",
1315
+ nodeType: "endpoint",
1316
+ });
1317
+ });
1318
+
1319
+ it("should emit HTTP_REQUEST and HTTP_RESPONSE events for endpoint nodes", async () => {
1320
+ const plan: TestPlanV1 = {
1321
+ id: "event-test-3",
1322
+ name: "HTTP Event Test",
1323
+ version: "1.0",
1324
+ environment: "default",
1325
+ nodes: [
1326
+ {
1327
+ id: "http-node",
1328
+ type: NodeType.ENDPOINT,
1329
+ method: HttpMethod.POST,
1330
+ path: "/create",
1331
+ base: { type: "target", key: "api" },
1332
+ headers: { "Content-Type": "application/json" },
1333
+ body: { name: "test" },
1334
+ response_format: ResponseFormat.JSON,
1335
+ },
1336
+ ],
1337
+ edges: [
1338
+ {
1339
+ from: START,
1340
+ to: "http-node",
1341
+ },
1342
+ {
1343
+ from: "http-node",
1344
+ to: END,
1345
+ },
1346
+ ],
1347
+ };
1348
+
1349
+ stubClient.addStub({
1350
+ match: "https://api.example.com/create",
1351
+ response: { status: 201, statusText: "Created", data: { id: 123 } },
1352
+ });
1353
+
1354
+ await executePlanV1(plan, {
1355
+ ...options,
1356
+ eventEmitter: emitter,
1357
+ });
1358
+
1359
+ const httpRequestEvents = events.filter((e) => e.type === "HTTP_REQUEST");
1360
+ const httpResponseEvents = events.filter(
1361
+ (e) => e.type === "HTTP_RESPONSE",
1362
+ );
1363
+
1364
+ expect(httpRequestEvents).toHaveLength(1);
1365
+ expect(httpResponseEvents).toHaveLength(1);
1366
+
1367
+ const httpRequest = httpRequestEvents[0];
1368
+ expect(httpRequest).toMatchObject({
1369
+ type: "HTTP_REQUEST",
1370
+ nodeId: "http-node",
1371
+ attempt: 1,
1372
+ method: "POST",
1373
+ url: "https://api.example.com/create",
1374
+ hasBody: true,
1375
+ });
1376
+ expect(httpRequest.headers).toEqual({
1377
+ "Content-Type": "application/json",
1378
+ });
1379
+
1380
+ const httpResponse = httpResponseEvents[0];
1381
+ expect(httpResponse).toMatchObject({
1382
+ type: "HTTP_RESPONSE",
1383
+ nodeId: "http-node",
1384
+ attempt: 1,
1385
+ status: 201,
1386
+ statusText: "Created",
1387
+ hasBody: true,
1388
+ });
1389
+ expect(httpResponse.duration_ms).toBeGreaterThanOrEqual(0);
1390
+ });
1391
+
1392
+ it("should emit WAIT_START event for wait nodes", async () => {
1393
+ const plan: TestPlanV1 = {
1394
+ id: "event-test-4",
1395
+ name: "Wait Event Test",
1396
+ version: "1.0",
1397
+ environment: "default",
1398
+ nodes: [
1399
+ {
1400
+ id: "wait-node",
1401
+ type: NodeType.WAIT,
1402
+ duration_ms: 50,
1403
+ },
1404
+ ],
1405
+ edges: [
1406
+ {
1407
+ from: START,
1408
+ to: "wait-node",
1409
+ },
1410
+ {
1411
+ from: "wait-node",
1412
+ to: END,
1413
+ },
1414
+ ],
1415
+ };
1416
+
1417
+ await executePlanV1(plan, {
1418
+ ...options,
1419
+ eventEmitter: emitter,
1420
+ });
1421
+
1422
+ const waitStartEvents = events.filter((e) => e.type === "WAIT_START");
1423
+
1424
+ expect(waitStartEvents).toHaveLength(1);
1425
+ expect(waitStartEvents[0]).toMatchObject({
1426
+ type: "WAIT_START",
1427
+ nodeId: "wait-node",
1428
+ duration_ms: 50,
1429
+ });
1430
+ });
1431
+
1432
+ it("should emit ERROR event on HTTP request failure", async () => {
1433
+ const plan: TestPlanV1 = {
1434
+ id: "event-test-5",
1435
+ name: "Error Event Test",
1436
+ version: "1.0",
1437
+ environment: "default",
1438
+ nodes: [
1439
+ {
1440
+ id: "failing-node",
1441
+ type: NodeType.ENDPOINT,
1442
+ method: HttpMethod.GET,
1443
+ path: "/fail",
1444
+ base: { type: "target", key: "api" },
1445
+ response_format: ResponseFormat.JSON,
1446
+ },
1447
+ ],
1448
+ edges: [
1449
+ {
1450
+ from: START,
1451
+ to: "failing-node",
1452
+ },
1453
+ {
1454
+ from: "failing-node",
1455
+ to: END,
1456
+ },
1457
+ ],
1458
+ };
1459
+
1460
+ // No stub - request will fail
1461
+
1462
+ const result = await executePlanV1(plan, {
1463
+ ...options,
1464
+ eventEmitter: emitter,
1465
+ });
1466
+
1467
+ result.errors;
1468
+
1469
+ // Should emit error for the failed HTTP request
1470
+ expect(result.errors.length).toBeGreaterThan(0);
1471
+ });
1472
+
1473
+ it("should maintain monotonic sequence numbers", async () => {
1474
+ const plan: TestPlanV1 = {
1475
+ id: "event-test-6",
1476
+ name: "Sequence Test",
1477
+ version: "1.0",
1478
+ environment: "default",
1479
+ nodes: [
1480
+ {
1481
+ id: "node-1",
1482
+ type: NodeType.ENDPOINT,
1483
+ method: HttpMethod.GET,
1484
+ path: "/test",
1485
+ base: { type: "target", key: "api" },
1486
+ response_format: ResponseFormat.JSON,
1487
+ },
1488
+ ],
1489
+ edges: [
1490
+ {
1491
+ from: START,
1492
+ to: "node-1",
1493
+ },
1494
+ {
1495
+ from: "node-1",
1496
+ to: END,
1497
+ },
1498
+ ],
1499
+ };
1500
+
1501
+ stubClient.addStub({
1502
+ match: "https://api.example.com/test",
1503
+ response: { status: 200, statusText: "OK", data: {} },
1504
+ });
1505
+
1506
+ await executePlanV1(plan, {
1507
+ ...options,
1508
+ eventEmitter: emitter,
1509
+ });
1510
+
1511
+ // Check that all events have the same executionId
1512
+ const executionIds = [...new Set(events.map((e) => e.executionId))];
1513
+ expect(executionIds).toHaveLength(1);
1514
+
1515
+ // Check that sequence numbers are monotonically increasing
1516
+ const seqs = events.map((e) => e.seq);
1517
+ for (let i = 0; i < seqs.length - 1; i++) {
1518
+ expect(seqs[i + 1]).toBe(seqs[i] + 1);
1519
+ }
1520
+
1521
+ // First event should be seq 0
1522
+ expect(seqs[0]).toBe(0);
1523
+ });
1524
+
1525
+ it("should use provided executionId if given", async () => {
1526
+ const customExecutionId = "custom-exec-id-123";
1527
+
1528
+ const plan: TestPlanV1 = {
1529
+ id: "event-test-7",
1530
+ name: "Custom Execution ID Test",
1531
+ version: "1.0",
1532
+ environment: "default",
1533
+ nodes: [
1534
+ {
1535
+ id: "node-1",
1536
+ type: NodeType.ENDPOINT,
1537
+ method: HttpMethod.GET,
1538
+ path: "/test",
1539
+ base: { type: "target", key: "api" },
1540
+ response_format: ResponseFormat.JSON,
1541
+ },
1542
+ ],
1543
+ edges: [
1544
+ {
1545
+ from: START,
1546
+ to: "node-1",
1547
+ },
1548
+ {
1549
+ from: "node-1",
1550
+ to: END,
1551
+ },
1552
+ ],
1553
+ };
1554
+
1555
+ stubClient.addStub({
1556
+ match: "https://api.example.com/test",
1557
+ response: { status: 200, statusText: "OK", data: {} },
1558
+ });
1559
+
1560
+ await executePlanV1(plan, {
1561
+ ...options,
1562
+ eventEmitter: emitter,
1563
+ executionId: customExecutionId,
1564
+ });
1565
+
1566
+ // All events should have the custom executionId
1567
+ events.forEach((event) => {
1568
+ expect(event.executionId).toBe(customExecutionId);
1569
+ });
1570
+ });
1571
+
1572
+ it("should emit events in correct order", async () => {
1573
+ const plan: TestPlanV1 = {
1574
+ id: "event-test-8",
1575
+ name: "Event Order Test",
1576
+ version: "1.0",
1577
+ environment: "default",
1578
+ nodes: [
1579
+ {
1580
+ id: "node-1",
1581
+ type: NodeType.ENDPOINT,
1582
+ method: HttpMethod.GET,
1583
+ path: "/test",
1584
+ base: { type: "target", key: "api" },
1585
+ response_format: ResponseFormat.JSON,
1586
+ },
1587
+ ],
1588
+ edges: [
1589
+ {
1590
+ from: START,
1591
+ to: "node-1",
1592
+ },
1593
+ {
1594
+ from: "node-1",
1595
+ to: END,
1596
+ },
1597
+ ],
1598
+ };
1599
+
1600
+ stubClient.addStub({
1601
+ match: "https://api.example.com/test",
1602
+ response: { status: 200, statusText: "OK", data: {} },
1603
+ });
1604
+
1605
+ await executePlanV1(plan, {
1606
+ ...options,
1607
+ eventEmitter: emitter,
1608
+ });
1609
+
1610
+ const eventTypes = events.map((e) => e.type);
1611
+
1612
+ // Expected order: PLAN_START, NODE_START, HTTP_REQUEST, HTTP_RESPONSE, NODE_END, PLAN_END
1613
+ expect(eventTypes[0]).toBe("PLAN_START");
1614
+ expect(eventTypes[1]).toBe("NODE_START");
1615
+ expect(eventTypes[2]).toBe("HTTP_REQUEST");
1616
+ expect(eventTypes[3]).toBe("HTTP_RESPONSE");
1617
+ expect(eventTypes[4]).toBe("NODE_END");
1618
+ expect(eventTypes[5]).toBe("PLAN_END");
1619
+ });
1620
+
1621
+ it("should handle failed HTTP requests correctly", async () => {
1622
+ const plan: TestPlanV1 = {
1623
+ id: "event-test-9",
1624
+ name: "Failed Request Event Test",
1625
+ version: "1.0",
1626
+ environment: "default",
1627
+ nodes: [
1628
+ {
1629
+ id: "failing-node",
1630
+ type: NodeType.ENDPOINT,
1631
+ method: HttpMethod.GET,
1632
+ path: "/fail",
1633
+ base: { type: "target", key: "api" },
1634
+ response_format: ResponseFormat.JSON,
1635
+ },
1636
+ ],
1637
+ edges: [
1638
+ {
1639
+ from: START,
1640
+ to: "failing-node",
1641
+ },
1642
+ {
1643
+ from: "failing-node",
1644
+ to: END,
1645
+ },
1646
+ ],
1647
+ };
1648
+
1649
+ // No stub - request will fail
1650
+
1651
+ await executePlanV1(plan, {
1652
+ ...options,
1653
+ eventEmitter: emitter,
1654
+ });
1655
+
1656
+ const httpResponseEvents = events.filter(
1657
+ (e) => e.type === "HTTP_RESPONSE",
1658
+ );
1659
+ const nodeEndEvents = events.filter((e) => e.type === "NODE_END");
1660
+
1661
+ expect(httpResponseEvents).toHaveLength(1);
1662
+ expect(httpResponseEvents[0]).toMatchObject({
1663
+ status: 0,
1664
+ statusText: "Error",
1665
+ hasBody: false,
1666
+ });
1667
+
1668
+ expect(nodeEndEvents[0]).toMatchObject({
1669
+ success: false,
1670
+ });
1671
+ expect(nodeEndEvents[0].error).toBeDefined();
1672
+ });
1673
+ });
1674
+ });