automategraph 1.5.0 → 1.5.3

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 (88) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/UPGRADE-PROPOSAL.md +141 -0
  3. package/docs/diagrams/c19-resilience-scenario.md +61 -0
  4. package/docs/diagrams/c19-resilience-scenario.mmd +28 -0
  5. package/docs/diagrams/c19-resilience-scenario.svg +181 -0
  6. package/examples/README.md +206 -0
  7. package/examples/branching-edges.ts +192 -0
  8. package/examples/branching-graph.ts +333 -0
  9. package/examples/branching-router.ts +475 -0
  10. package/examples/json-graph.ts +231 -0
  11. package/examples/json-scripts.ts +152 -0
  12. package/examples/order-flow.functions.cjs +32 -0
  13. package/examples/order-flow.graph.json +27 -0
  14. package/examples/order-flow.scripted.graph.json +68 -0
  15. package/js/examples/branching-edges.js +133 -0
  16. package/js/examples/branching-edges.js.map +1 -0
  17. package/js/examples/branching-graph.js +232 -0
  18. package/js/examples/branching-graph.js.map +1 -0
  19. package/js/examples/branching-router.js +305 -0
  20. package/js/examples/branching-router.js.map +1 -0
  21. package/js/examples/json-graph.js +197 -0
  22. package/js/examples/json-graph.js.map +1 -0
  23. package/js/examples/json-scripts.js +131 -0
  24. package/js/examples/json-scripts.js.map +1 -0
  25. package/js/tests/async.test.js +592 -0
  26. package/js/tests/async.test.js.map +1 -0
  27. package/js/tests/complex.test.js +972 -0
  28. package/js/tests/complex.test.js.map +1 -0
  29. package/js/tests/conformance.test.js +365 -0
  30. package/js/tests/conformance.test.js.map +1 -0
  31. package/js/tests/edges.test.js +702 -0
  32. package/js/tests/edges.test.js.map +1 -0
  33. package/js/tests/graphjson.test.js +504 -0
  34. package/js/tests/graphjson.test.js.map +1 -0
  35. package/js/tests/harness.js +192 -0
  36. package/js/tests/harness.js.map +1 -0
  37. package/js/tests/helpers.js +136 -0
  38. package/js/tests/helpers.js.map +1 -0
  39. package/js/tests/lint.test.js +317 -0
  40. package/js/tests/lint.test.js.map +1 -0
  41. package/js/tests/registry.test.js +705 -0
  42. package/js/tests/registry.test.js.map +1 -0
  43. package/js/tests/report.js +422 -0
  44. package/js/tests/report.js.map +1 -0
  45. package/js/tests/runner.js +67 -0
  46. package/js/tests/runner.js.map +1 -0
  47. package/js/tests/scripts.test.js +552 -0
  48. package/js/tests/scripts.test.js.map +1 -0
  49. package/js/tests/unit.test.js +1239 -0
  50. package/js/tests/unit.test.js.map +1 -0
  51. package/js/tests/upgrade.test.js +618 -0
  52. package/js/tests/upgrade.test.js.map +1 -0
  53. package/package.json +2 -9
  54. package/report/index.html +3727 -0
  55. package/report/report.json +3053 -0
  56. package/tests/async.test.ts +668 -0
  57. package/tests/complex.test.ts +1123 -0
  58. package/tests/conformance.test.ts +442 -0
  59. package/tests/edges.test.ts +848 -0
  60. package/tests/fixtures/scripts/bad-registration.cjs +4 -0
  61. package/tests/fixtures/scripts/broken.cjs +2 -0
  62. package/tests/fixtures/scripts/cjs-default.js +4 -0
  63. package/tests/fixtures/scripts/collide-a.cjs +4 -0
  64. package/tests/fixtures/scripts/collide-b.cjs +4 -0
  65. package/tests/fixtures/scripts/empty.cjs +2 -0
  66. package/tests/fixtures/scripts/esm-functions.mjs +10 -0
  67. package/tests/fixtures/scripts/esm-syntax.js +3 -0
  68. package/tests/fixtures/scripts/global-channel.cjs +6 -0
  69. package/tests/fixtures/scripts/global-channel.mjs +7 -0
  70. package/tests/fixtures/scripts/mismatch.cjs +4 -0
  71. package/tests/fixtures/scripts/needs-sibling.cjs +7 -0
  72. package/tests/fixtures/scripts/order.cjs +14 -0
  73. package/tests/fixtures/scripts/sibling-helper.cjs +3 -0
  74. package/tests/fixtures/scripts/throwing-global.mjs +6 -0
  75. package/tests/graphjson.test.ts +737 -0
  76. package/tests/harness.ts +291 -0
  77. package/tests/helpers.ts +216 -0
  78. package/tests/lint.test.ts +381 -0
  79. package/tests/registry.test.ts +1134 -0
  80. package/tests/report.ts +452 -0
  81. package/tests/runner.ts +58 -0
  82. package/tests/scripts.test.ts +970 -0
  83. package/tests/unit.test.ts +1192 -0
  84. package/tests/upgrade.test.ts +725 -0
  85. package/tools/server.mjs +152 -0
  86. package/tsconfig.browser.json +19 -0
  87. package/tsconfig.json +18 -0
  88. package/tsconfig.publish.json +13 -0
