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