@@ -0,0 +1,1239 @@
1
+ "use strict";
2
+ /* ============================================================
3
+ * 100 UNIT TESTS
4
+ * Categories:
5
+ * U-VAL Validation (12) U-ERR Errors (9)
6
+ * U-UTL Utilities (10) U-BUS EventBus (12)
7
+ * U-MEM InMemoryStorage (8) U-FRZ Graph freezing (8)
8
+ * U-LIF Engine lifecycle (10) U-SCH Execution / scheduler (20)
9
+ * U-RTY Retry (6) U-CAN Cancellation (5)
10
+ * ============================================================ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ const automategraph_1 = require("../src/automategraph");
13
+ const helpers_1 = require("./helpers");
14
+ const harness_1 = require("./harness");
15
+ /* =========================================================
16
+ * 1. Graph validation — 12
17
+ * ========================================================= */
18
+ const val = (0, harness_1.suite)("unit", "Graph validation");
19
+ val.t("throws GraphValidationError when graph.id is empty", () => {
20
+ try {
21
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1)], entryNodes: ["a"], terminalNodes: ["a"], id: "" }));
22
+ throw new Error("expected throw");
23
+ }
24
+ catch (error) {
25
+ if (!(error instanceof automategraph_1.GraphValidationError))
26
+ throw error;
27
+ if (error.code !== "GRAPH_VALIDATION_ERROR")
28
+ throw new Error(error.code);
29
+ if (error.message !== "Graph id is required")
30
+ throw new Error(error.message);
31
+ }
32
+ });
33
+ val.t("throws when graph.id is only whitespace", () => {
34
+ try {
35
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1)], entryNodes: ["a"], terminalNodes: ["a"], id: " " }));
36
+ throw new Error("expected throw");
37
+ }
38
+ catch (error) {
39
+ if (!(error instanceof automategraph_1.GraphValidationError))
40
+ throw error;
41
+ if (error.message !== "Graph id is required")
42
+ throw new Error(error.message);
43
+ }
44
+ });
45
+ val.t("throws when graph.version is empty", () => {
46
+ try {
47
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1)], entryNodes: ["a"], terminalNodes: ["a"], version: "" }));
48
+ throw new Error("expected throw");
49
+ }
50
+ catch (error) {
51
+ if (!(error instanceof automategraph_1.GraphValidationError))
52
+ throw error;
53
+ if (error.message !== "Graph version is required")
54
+ throw new Error(error.message);
55
+ }
56
+ });
57
+ val.t("throws when there are no entry nodes", () => {
58
+ try {
59
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1)], entryNodes: [], terminalNodes: ["a"] }));
60
+ throw new Error("expected throw");
61
+ }
62
+ catch (error) {
63
+ if (!(error instanceof automategraph_1.GraphValidationError))
64
+ throw error;
65
+ if (error.message !== "At least one entry Node is required")
66
+ throw new Error(error.message);
67
+ }
68
+ });
69
+ val.t("throws when there are no terminal nodes", () => {
70
+ try {
71
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1)], entryNodes: ["a"], terminalNodes: [] }));
72
+ throw new Error("expected throw");
73
+ }
74
+ catch (error) {
75
+ if (!(error instanceof automategraph_1.GraphValidationError))
76
+ throw error;
77
+ if (error.message !== "At least one terminal Node is required")
78
+ throw new Error(error.message);
79
+ }
80
+ });
81
+ val.t("throws when a node key does not match Node.id", () => {
82
+ const definition = {
83
+ id: "mismatch",
84
+ version: "1",
85
+ entryNodes: ["key"],
86
+ terminalNodes: ["key"],
87
+ nodes: { key: { id: "other", run: () => 1 } }
88
+ };
89
+ try {
90
+ new automategraph_1.GraphExecutionEngine(definition);
91
+ throw new Error("expected throw");
92
+ }
93
+ catch (error) {
94
+ if (!(error instanceof automategraph_1.GraphValidationError))
95
+ throw error;
96
+ if (!/does not match Node\.id/.test(error.message))
97
+ throw new Error(error.message);
98
+ }
99
+ });
100
+ val.t("throws when next references an unknown node", () => {
101
+ try {
102
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1, { next: ["ghost"] })], entryNodes: ["a"], terminalNodes: ["a"] }));
103
+ throw new Error("expected throw");
104
+ }
105
+ catch (error) {
106
+ if (!(error instanceof automategraph_1.GraphValidationError))
107
+ throw error;
108
+ if (error.message !== 'Node "a" references unknown next Node "ghost"')
109
+ throw new Error(error.message);
110
+ }
111
+ });
112
+ val.t("throws when onThrow references an unknown node", () => {
113
+ try {
114
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1, { onThrow: "doom" })], entryNodes: ["a"], terminalNodes: ["a"] }));
115
+ throw new Error("expected throw");
116
+ }
117
+ catch (error) {
118
+ if (!(error instanceof automategraph_1.GraphValidationError))
119
+ throw error;
120
+ if (error.message !== 'Node "a" references unknown ONTHROW target "doom"')
121
+ throw new Error(error.message);
122
+ }
123
+ });
124
+ val.t("throws when node timeoutMs is zero", () => {
125
+ try {
126
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1, { timeoutMs: 0 })], entryNodes: ["a"], terminalNodes: ["a"] }));
127
+ throw new Error("expected throw");
128
+ }
129
+ catch (error) {
130
+ if (!(error instanceof automategraph_1.GraphValidationError))
131
+ throw error;
132
+ if (error.message !== 'Node "a" timeout must be positive')
133
+ throw new Error(error.message);
134
+ }
135
+ });
136
+ val.t("throws when node timeoutMs is negative", () => {
137
+ try {
138
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1, { timeoutMs: -5 })], entryNodes: ["a"], terminalNodes: ["a"] }));
139
+ throw new Error("expected throw");
140
+ }
141
+ catch (error) {
142
+ if (!(error instanceof automategraph_1.GraphValidationError))
143
+ throw error;
144
+ if (error.message !== 'Node "a" timeout must be positive')
145
+ throw new Error(error.message);
146
+ }
147
+ });
148
+ val.t("throws when retry.maxAttempts is not an integer", () => {
149
+ try {
150
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1, { retry: { maxAttempts: 1.5 } })], entryNodes: ["a"], terminalNodes: ["a"] }));
151
+ throw new Error("expected throw");
152
+ }
153
+ catch (error) {
154
+ if (!(error instanceof automategraph_1.GraphValidationError))
155
+ throw error;
156
+ if (error.message !== 'Node "a" retry.maxAttempts must be >= 1')
157
+ throw new Error(error.message);
158
+ }
159
+ });
160
+ val.t("throws when entry or terminal node is unknown", () => {
161
+ try {
162
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1)], entryNodes: ["nope"], terminalNodes: ["a"] }));
163
+ throw new Error("expected throw for entry");
164
+ }
165
+ catch (error) {
166
+ if (!(error instanceof automategraph_1.GraphValidationError))
167
+ throw error;
168
+ if (error.message !== 'Unknown entry Node "nope"')
169
+ throw new Error(error.message);
170
+ }
171
+ try {
172
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.g)({ nodes: [(0, helpers_1.node)("a", () => 1)], entryNodes: ["a"], terminalNodes: ["nope"] }));
173
+ throw new Error("expected throw for terminal");
174
+ }
175
+ catch (error) {
176
+ if (!(error instanceof automategraph_1.GraphValidationError))
177
+ throw error;
178
+ if (error.message !== 'Unknown terminal Node "nope"')
179
+ throw new Error(error.message);
180
+ }
181
+ });
182
+ /* =========================================================
183
+ * 2. Error classes & utilities — 9 + 10 = 19
184
+ * ========================================================= */
185
+ const err = (0, harness_1.suite)("unit", "Error classes & utilities");
186
+ err.t("AutomateGraphError carries message, code and details", () => {
187
+ const error = new automategraph_1.AutomateGraphError("boom", "BOOM", { a: 1 });
188
+ if (error.name !== "AutomateGraphError")
189
+ throw new Error(error.name);
190
+ if (error.code !== "BOOM")
191
+ throw new Error(error.code);
192
+ if (error.message !== "boom")
193
+ throw new Error(error.message);
194
+ if (!(error instanceof Error))
195
+ throw new Error("not an Error");
196
+ if (error.details.a !== 1)
197
+ throw new Error("details lost");
198
+ });
199
+ err.t("GraphValidationError uses the GRAPH_VALIDATION_ERROR code", () => {
200
+ const error = new automategraph_1.GraphValidationError("bad graph", { hint: true });
201
+ if (error.code !== "GRAPH_VALIDATION_ERROR")
202
+ throw new Error(error.code);
203
+ if (error.name !== "GraphValidationError")
204
+ throw new Error(error.name);
205
+ if (!(error instanceof automategraph_1.GraphValidationError))
206
+ throw new Error("instanceof");
207
+ });
208
+ err.t("TimeoutError composes a readable message and details", () => {
209
+ const error = new automategraph_1.TimeoutError("slow", 250);
210
+ if (error.name !== "TimeoutError")
211
+ throw new Error(error.name);
212
+ if (error.code !== "NODE_TIMEOUT")
213
+ throw new Error(error.code);
214
+ if (error.message !== 'Node "slow" exceeded timeout of 250ms')
215
+ throw new Error(error.message);
216
+ const details = error.details;
217
+ if (details.nodeId !== "slow" || details.timeoutMs !== 250)
218
+ throw new Error("bad details");
219
+ });
220
+ err.t("PluginInitializationError reports the plugin id and keeps the cause", () => {
221
+ const cause = new Error("db down");
222
+ const error = new automategraph_1.PluginInitializationError("billing", cause);
223
+ if (error.code !== "PLUGIN_INITIALIZATION_ERROR")
224
+ throw new Error(error.code);
225
+ if (error.message !== 'Required plugin "billing" failed to initialize')
226
+ throw new Error(error.message);
227
+ if (error.details !== cause)
228
+ throw new Error("cause not preserved");
229
+ });
230
+ err.t("inheritance chain: TimeoutError -> AutomateGraphError -> Error", () => {
231
+ const error = new automategraph_1.TimeoutError("n", 1);
232
+ if (!(error instanceof automategraph_1.TimeoutError))
233
+ throw new Error("self");
234
+ if (!(error instanceof automategraph_1.AutomateGraphError))
235
+ throw new Error("parent");
236
+ if (!(error instanceof Error))
237
+ throw new Error("root");
238
+ });
239
+ err.t("PluginInitializationError is an AutomateGraphError", () => {
240
+ const error = new automategraph_1.PluginInitializationError("p", new Error("x"));
241
+ if (!(error instanceof automategraph_1.AutomateGraphError))
242
+ throw new Error("not graph error");
243
+ if (!(error instanceof Error))
244
+ throw new Error("not error");
245
+ });
246
+ err.t("AutomateGraphError is not a GraphValidationError (distinct subclass)", () => {
247
+ const error = new automategraph_1.AutomateGraphError("x", "X");
248
+ if (error instanceof automategraph_1.GraphValidationError)
249
+ throw new Error("unexpected subtype");
250
+ });
251
+ err.t("AutomateGraphError allows optional details to be omitted", () => {
252
+ const error = new automategraph_1.AutomateGraphError("x", "X");
253
+ if (error.details !== undefined)
254
+ throw new Error("details should be undefined");
255
+ });
256
+ err.t("Error classes expose stable constructor.name values", () => {
257
+ if (automategraph_1.AutomateGraphError.name !== "AutomateGraphError")
258
+ throw new Error("1");
259
+ if (automategraph_1.GraphValidationError.name !== "GraphValidationError")
260
+ throw new Error("2");
261
+ if (automategraph_1.TimeoutError.name !== "TimeoutError")
262
+ throw new Error("3");
263
+ if (automategraph_1.PluginInitializationError.name !== "PluginInitializationError")
264
+ throw new Error("4");
265
+ });
266
+ const util = (0, harness_1.suite)("unit", "Utilities");
267
+ util.t("engine executionId is a stable 36-char UUID-ish string", () => {
268
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]));
269
+ const id = engine.getExecutionId();
270
+ if (typeof id !== "string")
271
+ throw new Error("type");
272
+ if (id.length !== 36)
273
+ throw new Error(`length ${id.length}`);
274
+ if (engine.getExecutionId() !== id)
275
+ throw new Error("unstable");
276
+ });
277
+ util.t("executionIds are unique across engines", () => {
278
+ const a = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"])).getExecutionId();
279
+ const b = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"])).getExecutionId();
280
+ if (a === b)
281
+ throw new Error("collision");
282
+ });
283
+ util.t("report.startedAt is an ISO-8601 timestamp", async () => {
284
+ const report = await new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"])).run();
285
+ const parsed = Date.parse(report.startedAt);
286
+ if (Number.isNaN(parsed))
287
+ throw new Error("not a date");
288
+ if (new Date(report.startedAt).toISOString() !== report.startedAt)
289
+ throw new Error("not ISO");
290
+ });
291
+ util.t("report.finishedAt >= report.startedAt", async () => {
292
+ const report = await new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"])).run();
293
+ if (Date.parse(report.finishedAt) < Date.parse(report.startedAt))
294
+ throw new Error("time travel");
295
+ });
296
+ util.t("node result startedAt <= finishedAt", async () => {
297
+ const report = await new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"])).run();
298
+ const result = report.results[0];
299
+ if (Date.parse(result.finishedAt) < Date.parse(result.startedAt))
300
+ throw new Error("time travel");
301
+ });
302
+ util.t("error records carry ISO timestamps", async () => {
303
+ const definition = (0, helpers_1.linear)(["a", "t"], (id) => {
304
+ if (id === "a")
305
+ throw new Error("x");
306
+ return id;
307
+ });
308
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
309
+ for (const record of report.errors) {
310
+ if (new Date(record.timestamp).toISOString() !== record.timestamp)
311
+ throw new Error("bad ts");
312
+ }
313
+ });
314
+ util.t("string throws are converted to Error instances", async () => {
315
+ const definition = (0, helpers_1.g)({
316
+ nodes: [(0, helpers_1.node)("a", () => { throw "plain-string-failure"; })],
317
+ entryNodes: ["a"],
318
+ terminalNodes: ["a"]
319
+ });
320
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
321
+ if (report.errors[0].message !== "plain-string-failure")
322
+ throw new Error(report.errors[0].message);
323
+ if (report.errors[0].code !== "NODE_EXECUTION_ERROR")
324
+ throw new Error(report.errors[0].code);
325
+ });
326
+ util.t("throwing a non-Error object stringifies via toError", async () => {
327
+ const definition = (0, helpers_1.g)({
328
+ nodes: [(0, helpers_1.node)("a", () => { throw { weird: true }; })],
329
+ entryNodes: ["a"],
330
+ terminalNodes: ["a"]
331
+ });
332
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
333
+ if (report.errors[0].message !== "[object Object]")
334
+ throw new Error(report.errors[0].message);
335
+ });
336
+ util.t("error records expose a stack trace for real errors", async () => {
337
+ const definition = (0, helpers_1.g)({
338
+ nodes: [(0, helpers_1.node)("a", () => { throw new Error("with stack"); })],
339
+ entryNodes: ["a"],
340
+ terminalNodes: ["a"]
341
+ });
342
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
343
+ if (!report.errors[0].stack || !report.errors[0].stack.includes("with stack")) {
344
+ throw new Error("missing stack");
345
+ }
346
+ });
347
+ util.t("graph id/version of the report mirror the definition", async () => {
348
+ const definition = (0, helpers_1.g)({
349
+ id: "billing-flow",
350
+ version: "2.3.4",
351
+ nodes: [(0, helpers_1.constNode)("a", 1)],
352
+ entryNodes: ["a"],
353
+ terminalNodes: ["a"]
354
+ });
355
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
356
+ if (report.graphId !== "billing-flow")
357
+ throw new Error(report.graphId);
358
+ if (report.graphVersion !== "2.3.4")
359
+ throw new Error(report.graphVersion);
360
+ });
361
+ /* =========================================================
362
+ * 3. EventBus — 12
363
+ * ========================================================= */
364
+ const bus = (0, harness_1.suite)("unit", "EventBus");
365
+ bus.t("emit with no listeners resolves", async () => {
366
+ const target = new automategraph_1.EventBus();
367
+ await target.emit("nothing");
368
+ });
369
+ bus.t("required listeners receive the payload and are awaited", async () => {
370
+ const target = new automategraph_1.EventBus();
371
+ const seen = [];
372
+ target.on("e", async (payload) => {
373
+ await (0, helpers_1.sleep)(5);
374
+ seen.push(payload);
375
+ }, { required: true });
376
+ await target.emit("e", { n: 1 });
377
+ if (seen.length !== 1)
378
+ throw new Error("not awaited");
379
+ if (seen[0].n !== 1)
380
+ throw new Error("payload lost");
381
+ });
382
+ bus.t("required listeners run in registration order", async () => {
383
+ const target = new automategraph_1.EventBus();
384
+ const order = [];
385
+ for (const index of [1, 2, 3]) {
386
+ target.on("e", async () => { order.push(index); }, { required: true });
387
+ }
388
+ await target.emit("e");
389
+ if (order.join(",") !== "1,2,3")
390
+ throw new Error(order.join(","));
391
+ });
392
+ bus.t("plain listeners are NOT awaited — emit resolves first", async () => {
393
+ const target = new automategraph_1.EventBus();
394
+ let handled = false;
395
+ target.on("e", async () => {
396
+ await (0, helpers_1.sleep)(30);
397
+ handled = true;
398
+ });
399
+ await target.emit("e");
400
+ if (handled)
401
+ throw new Error("emit wrongly awaited a non-required listener");
402
+ await (0, helpers_1.sleep)(60);
403
+ if (!handled)
404
+ throw new Error("listener never ran");
405
+ });
406
+ bus.t("unsubscribe stops delivery", async () => {
407
+ const target = new automategraph_1.EventBus();
408
+ let count = 0;
409
+ const off = target.on("e", () => { count += 1; }, { required: true });
410
+ await target.emit("e");
411
+ off();
412
+ await target.emit("e");
413
+ if (count !== 1)
414
+ throw new Error(`count=${count}`);
415
+ });
416
+ bus.t("unsubscribing the last listener removes the event key", () => {
417
+ const target = new automategraph_1.EventBus();
418
+ const off = target.on("e", () => undefined);
419
+ off();
420
+ // @ts-expect-error — inspecting private state intentionally
421
+ const listeners = target.listeners;
422
+ if (listeners.has("e"))
423
+ throw new Error("key retained");
424
+ });
425
+ bus.t("duplicate handlers registered as distinct listeners both fire", async () => {
426
+ const target = new automategraph_1.EventBus();
427
+ let count = 0;
428
+ const handler = () => { count += 1; };
429
+ target.on("e", handler, { required: true });
430
+ target.on("e", handler, { required: true });
431
+ await target.emit("e");
432
+ if (count !== 2)
433
+ throw new Error(`count=${count}`);
434
+ });
435
+ bus.t("a throwing plain listener does not reject emit (isolated)", async () => {
436
+ const { lines, onLog } = (0, helpers_1.logCollector)();
437
+ const target = new automategraph_1.EventBus(onLog);
438
+ target.on("e", () => { throw new Error("listener blew up"); });
439
+ await target.emit("e");
440
+ await (0, helpers_1.tick)();
441
+ await (0, helpers_1.sleep)(10);
442
+ });
443
+ bus.t("a failing plain listener is reported through onLog", async () => {
444
+ const { lines, onLog } = (0, helpers_1.logCollector)();
445
+ const target = new automategraph_1.EventBus(onLog);
446
+ target.on("e", () => { throw new Error("kaboom"); });
447
+ await target.emit("e");
448
+ await (0, helpers_1.sleep)(15);
449
+ const found = lines.find(line => line.message === "Non-critical event listener failed: e");
450
+ if (!found)
451
+ throw new Error("not reported");
452
+ if (found.level !== "error")
453
+ throw new Error(found.level);
454
+ if (found.details.message !== "kaboom")
455
+ throw new Error("cause lost");
456
+ });
457
+ bus.t("a throwing REQUIRED listener propagates to the emitter", async () => {
458
+ const target = new automategraph_1.EventBus();
459
+ target.on("e", () => { throw new Error("required-fail"); }, { required: true });
460
+ try {
461
+ await target.emit("e");
462
+ throw new Error("expected rejection");
463
+ }
464
+ catch (error) {
465
+ if (error.message !== "required-fail")
466
+ throw error;
467
+ }
468
+ });
469
+ bus.t("mixed required/plain: required first, plain afterwards (async)", async () => {
470
+ const target = new automategraph_1.EventBus();
471
+ const order = [];
472
+ target.on("e", async () => { order.push("plain"); });
473
+ target.on("e", async () => { order.push("required"); }, { required: true });
474
+ await target.emit("e");
475
+ await (0, helpers_1.sleep)(20);
476
+ if (order.join(",") !== "required,plain")
477
+ throw new Error(order.join(","));
478
+ });
479
+ bus.t("clear() removes every listener", async () => {
480
+ const target = new automategraph_1.EventBus();
481
+ let count = 0;
482
+ target.on("a", () => { count += 1; }, { required: true });
483
+ target.on("b", () => { count += 1; }, { required: true });
484
+ target.clear();
485
+ await target.emit("a");
486
+ await target.emit("b");
487
+ if (count !== 0)
488
+ throw new Error(`count=${count}`);
489
+ });
490
+ /* =========================================================
491
+ * 4. InMemoryStorage — 8
492
+ * ========================================================= */
493
+ const mem = (0, harness_1.suite)("unit", "InMemoryStorage");
494
+ function sampleSnapshot(overrides = {}) {
495
+ return {
496
+ executionId: "exec-1",
497
+ graphId: "graph-1",
498
+ graphVersion: "1.0.0",
499
+ status: "RUNNING",
500
+ outputs: { a: 1 },
501
+ results: [{ nodeId: "a", activationId: "act", status: "SUCCEEDED", attempt: 1 }],
502
+ errors: [],
503
+ pluginVersions: { p: "1.0.0" },
504
+ timestamp: new Date().toISOString(),
505
+ ...overrides
506
+ };
507
+ }
508
+ mem.t("storage advertises a stable id and version", () => {
509
+ const storage = new automategraph_1.InMemoryStorage();
510
+ if (storage.id !== "automategraph.memory-storage")
511
+ throw new Error(storage.id);
512
+ // v1.1: the reference adapter gained list/delete/close and capabilities.
513
+ if (storage.version !== "1.1.0")
514
+ throw new Error(storage.version);
515
+ });
516
+ mem.t("load returns null for an unknown executionId", async () => {
517
+ const storage = new automategraph_1.InMemoryStorage();
518
+ const loaded = await storage.load("missing");
519
+ if (loaded !== null)
520
+ throw new Error("expected null");
521
+ });
522
+ mem.t("saveAtomic then load returns the snapshot", async () => {
523
+ const storage = new automategraph_1.InMemoryStorage();
524
+ await storage.saveAtomic(sampleSnapshot());
525
+ const loaded = await storage.load("exec-1");
526
+ if (!loaded)
527
+ throw new Error("not found");
528
+ if (loaded.graphId !== "graph-1")
529
+ throw new Error(loaded.graphId);
530
+ });
531
+ mem.t("saveAtomic deep-copies the incoming snapshot (caller later mutates)", async () => {
532
+ const storage = new automategraph_1.InMemoryStorage();
533
+ const snapshot = sampleSnapshot();
534
+ await storage.saveAtomic(snapshot);
535
+ snapshot.outputs.a = 999;
536
+ snapshot.results.push({ nodeId: "b", activationId: "x", status: "FAILED", attempt: 1 });
537
+ const loaded = await storage.load("exec-1");
538
+ if (!loaded)
539
+ throw new Error("not found");
540
+ if (loaded.outputs.a !== 1)
541
+ throw new Error("outputs aliased");
542
+ if (loaded.results.length !== 1)
543
+ throw new Error("results aliased");
544
+ });
545
+ mem.t("load returns an isolated copy (mutation of the returned object is inert)", async () => {
546
+ const storage = new automategraph_1.InMemoryStorage();
547
+ await storage.saveAtomic(sampleSnapshot());
548
+ const first = await storage.load("exec-1");
549
+ first.results.length = 0;
550
+ first.outputs.a = -1;
551
+ const second = await storage.load("exec-1");
552
+ if (second.results.length !== 1)
553
+ throw new Error("results not isolated");
554
+ if (second.outputs.a !== 1)
555
+ throw new Error("outputs not isolated");
556
+ });
557
+ mem.t("repeated saves replace the previous snapshot", async () => {
558
+ const storage = new automategraph_1.InMemoryStorage();
559
+ await storage.saveAtomic(sampleSnapshot({ outputs: { a: 1 } }));
560
+ await storage.saveAtomic(sampleSnapshot({ outputs: { a: 2 }, status: "COMPLETED" }));
561
+ const loaded = await storage.load("exec-1");
562
+ if (loaded.outputs.a !== 2)
563
+ throw new Error("not replaced");
564
+ if (loaded.status !== "COMPLETED")
565
+ throw new Error("status stale");
566
+ });
567
+ mem.t("snapshots for different executions stay isolated", async () => {
568
+ const storage = new automategraph_1.InMemoryStorage();
569
+ await storage.saveAtomic(sampleSnapshot({ executionId: "e1" }));
570
+ await storage.saveAtomic(sampleSnapshot({ executionId: "e2", graphId: "other" }));
571
+ const one = await storage.load("e1");
572
+ const two = await storage.load("e2");
573
+ if (one.graphId !== "graph-1")
574
+ throw new Error("e1 crossed");
575
+ if (two.graphId !== "other")
576
+ throw new Error("e2 crossed");
577
+ });
578
+ mem.t("concurrent saveAtomic calls never interleave partial data", async () => {
579
+ const storage = new automategraph_1.InMemoryStorage();
580
+ await Promise.all(Array.from({ length: 50 }, (_, index) => storage.saveAtomic(sampleSnapshot({ outputs: { marker: index } }))));
581
+ const loaded = await storage.load("exec-1");
582
+ if (typeof loaded.outputs.marker !== "number")
583
+ throw new Error("torn snapshot");
584
+ });
585
+ /* =========================================================
586
+ * 5. Graph freezing — 8
587
+ * ========================================================= */
588
+ const frz = (0, harness_1.suite)("unit", "Graph freezing");
589
+ function frozenGraph() {
590
+ return (0, helpers_1.linear)(["a", "b"]);
591
+ }
592
+ frz.t("getGraphDefinition returns a frozen object", () => {
593
+ const engine = new automategraph_1.GraphExecutionEngine(frozenGraph());
594
+ if (!Object.isFrozen(engine.getGraphDefinition()))
595
+ throw new Error("not frozen");
596
+ });
597
+ frz.t("entryNodes and terminalNodes arrays are frozen", () => {
598
+ const engine = new automategraph_1.GraphExecutionEngine(frozenGraph());
599
+ const graph = engine.getGraphDefinition();
600
+ if (!Object.isFrozen(graph.entryNodes))
601
+ throw new Error("entryNodes");
602
+ if (!Object.isFrozen(graph.terminalNodes))
603
+ throw new Error("terminalNodes");
604
+ });
605
+ frz.t("the nodes record is frozen", () => {
606
+ const engine = new automategraph_1.GraphExecutionEngine(frozenGraph());
607
+ if (!Object.isFrozen(engine.getGraphDefinition().nodes))
608
+ throw new Error("nodes not frozen");
609
+ });
610
+ frz.t("mutating the definition after construction does not affect the engine", async () => {
611
+ const definition = (0, helpers_1.linear)(["a", "b"]);
612
+ const engine = new automategraph_1.GraphExecutionEngine(definition);
613
+ definition.nodes.c = (0, helpers_1.constNode)("c", "injected");
614
+ definition.entryNodes; // (frozen copy) — no-op
615
+ const report = await engine.run();
616
+ if (report.outputs.c !== undefined)
617
+ throw new Error("injection took effect");
618
+ if (report.results.some(result => result.nodeId === "c"))
619
+ throw new Error("ghost node ran");
620
+ });
621
+ frz.t("mutating a node's next array after construction is ignored", async () => {
622
+ const definition = (0, helpers_1.linear)(["a", "b"]);
623
+ const engine = new automategraph_1.GraphExecutionEngine(definition);
624
+ definition.nodes.a.next.push("b");
625
+ const report = await engine.run();
626
+ if (report.results.length !== 2)
627
+ throw new Error(`results=${report.results.length}`);
628
+ });
629
+ frz.t("getGraphDefinition is referentially identical across calls", () => {
630
+ const engine = new automategraph_1.GraphExecutionEngine(frozenGraph());
631
+ if (engine.getGraphDefinition() !== engine.getGraphDefinition())
632
+ throw new Error("not identical");
633
+ });
634
+ frz.t("deep-frozen graphs are accepted (idempotent freezing)", async () => {
635
+ const definition = (0, helpers_1.linear)(["a"]);
636
+ Object.freeze(definition);
637
+ Object.freeze(definition.nodes);
638
+ const engine = new automategraph_1.GraphExecutionEngine(definition);
639
+ const report = await engine.run();
640
+ if (report.status !== "COMPLETED")
641
+ throw new Error(report.status);
642
+ });
643
+ frz.t("frozen node objects reject mutation at runtime (strict mode)", () => {
644
+ const engine = new automategraph_1.GraphExecutionEngine(frozenGraph());
645
+ const target = engine.getGraphDefinition().nodes.a;
646
+ try {
647
+ target.id = "hacked";
648
+ }
649
+ catch {
650
+ /* expected in strict mode, ESM emit is strict */
651
+ }
652
+ if (target.id !== "a")
653
+ throw new Error("mutation succeeded");
654
+ });
655
+ /* =========================================================
656
+ * 6. Engine lifecycle — 10
657
+ * ========================================================= */
658
+ const life = (0, harness_1.suite)("unit", "Engine lifecycle");
659
+ life.t("getState() is CREATED before run()", () => {
660
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]));
661
+ if (engine.getState() !== "CREATED")
662
+ throw new Error(engine.getState());
663
+ });
664
+ life.t("calling run() twice throws EXECUTION_ALREADY_STARTED", async () => {
665
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]));
666
+ const promise = engine.run();
667
+ try {
668
+ engine.run();
669
+ throw new Error("expected throw");
670
+ }
671
+ catch (error) {
672
+ if (!(error instanceof automategraph_1.AutomateGraphError))
673
+ throw error;
674
+ if (error.code !== "EXECUTION_ALREADY_STARTED")
675
+ throw new Error(error.code);
676
+ }
677
+ await promise;
678
+ });
679
+ life.t("run() returns a Promise", () => {
680
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]));
681
+ const promise = engine.run();
682
+ if (typeof promise.then !== "function")
683
+ throw new Error("not a promise");
684
+ return promise;
685
+ });
686
+ life.t("read-only accessors return defensive copies of errors", async () => {
687
+ const definition = (0, helpers_1.g)({
688
+ nodes: [(0, helpers_1.node)("a", () => { throw new Error("x"); })],
689
+ entryNodes: ["a"],
690
+ terminalNodes: ["a"]
691
+ });
692
+ const engine = new automategraph_1.GraphExecutionEngine(definition);
693
+ await engine.run();
694
+ const first = engine.getErrors();
695
+ if (first.length !== 1)
696
+ throw new Error("expected one error");
697
+ first.length = 0;
698
+ if (engine.getErrors().length !== 1)
699
+ throw new Error("internal array leaked");
700
+ });
701
+ life.t("read-only accessors return defensive copies of results", async () => {
702
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]));
703
+ await engine.run();
704
+ const first = engine.getResults();
705
+ first[0].nodeId = "tampered";
706
+ if (engine.getResults()[0].nodeId !== "a")
707
+ throw new Error("internal object leaked");
708
+ });
709
+ life.t("duplicate plugin ids are rejected with DUPLICATE_PLUGIN", async () => {
710
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]), {
711
+ plugins: [(0, helpers_1.plugin)({ id: "dup" }), (0, helpers_1.plugin)({ id: "dup", onInitialize: () => undefined })]
712
+ });
713
+ const report = await engine.run();
714
+ if (report.status !== "REJECTED")
715
+ throw new Error(report.status);
716
+ if (report.errors[0].code !== "DUPLICATE_PLUGIN")
717
+ throw new Error(report.errors[0].code);
718
+ });
719
+ life.t("a plugin reusing the storage id is rejected in the constructor", () => {
720
+ const storage = new automategraph_1.InMemoryStorage();
721
+ try {
722
+ new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]), {
723
+ storage,
724
+ plugins: [(0, helpers_1.plugin)({ id: storage.id })]
725
+ });
726
+ throw new Error("expected throw");
727
+ }
728
+ catch (error) {
729
+ if (!(error instanceof automategraph_1.GraphValidationError))
730
+ throw error;
731
+ if (!/already reserved by Storage/.test(error.message))
732
+ throw new Error(error.message);
733
+ }
734
+ });
735
+ life.t("optional plugin failure is tolerated and logged", async () => {
736
+ const { lines, onLog } = (0, helpers_1.logCollector)();
737
+ const events = [];
738
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]), {
739
+ onLog,
740
+ plugins: [
741
+ (0, helpers_1.plugin)({
742
+ id: "optional-broken",
743
+ required: false,
744
+ onInitialize: () => { throw new Error("no creds"); }
745
+ }),
746
+ (0, helpers_1.plugin)({ id: "healthy", onInitialize: () => { events.push("healthy-up"); } })
747
+ ]
748
+ });
749
+ const report = await engine.run();
750
+ if (report.status !== "COMPLETED")
751
+ throw new Error(report.status);
752
+ if (!lines.some(line => line.message === "Plugin initialization failed: optional-broken")) {
753
+ throw new Error("not logged");
754
+ }
755
+ if (events.includes("healthy-up") !== true)
756
+ throw new Error("healthy plugin skipped");
757
+ });
758
+ life.t("a required plugin failure REJECTS the run and shuts down initialized plugins", async () => {
759
+ const events = [];
760
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]), {
761
+ plugins: [
762
+ (0, helpers_1.plugin)({ id: "first", onInitialize: () => { events.push("first-up"); }, onClose: () => { events.push("first-down"); } }),
763
+ (0, helpers_1.plugin)({ id: "second", required: true, onInitialize: () => { throw new Error("fatal"); } })
764
+ ]
765
+ });
766
+ const report = await engine.run();
767
+ if (report.status !== "REJECTED")
768
+ throw new Error(report.status);
769
+ if (report.errors[0].code !== "PLUGIN_INITIALIZATION_ERROR")
770
+ throw new Error(report.errors[0].code);
771
+ if (!events.includes("first-down"))
772
+ throw new Error("prior plugin not closed");
773
+ if (report.results.length !== 0)
774
+ throw new Error("nodes ran after rejection");
775
+ });
776
+ life.t("plugins close in reverse initialization order after a successful run", async () => {
777
+ const events = [];
778
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]), {
779
+ plugins: [
780
+ (0, helpers_1.plugin)({ id: "p1", onClose: () => { events.push("close-1"); } }),
781
+ (0, helpers_1.plugin)({ id: "p2", onClose: () => { events.push("close-2"); } }),
782
+ (0, helpers_1.plugin)({ id: "p3", onClose: () => { events.push("close-3"); } })
783
+ ]
784
+ });
785
+ await engine.run();
786
+ const closes = events.filter(event => event.startsWith("close-"));
787
+ if (closes.join(",") !== "close-3,close-2,close-1")
788
+ throw new Error(closes.join(","));
789
+ });
790
+ /* =========================================================
791
+ * 7. Execution & scheduler — 20
792
+ * ========================================================= */
793
+ const sch = (0, harness_1.suite)("unit", "Execution & scheduler");
794
+ sch.t("single-node graph completes and stores its output", async () => {
795
+ const report = await new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"], () => 42)).run();
796
+ if (report.status !== "COMPLETED")
797
+ throw new Error(report.status);
798
+ if (report.outputs.a !== 42)
799
+ throw new Error(String(report.outputs.a));
800
+ if (!report.logicallyCompleted)
801
+ throw new Error("logicallyCompleted=false");
802
+ });
803
+ sch.t("linear graph runs every node in order", async () => {
804
+ const order = [];
805
+ const report = await new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a", "b", "c"], id => { order.push(id); return id; })).run();
806
+ if (order.join(",") !== "a,b,c")
807
+ throw new Error(order.join(","));
808
+ if (report.results.length !== 3)
809
+ throw new Error(`results=${report.results.length}`);
810
+ });
811
+ sch.t("diamond graph fans out and converges", async () => {
812
+ const definition = (0, helpers_1.g)({
813
+ nodes: [
814
+ (0, helpers_1.node)("a", () => "A", { next: ["b", "c"] }),
815
+ (0, helpers_1.constNode)("b", "B", { next: ["d"] }),
816
+ (0, helpers_1.constNode)("c", "C", { next: ["d"] }),
817
+ (0, helpers_1.constNode)("d", "D")
818
+ ],
819
+ entryNodes: ["a"],
820
+ terminalNodes: ["d"]
821
+ });
822
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
823
+ if (report.status !== "COMPLETED")
824
+ throw new Error(report.status);
825
+ const dRuns = report.results.filter(result => result.nodeId === "d").length;
826
+ if (dRuns !== 2)
827
+ throw new Error(`d ran ${dRuns} times (expected per-activation fan-in)`);
828
+ });
829
+ sch.t("independent entry nodes both execute", async () => {
830
+ const definition = (0, helpers_1.g)({
831
+ nodes: [(0, helpers_1.constNode)("a", "A"), (0, helpers_1.constNode)("b", "B")],
832
+ entryNodes: ["a", "b"],
833
+ terminalNodes: ["a"]
834
+ });
835
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
836
+ const ran = new Set(report.results.map(result => result.nodeId));
837
+ if (!ran.has("a") || !ran.has("b"))
838
+ throw new Error([...ran].join(","));
839
+ });
840
+ sch.t("multiple terminal nodes are both recognized", async () => {
841
+ const definition = (0, helpers_1.g)({
842
+ nodes: [(0, helpers_1.node)("a", () => "A", { next: ["b", "c"] }), (0, helpers_1.constNode)("b", "B"), (0, helpers_1.constNode)("c", "C")],
843
+ entryNodes: ["a"],
844
+ terminalNodes: ["b", "c"]
845
+ });
846
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
847
+ if (report.status !== "COMPLETED")
848
+ throw new Error(report.status);
849
+ });
850
+ sch.t("node context exposes execution metadata", async () => {
851
+ let seen = {};
852
+ const definition = (0, helpers_1.g)({
853
+ nodes: [
854
+ (0, helpers_1.node)("a", context => {
855
+ seen = {
856
+ executionId: context.executionId,
857
+ graphId: context.graphId,
858
+ nodeId: context.nodeId,
859
+ attempt: context.attempt,
860
+ hasSignal: context.signal instanceof AbortSignal
861
+ };
862
+ return "ok";
863
+ })
864
+ ],
865
+ entryNodes: ["a"],
866
+ terminalNodes: ["a"]
867
+ });
868
+ const engine = new automategraph_1.GraphExecutionEngine(definition);
869
+ await engine.run();
870
+ if (seen.executionId !== engine.getExecutionId())
871
+ throw new Error("executionId");
872
+ if (seen.graphId !== "test-graph")
873
+ throw new Error(String(seen.graphId));
874
+ if (seen.nodeId !== "a")
875
+ throw new Error(String(seen.nodeId));
876
+ if (seen.attempt !== 1)
877
+ throw new Error(String(seen.attempt));
878
+ if (seen.hasSignal !== true)
879
+ throw new Error("signal missing");
880
+ });
881
+ sch.t("activationId is unique per activation", async () => {
882
+ const definition = (0, helpers_1.g)({
883
+ nodes: [(0, helpers_1.node)("a", () => 1, { next: ["b"] }), (0, helpers_1.constNode)("b", 2)],
884
+ entryNodes: ["a"],
885
+ terminalNodes: ["b"]
886
+ });
887
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
888
+ const ids = report.results.map(result => result.activationId);
889
+ if (new Set(ids).size !== ids.length)
890
+ throw new Error("duplicate activationIds");
891
+ });
892
+ sch.t("node output can be read from a successor's context.outputs", async () => {
893
+ let observed;
894
+ const definition = (0, helpers_1.g)({
895
+ nodes: [
896
+ (0, helpers_1.constNode)("a", { value: 7 }, { next: ["b"] }),
897
+ (0, helpers_1.node)("b", context => { observed = context.outputs.a; return null; })
898
+ ],
899
+ entryNodes: ["a"],
900
+ terminalNodes: ["b"]
901
+ });
902
+ await new automategraph_1.GraphExecutionEngine(definition).run();
903
+ if (observed.value !== 7)
904
+ throw new Error("output not propagated");
905
+ });
906
+ sch.t("context.outputs freezing blocks mutation (strict runtime)", async () => {
907
+ let threw = false;
908
+ const definition = (0, helpers_1.g)({
909
+ nodes: [
910
+ (0, helpers_1.constNode)("a", 1, { next: ["b"] }),
911
+ (0, helpers_1.node)("b", context => {
912
+ try {
913
+ context.outputs.b = 2;
914
+ }
915
+ catch {
916
+ threw = true;
917
+ }
918
+ return null;
919
+ })
920
+ ],
921
+ entryNodes: ["a"],
922
+ terminalNodes: ["b"]
923
+ });
924
+ await new automategraph_1.GraphExecutionEngine(definition).run();
925
+ if (!threw)
926
+ throw new Error("outputs not frozen");
927
+ });
928
+ sch.t("async node functions are awaited", async () => {
929
+ const definition = (0, helpers_1.g)({
930
+ nodes: [(0, helpers_1.node)("a", async () => { await (0, helpers_1.sleep)(15); return "late"; })],
931
+ entryNodes: ["a"],
932
+ terminalNodes: ["a"]
933
+ });
934
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
935
+ if (report.outputs.a !== "late")
936
+ throw new Error(String(report.outputs.a));
937
+ });
938
+ sch.t("reaching the terminal marks logicallyCompleted and stops scheduling", async () => {
939
+ const definition = (0, helpers_1.g)({
940
+ nodes: [(0, helpers_1.node)("a", () => "A", { next: ["b"] }), (0, helpers_1.constNode)("b", "B", { next: ["c"] }), (0, helpers_1.constNode)("c", "C")],
941
+ entryNodes: ["a"],
942
+ terminalNodes: ["b"]
943
+ });
944
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
945
+ if (!report.logicallyCompleted)
946
+ throw new Error("not completed logically");
947
+ if (report.results.some(result => result.nodeId === "c"))
948
+ throw new Error("c ran past the terminal");
949
+ });
950
+ sch.t("the graph exhausts without a terminal => FAILED + GRAPH_TERMINAL_NOT_REACHED", async () => {
951
+ const definition = (0, helpers_1.g)({
952
+ // "sink" is a declared terminal node that nothing ever routes to;
953
+ // "a" runs with no successor, so executable work is exhausted.
954
+ nodes: [(0, helpers_1.constNode)("a", "dead-end"), (0, helpers_1.constNode)("sink", "unreachable")],
955
+ entryNodes: ["a"],
956
+ terminalNodes: ["sink"]
957
+ });
958
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
959
+ if (report.status !== "FAILED")
960
+ throw new Error(report.status);
961
+ const codes = report.errors.map(error => error.code);
962
+ if (!codes.includes("GRAPH_TERMINAL_NOT_REACHED"))
963
+ throw new Error(codes.join(","));
964
+ });
965
+ sch.t("node.started / node.succeeded events fire with payloads", async () => {
966
+ const definition = (0, helpers_1.linear)(["a", "b"]);
967
+ const engine = new automategraph_1.GraphExecutionEngine(definition);
968
+ const captured = (0, helpers_1.capture)(engine, ["graph.ready", "node.started", "node.succeeded", "graph.logicallyCompleted", "graph.finished"]);
969
+ await engine.run();
970
+ const names = captured.map(entry => entry.event);
971
+ if (names[0] !== "graph.ready")
972
+ throw new Error(names[0]);
973
+ if (!names.includes("node.started") || !names.includes("node.succeeded"))
974
+ throw new Error(names.join(","));
975
+ if (names[names.length - 1] !== "graph.finished")
976
+ throw new Error(names[names.length - 1]);
977
+ const started = captured.find(entry => entry.event === "node.started").payload;
978
+ if (started.attempt !== 1)
979
+ throw new Error("bad attempt");
980
+ });
981
+ sch.t("node.started precedes node.succeeded per node", async () => {
982
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]));
983
+ const captured = (0, helpers_1.capture)(engine, ["node.started", "node.succeeded"]);
984
+ await engine.run();
985
+ if (captured[0].event !== "node.started" || captured[1].event !== "node.succeeded") {
986
+ throw new Error(captured.map(c => c.event).join(","));
987
+ }
988
+ });
989
+ sch.t("context.emit() forwards to the bus", async () => {
990
+ const seen = [];
991
+ const definition = (0, helpers_1.g)({
992
+ nodes: [(0, helpers_1.node)("a", async (context) => { await context.emit("custom.event", { hi: true }); return 1; })],
993
+ entryNodes: ["a"],
994
+ terminalNodes: ["a"]
995
+ });
996
+ const engine = new automategraph_1.GraphExecutionEngine(definition);
997
+ engine.events.on("custom.event", payload => { seen.push(payload); }, { required: true });
998
+ await engine.run();
999
+ if (seen[0].hi !== true)
1000
+ throw new Error("payload lost");
1001
+ });
1002
+ sch.t("checkpoints are written once per successful node by default", async () => {
1003
+ const storage = new helpers_1.SpyStorage();
1004
+ const definition = (0, helpers_1.linear)(["a", "b", "c"]);
1005
+ await new automategraph_1.GraphExecutionEngine(definition, { storage }).run();
1006
+ if (storage.calls !== 3)
1007
+ throw new Error(`checkpoints=${storage.calls}`);
1008
+ });
1009
+ sch.t("checkpointEveryNode: false disables node checkpoints", async () => {
1010
+ const storage = new helpers_1.SpyStorage();
1011
+ const definition = (0, helpers_1.linear)(["a", "b", "c"], undefined, { checkpointEveryNode: false });
1012
+ await new automategraph_1.GraphExecutionEngine(definition, { storage }).run();
1013
+ if (storage.calls !== 0)
1014
+ throw new Error(`checkpoints=${storage.calls}`);
1015
+ });
1016
+ sch.t("snapshots record outputs, plugin versions and the execution id", async () => {
1017
+ const storage = new helpers_1.SpyStorage();
1018
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a", "b"], (id) => `${id}!`), {
1019
+ storage,
1020
+ plugins: [(0, helpers_1.plugin)({ id: "metrics-plugin", version: "4.5.6" })]
1021
+ });
1022
+ const report = await engine.run();
1023
+ const snapshot = storage.snapshots[storage.snapshots.length - 1];
1024
+ if (snapshot.executionId !== report.executionId)
1025
+ throw new Error("executionId");
1026
+ if (snapshot.outputs.a !== "a!" || snapshot.outputs.b !== "b!")
1027
+ throw new Error("outputs missing");
1028
+ if (snapshot.pluginVersions["metrics-plugin"] !== "4.5.6")
1029
+ throw new Error("plugin versions missing");
1030
+ if (snapshot.status !== "RUNNING")
1031
+ throw new Error(`status=${snapshot.status}`);
1032
+ });
1033
+ sch.t("successful run yields zero errors and cloneable outputs", async () => {
1034
+ const report = await new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a", "b"])).run();
1035
+ if (report.errors.length !== 0)
1036
+ throw new Error(String(report.errors.length));
1037
+ const clone = JSON.parse(JSON.stringify(report.outputs));
1038
+ if (clone.a !== "a-out")
1039
+ throw new Error("clone failed");
1040
+ });
1041
+ sch.t("node.started listener sees the context emitted by NodeContext.emit", async () => {
1042
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]));
1043
+ const captured = (0, helpers_1.capture)(engine, ["node.started"]);
1044
+ await engine.run();
1045
+ const payload = captured[0].payload;
1046
+ if (payload.executionId !== engine.getExecutionId())
1047
+ throw new Error("bad executionId");
1048
+ if (typeof payload.activationId !== "string")
1049
+ throw new Error("bad activationId");
1050
+ });
1051
+ /* =========================================================
1052
+ * 8. Retry — 6
1053
+ * ========================================================= */
1054
+ const rty = (0, harness_1.suite)("unit", "Retry");
1055
+ rty.t("errorPolicy RETRY re-runs until success and records the final attempt", async () => {
1056
+ let attempts = 0;
1057
+ const definition = (0, helpers_1.g)({
1058
+ nodes: [
1059
+ (0, helpers_1.node)("flaky", () => {
1060
+ attempts += 1;
1061
+ if (attempts < 3)
1062
+ throw new Error(`fail-${attempts}`);
1063
+ return "recovered";
1064
+ }, { retry: { maxAttempts: 3, delayMs: 1 }, errorPolicy: "RETRY" })
1065
+ ],
1066
+ entryNodes: ["flaky"],
1067
+ terminalNodes: ["flaky"]
1068
+ });
1069
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
1070
+ if (attempts !== 3)
1071
+ throw new Error(`attempts=${attempts}`);
1072
+ if (report.results[0].attempt !== 3)
1073
+ throw new Error(`attempt=${report.results[0].attempt}`);
1074
+ if (report.results[0].status !== "SUCCEEDED")
1075
+ throw new Error(report.results[0].status);
1076
+ if (report.errors.length !== 0)
1077
+ throw new Error("errors recorded despite recovery");
1078
+ });
1079
+ rty.t("RETRY respects delayMs between attempts", async () => {
1080
+ const marks = [];
1081
+ const definition = (0, helpers_1.g)({
1082
+ nodes: [
1083
+ (0, helpers_1.node)("flaky", () => {
1084
+ marks.push(Date.now());
1085
+ if (marks.length < 3)
1086
+ throw new Error("again");
1087
+ return "ok";
1088
+ }, { retry: { maxAttempts: 3, delayMs: 40 }, errorPolicy: "RETRY" })
1089
+ ],
1090
+ entryNodes: ["flaky"],
1091
+ terminalNodes: ["flaky"]
1092
+ });
1093
+ await new automategraph_1.GraphExecutionEngine(definition).run();
1094
+ const firstGap = marks[1] - marks[0];
1095
+ if (firstGap < 30)
1096
+ throw new Error(`gap=${firstGap}ms (delay ignored)`);
1097
+ });
1098
+ rty.t("exhausting maxAttempts records one unhandled error and terminates", async () => {
1099
+ let attempts = 0;
1100
+ const definition = (0, helpers_1.g)({
1101
+ nodes: [
1102
+ (0, helpers_1.node)("always-fails", () => { attempts += 1; throw new Error("nope"); }, { retry: { maxAttempts: 3 }, errorPolicy: "RETRY" })
1103
+ ],
1104
+ entryNodes: ["always-fails"],
1105
+ terminalNodes: ["always-fails"]
1106
+ });
1107
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
1108
+ if (attempts !== 3)
1109
+ throw new Error(`attempts=${attempts}`);
1110
+ if (report.status !== "FAILED")
1111
+ throw new Error(report.status);
1112
+ if (report.errors.length !== 1)
1113
+ throw new Error(`errors=${report.errors.length}`);
1114
+ if (report.errors[0].handled)
1115
+ throw new Error("should be unhandled");
1116
+ });
1117
+ rty.t("RETRY without delayMs retries without added latency", async () => {
1118
+ let attempts = 0;
1119
+ const definition = (0, helpers_1.g)({
1120
+ nodes: [
1121
+ (0, helpers_1.node)("flaky", () => {
1122
+ attempts += 1;
1123
+ if (attempts < 2)
1124
+ throw new Error("once");
1125
+ return "fine";
1126
+ }, { retry: { maxAttempts: 2 }, errorPolicy: "RETRY" })
1127
+ ],
1128
+ entryNodes: ["flaky"],
1129
+ terminalNodes: ["flaky"]
1130
+ });
1131
+ const started = Date.now();
1132
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
1133
+ if (Date.now() - started > 200)
1134
+ throw new Error("unexpected delay");
1135
+ if (report.status !== "COMPLETED")
1136
+ throw new Error(report.status);
1137
+ });
1138
+ rty.t("retry maxAttempts=1 means a single execution", async () => {
1139
+ let attempts = 0;
1140
+ const definition = (0, helpers_1.g)({
1141
+ nodes: [
1142
+ (0, helpers_1.node)("one-shot", () => { attempts += 1; throw new Error("x"); }, { retry: { maxAttempts: 1 } })
1143
+ ],
1144
+ entryNodes: ["one-shot"],
1145
+ terminalNodes: ["one-shot"]
1146
+ });
1147
+ await new automategraph_1.GraphExecutionEngine(definition).run();
1148
+ if (attempts !== 1)
1149
+ throw new Error(`attempts=${attempts}`);
1150
+ });
1151
+ rty.t("once retries succeed the successor nodes are scheduled", async () => {
1152
+ let attempts = 0;
1153
+ const definition = (0, helpers_1.g)({
1154
+ nodes: [
1155
+ (0, helpers_1.node)("flaky", () => {
1156
+ attempts += 1;
1157
+ if (attempts === 1)
1158
+ throw new Error("first");
1159
+ return "second-wins";
1160
+ }, { retry: { maxAttempts: 2 }, errorPolicy: "RETRY", next: ["tail"] }),
1161
+ (0, helpers_1.constNode)("tail", "done")
1162
+ ],
1163
+ entryNodes: ["flaky"],
1164
+ terminalNodes: ["tail"]
1165
+ });
1166
+ const report = await new automategraph_1.GraphExecutionEngine(definition).run();
1167
+ if (report.status !== "COMPLETED")
1168
+ throw new Error(report.status);
1169
+ if (report.outputs.flaky !== "second-wins")
1170
+ throw new Error("stale output");
1171
+ });
1172
+ /* =========================================================
1173
+ * 9. Cancellation — 5
1174
+ * ========================================================= */
1175
+ const can = (0, harness_1.suite)("unit", "Cancellation");
1176
+ can.t("cancel() on a CREATED engine produces CANCELLED", async () => {
1177
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a", "b"]));
1178
+ await engine.cancel();
1179
+ const report = await engine.run();
1180
+ if (report.status !== "CANCELLED")
1181
+ throw new Error(report.status);
1182
+ if (report.results.length !== 0)
1183
+ throw new Error("nodes ran");
1184
+ });
1185
+ can.t("cancel() aborts the execution signal exposed to running nodes", async () => {
1186
+ let observed = false;
1187
+ const definition = (0, helpers_1.g)({
1188
+ nodes: [
1189
+ (0, helpers_1.node)("a", async (context) => {
1190
+ await (0, helpers_1.sleep)(30);
1191
+ observed = context.signal.aborted;
1192
+ return "late";
1193
+ })
1194
+ ],
1195
+ entryNodes: ["a"],
1196
+ terminalNodes: ["a"]
1197
+ });
1198
+ const engine = new automategraph_1.GraphExecutionEngine(definition);
1199
+ const promise = engine.run();
1200
+ await (0, helpers_1.sleep)(5);
1201
+ await engine.cancel();
1202
+ const report = await promise;
1203
+ if (report.status !== "CANCELLED")
1204
+ throw new Error(report.status);
1205
+ if (!observed)
1206
+ throw new Error("signal never aborted");
1207
+ });
1208
+ can.t("cancel() emits graph.cancellationRequested", async () => {
1209
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]));
1210
+ const captured = (0, helpers_1.capture)(engine, ["graph.cancellationRequested"]);
1211
+ await engine.cancel();
1212
+ if (captured.length !== 1)
1213
+ throw new Error("not emitted");
1214
+ const payload = captured[0].payload;
1215
+ if (payload.executionId !== engine.getExecutionId())
1216
+ throw new Error("bad payload");
1217
+ });
1218
+ can.t("nodes in flight when cancelling settle as CANCELLED", async () => {
1219
+ const definition = (0, helpers_1.g)({
1220
+ nodes: [(0, helpers_1.node)("slow", async () => { await (0, helpers_1.sleep)(40); return "x"; })],
1221
+ entryNodes: ["slow"],
1222
+ terminalNodes: ["slow"]
1223
+ });
1224
+ const engine = new automategraph_1.GraphExecutionEngine(definition);
1225
+ const promise = engine.run();
1226
+ await (0, helpers_1.sleep)(5);
1227
+ await engine.cancel();
1228
+ const report = await promise;
1229
+ if (report.results[0].status !== "CANCELLED")
1230
+ throw new Error(report.results[0].status);
1231
+ });
1232
+ can.t("cancel() after COMPLETED is a no-op", async () => {
1233
+ const engine = new automategraph_1.GraphExecutionEngine((0, helpers_1.linear)(["a"]));
1234
+ await engine.run();
1235
+ await engine.cancel();
1236
+ if (engine.getState() !== "COMPLETED")
1237
+ throw new Error(engine.getState());
1238
+ });
1239
+ //# sourceMappingURL=unit.test.js.map