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,848 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* TYPED EDGES & EXPLICIT ROUTING PACK (v1.3)
|
|
3
|
+
* ------------------------------------------------------------
|
|
4
|
+
* Pins the new `next` contract:
|
|
5
|
+
*
|
|
6
|
+
* string → parallel edge (v1.0 behaviour)
|
|
7
|
+
* { nextNode, result } → exclusive, deep-equality matcher
|
|
8
|
+
* { nextNode, test } → exclusive, predicate matcher
|
|
9
|
+
* { nextNode } → exclusive else edge
|
|
10
|
+
* { nextNode, parallel: true } → unconditional, out of the chain
|
|
11
|
+
* return new Next(target, value)→ explicit route for the whole activation
|
|
12
|
+
* context.goto(target) + return value → same, for void/imperative nodes
|
|
13
|
+
*
|
|
14
|
+
* Plus the three new lint codes and the validation rules.
|
|
15
|
+
* ============================================================ */
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
AutomateGraphError,
|
|
19
|
+
GraphExecutionEngine,
|
|
20
|
+
GraphValidationError,
|
|
21
|
+
Next,
|
|
22
|
+
deepEquals,
|
|
23
|
+
lintGraph,
|
|
24
|
+
normalizeNextEdges
|
|
25
|
+
} from "../src/automategraph";
|
|
26
|
+
|
|
27
|
+
import { capture, constNode, g, logCollector, node, sleep } from "./helpers";
|
|
28
|
+
import { suite } from "./harness";
|
|
29
|
+
|
|
30
|
+
const edges = suite("edges", "Typed edges & routing");
|
|
31
|
+
|
|
32
|
+
const BIG = { timeoutMs: 20_000 };
|
|
33
|
+
|
|
34
|
+
/* ------------------------------ 1. compatibility ------------------------------ */
|
|
35
|
+
|
|
36
|
+
edges.t(
|
|
37
|
+
"string edges keep the v1.0 meaning: unconditional fan-out",
|
|
38
|
+
async t => {
|
|
39
|
+
const definition = g({
|
|
40
|
+
nodes: [
|
|
41
|
+
node("a", () => 1, { next: ["b", "c"] }),
|
|
42
|
+
constNode("b", "B"),
|
|
43
|
+
constNode("c", "C")
|
|
44
|
+
],
|
|
45
|
+
entryNodes: ["a"],
|
|
46
|
+
terminalNodes: ["b", "c"]
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
50
|
+
const ran = report.results.map(result => result.nodeId);
|
|
51
|
+
|
|
52
|
+
if (!ran.includes("b") || !ran.includes("c")) throw new Error(ran.join(","));
|
|
53
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
54
|
+
|
|
55
|
+
const normalized = normalizeNextEdges(definition.nodes.a.next);
|
|
56
|
+
if (normalized.length !== 2) throw new Error(`normalized=${normalized.length}`);
|
|
57
|
+
if (!normalized.every(edge => edge.parallel)) throw new Error("strings must normalize to parallel");
|
|
58
|
+
|
|
59
|
+
t.log("finding: `next: [\"b\",\"c\"]` normalizes to two parallel edges — every existing graph keeps its behaviour");
|
|
60
|
+
},
|
|
61
|
+
BIG
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
/* ------------------------------ 2. exclusive conditionals ------------------------------ */
|
|
65
|
+
|
|
66
|
+
edges.t(
|
|
67
|
+
"result matches by deep equality and only the first matching edge fires",
|
|
68
|
+
async t => {
|
|
69
|
+
const definition = g({
|
|
70
|
+
nodes: [
|
|
71
|
+
node("a", () => 1, {
|
|
72
|
+
next: [
|
|
73
|
+
{ result: 1, nextNode: "b" },
|
|
74
|
+
{ result: 2, nextNode: "c" },
|
|
75
|
+
{ nextNode: "d" }
|
|
76
|
+
]
|
|
77
|
+
}),
|
|
78
|
+
constNode("b", "B", { next: ["end"] }),
|
|
79
|
+
constNode("c", "C", { next: ["end"] }),
|
|
80
|
+
constNode("d", "D", { next: ["end"] }),
|
|
81
|
+
constNode("end", "END")
|
|
82
|
+
],
|
|
83
|
+
entryNodes: ["a"],
|
|
84
|
+
terminalNodes: ["end"]
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
88
|
+
const ran = report.results.map(result => result.nodeId);
|
|
89
|
+
|
|
90
|
+
if (!ran.includes("b")) throw new Error("result: 1 did not route to b");
|
|
91
|
+
if (ran.includes("c") || ran.includes("d")) throw new Error(`extra branches ran: ${ran.join(",")}`);
|
|
92
|
+
if (report.outputs.end !== "END") throw new Error("end never ran");
|
|
93
|
+
},
|
|
94
|
+
BIG
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
edges.t(
|
|
98
|
+
"deep equality matches nested objects regardless of key order",
|
|
99
|
+
async t => {
|
|
100
|
+
const definition = g({
|
|
101
|
+
nodes: [
|
|
102
|
+
node("a", () => ({ tags: ["x", "y"], id: 7 }), {
|
|
103
|
+
next: [
|
|
104
|
+
{ result: { id: 7, tags: ["x", "y"] }, nextNode: "b" },
|
|
105
|
+
{ nextNode: "d" }
|
|
106
|
+
]
|
|
107
|
+
}),
|
|
108
|
+
constNode("b", "B"),
|
|
109
|
+
constNode("d", "D")
|
|
110
|
+
],
|
|
111
|
+
entryNodes: ["a"],
|
|
112
|
+
terminalNodes: ["b", "d"]
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
116
|
+
const ran = report.results.map(result => result.nodeId);
|
|
117
|
+
|
|
118
|
+
if (!ran.includes("b")) throw new Error(`deep-equality match failed: ${ran.join(",")}`);
|
|
119
|
+
if (ran.includes("d")) throw new Error("else edge fired although a result matched");
|
|
120
|
+
|
|
121
|
+
t.log("finding: result uses structural equality (key order, nesting and array order aware)");
|
|
122
|
+
},
|
|
123
|
+
BIG
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
edges.t(
|
|
127
|
+
"result: NaN matches a NaN output (Object.is semantics)",
|
|
128
|
+
async t => {
|
|
129
|
+
const definition = g({
|
|
130
|
+
nodes: [
|
|
131
|
+
node("a", () => Number.NaN, {
|
|
132
|
+
next: [
|
|
133
|
+
{ result: Number.NaN, nextNode: "b" },
|
|
134
|
+
{ nextNode: "d" }
|
|
135
|
+
]
|
|
136
|
+
}),
|
|
137
|
+
constNode("b", "B"),
|
|
138
|
+
constNode("d", "D")
|
|
139
|
+
],
|
|
140
|
+
entryNodes: ["a"],
|
|
141
|
+
terminalNodes: ["b", "d"]
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
145
|
+
const ran = report.results.map(result => result.nodeId);
|
|
146
|
+
|
|
147
|
+
if (!ran.includes("b")) throw new Error(`NaN did not match: ${ran.join(",")}`);
|
|
148
|
+
if (ran.includes("d")) throw new Error("else fired too");
|
|
149
|
+
|
|
150
|
+
if (!deepEquals(Number.NaN, Number.NaN)) throw new Error("deepEquals(NaN, NaN) is false");
|
|
151
|
+
if (deepEquals(0, -0)) throw new Error("deepEquals(0, -0) should be false (Object.is)");
|
|
152
|
+
},
|
|
153
|
+
BIG
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
edges.t(
|
|
157
|
+
"result: undefined matches an undefined output (presence, not truthiness)",
|
|
158
|
+
async t => {
|
|
159
|
+
const definition = g({
|
|
160
|
+
nodes: [
|
|
161
|
+
node("a", () => undefined, {
|
|
162
|
+
next: [
|
|
163
|
+
{ result: undefined, nextNode: "b" },
|
|
164
|
+
{ nextNode: "d" }
|
|
165
|
+
]
|
|
166
|
+
}),
|
|
167
|
+
constNode("b", "B"),
|
|
168
|
+
constNode("d", "D")
|
|
169
|
+
],
|
|
170
|
+
entryNodes: ["a"],
|
|
171
|
+
terminalNodes: ["b", "d"]
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
175
|
+
const ran = report.results.map(result => result.nodeId);
|
|
176
|
+
|
|
177
|
+
if (!ran.includes("b")) throw new Error(`undefined did not match: ${ran.join(",")}`);
|
|
178
|
+
if (ran.includes("d")) throw new Error("else fired too");
|
|
179
|
+
|
|
180
|
+
t.log("finding: hasResult tracks key presence, so an undefined output is matchable");
|
|
181
|
+
},
|
|
182
|
+
BIG
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
edges.t(
|
|
186
|
+
"test predicate routes the activation",
|
|
187
|
+
async t => {
|
|
188
|
+
const definition = g({
|
|
189
|
+
nodes: [
|
|
190
|
+
node("a", () => 20, {
|
|
191
|
+
next: [
|
|
192
|
+
{ test: value => typeof value === "number" && value > 10, nextNode: "b" },
|
|
193
|
+
{ nextNode: "d" }
|
|
194
|
+
]
|
|
195
|
+
}),
|
|
196
|
+
constNode("b", "B"),
|
|
197
|
+
constNode("d", "D")
|
|
198
|
+
],
|
|
199
|
+
entryNodes: ["a"],
|
|
200
|
+
terminalNodes: ["b", "d"]
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
204
|
+
const ran = report.results.map(result => result.nodeId);
|
|
205
|
+
|
|
206
|
+
if (!ran.includes("b") || ran.includes("d")) throw new Error(ran.join(","));
|
|
207
|
+
},
|
|
208
|
+
BIG
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
edges.t(
|
|
212
|
+
"two matching conditionals: declaration order wins",
|
|
213
|
+
async t => {
|
|
214
|
+
const definition = g({
|
|
215
|
+
nodes: [
|
|
216
|
+
node("a", () => 1, {
|
|
217
|
+
next: [
|
|
218
|
+
{ test: () => true, nextNode: "b" },
|
|
219
|
+
{ test: () => true, nextNode: "c" },
|
|
220
|
+
{ nextNode: "d" }
|
|
221
|
+
]
|
|
222
|
+
}),
|
|
223
|
+
constNode("b", "B"),
|
|
224
|
+
constNode("c", "C"),
|
|
225
|
+
constNode("d", "D")
|
|
226
|
+
],
|
|
227
|
+
entryNodes: ["a"],
|
|
228
|
+
terminalNodes: ["b", "c", "d"]
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
232
|
+
const ran = report.results.map(result => result.nodeId);
|
|
233
|
+
|
|
234
|
+
if (!ran.includes("b")) throw new Error("first matching edge did not win");
|
|
235
|
+
if (ran.includes("c") || ran.includes("d")) throw new Error(`later edges fired: ${ran.join(",")}`);
|
|
236
|
+
},
|
|
237
|
+
BIG
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
edges.t(
|
|
241
|
+
"result + test on one edge must both pass (AND)",
|
|
242
|
+
async t => {
|
|
243
|
+
const definition = g({
|
|
244
|
+
nodes: [
|
|
245
|
+
node("a", () => 5, {
|
|
246
|
+
next: [
|
|
247
|
+
{ result: 5, test: value => typeof value === "number" && value > 10, nextNode: "b" },
|
|
248
|
+
{ nextNode: "d" }
|
|
249
|
+
]
|
|
250
|
+
}),
|
|
251
|
+
constNode("b", "B"),
|
|
252
|
+
constNode("d", "D")
|
|
253
|
+
],
|
|
254
|
+
entryNodes: ["a"],
|
|
255
|
+
terminalNodes: ["b", "d"]
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
259
|
+
const ran = report.results.map(result => result.nodeId);
|
|
260
|
+
|
|
261
|
+
if (ran.includes("b")) throw new Error("AND failed: b ran although test() was false");
|
|
262
|
+
if (!ran.includes("d")) throw new Error("else edge should have fired");
|
|
263
|
+
},
|
|
264
|
+
BIG
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
edges.t(
|
|
268
|
+
"the else edge (no matcher) fires when nothing earlier matched",
|
|
269
|
+
async t => {
|
|
270
|
+
const definition = g({
|
|
271
|
+
nodes: [
|
|
272
|
+
node("a", () => 3, {
|
|
273
|
+
next: [
|
|
274
|
+
{ result: 1, nextNode: "b" },
|
|
275
|
+
{ test: value => value === 2, nextNode: "c" },
|
|
276
|
+
{ nextNode: "d" }
|
|
277
|
+
]
|
|
278
|
+
}),
|
|
279
|
+
constNode("b", "B"),
|
|
280
|
+
constNode("c", "C"),
|
|
281
|
+
constNode("d", "D")
|
|
282
|
+
],
|
|
283
|
+
entryNodes: ["a"],
|
|
284
|
+
terminalNodes: ["b", "c", "d"]
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
288
|
+
const ran = report.results.map(result => result.nodeId);
|
|
289
|
+
|
|
290
|
+
if (!ran.includes("d") || ran.includes("b") || ran.includes("c")) {
|
|
291
|
+
throw new Error(ran.join(","));
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
BIG
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
edges.t(
|
|
298
|
+
"no match is silent: nothing is scheduled, no routing error is recorded",
|
|
299
|
+
async t => {
|
|
300
|
+
const definition = g({
|
|
301
|
+
nodes: [
|
|
302
|
+
node("a", () => 42, {
|
|
303
|
+
next: [
|
|
304
|
+
{ result: 1, nextNode: "b" },
|
|
305
|
+
{ test: value => value === 2, nextNode: "c" }
|
|
306
|
+
]
|
|
307
|
+
}),
|
|
308
|
+
constNode("b", "B"),
|
|
309
|
+
constNode("c", "C")
|
|
310
|
+
],
|
|
311
|
+
entryNodes: ["a"],
|
|
312
|
+
terminalNodes: ["b"]
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
316
|
+
|
|
317
|
+
if (report.results.length !== 1) throw new Error(`results=${report.results.length}`);
|
|
318
|
+
if (report.status !== "FAILED") throw new Error(report.status);
|
|
319
|
+
if (report.errors.length !== 1) throw new Error(`errors=${report.errors.length}`);
|
|
320
|
+
if (report.errors[0].code !== "GRAPH_TERMINAL_NOT_REACHED") throw new Error(report.errors[0].code);
|
|
321
|
+
|
|
322
|
+
t.log("finding: an unmatched conditional chain is silent by design (no NO_EDGE_MATCHED error)");
|
|
323
|
+
},
|
|
324
|
+
BIG
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
/* ------------------------------ 3. parallel edges ------------------------------ */
|
|
328
|
+
|
|
329
|
+
edges.t(
|
|
330
|
+
"parallel edges fire alongside the winning conditional",
|
|
331
|
+
async t => {
|
|
332
|
+
const definition = g({
|
|
333
|
+
nodes: [
|
|
334
|
+
node("a", () => 1, {
|
|
335
|
+
next: [
|
|
336
|
+
{ result: 1, nextNode: "b" },
|
|
337
|
+
"audit"
|
|
338
|
+
]
|
|
339
|
+
}),
|
|
340
|
+
constNode("b", "B"),
|
|
341
|
+
constNode("audit", "AUDITED")
|
|
342
|
+
],
|
|
343
|
+
entryNodes: ["a"],
|
|
344
|
+
terminalNodes: ["b", "audit"]
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
348
|
+
const ran = report.results.map(result => result.nodeId);
|
|
349
|
+
|
|
350
|
+
if (!ran.includes("b")) throw new Error("conditional branch missing");
|
|
351
|
+
if (!ran.includes("audit")) throw new Error("parallel edge was suppressed by the conditional");
|
|
352
|
+
},
|
|
353
|
+
BIG
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
edges.t(
|
|
357
|
+
"a parallel edge is never blocked, even after a conditional match",
|
|
358
|
+
async t => {
|
|
359
|
+
const definition = g({
|
|
360
|
+
nodes: [
|
|
361
|
+
node("a", () => 7, {
|
|
362
|
+
next: [
|
|
363
|
+
{ test: () => true, nextNode: "b" },
|
|
364
|
+
"audit",
|
|
365
|
+
{ test: () => true, nextNode: "c" }
|
|
366
|
+
]
|
|
367
|
+
}),
|
|
368
|
+
constNode("b", "B"),
|
|
369
|
+
constNode("c", "C"),
|
|
370
|
+
constNode("audit", "AUDITED")
|
|
371
|
+
],
|
|
372
|
+
entryNodes: ["a"],
|
|
373
|
+
terminalNodes: ["b", "c", "audit"]
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
377
|
+
const ran = report.results.map(result => result.nodeId);
|
|
378
|
+
|
|
379
|
+
if (!ran.includes("b")) throw new Error("first conditional must win");
|
|
380
|
+
if (!ran.includes("audit")) throw new Error("parallel edge blocked by exclusivity");
|
|
381
|
+
if (ran.includes("c")) throw new Error("second conditional should be skipped");
|
|
382
|
+
},
|
|
383
|
+
BIG
|
|
384
|
+
);
|
|
385
|
+
|
|
386
|
+
edges.t(
|
|
387
|
+
"a parallel edge with a matcher is still unconditional (matcher ignored, lint warns)",
|
|
388
|
+
async t => {
|
|
389
|
+
const definition = g({
|
|
390
|
+
nodes: [
|
|
391
|
+
node("a", () => 1, {
|
|
392
|
+
next: [
|
|
393
|
+
"audit",
|
|
394
|
+
{ result: 999, parallel: true, nextNode: "b" }
|
|
395
|
+
]
|
|
396
|
+
}),
|
|
397
|
+
constNode("b", "B"),
|
|
398
|
+
constNode("audit", "AUDITED")
|
|
399
|
+
],
|
|
400
|
+
entryNodes: ["a"],
|
|
401
|
+
terminalNodes: ["b", "audit"]
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
405
|
+
const ran = report.results.map(result => result.nodeId);
|
|
406
|
+
|
|
407
|
+
if (!ran.includes("b") || !ran.includes("audit")) throw new Error(ran.join(","));
|
|
408
|
+
|
|
409
|
+
const issues = lintGraph(definition);
|
|
410
|
+
const warning = issues.find(issue => issue.code === "PARALLEL_EDGE_MATCHER_IGNORED");
|
|
411
|
+
if (!warning) throw new Error(`lint codes: ${issues.map(i => i.code).join(",")}`);
|
|
412
|
+
if (warning.level !== "warn") throw new Error(warning.level);
|
|
413
|
+
if (warning.nodeId !== "a") throw new Error(String(warning.nodeId));
|
|
414
|
+
},
|
|
415
|
+
BIG
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
/* ------------------------------ 4. explicit routing ------------------------------ */
|
|
419
|
+
|
|
420
|
+
edges.t(
|
|
421
|
+
"returning new Next(target, value) routes and successors see the unwrapped value",
|
|
422
|
+
async t => {
|
|
423
|
+
let seen: unknown;
|
|
424
|
+
const definition = g({
|
|
425
|
+
nodes: [
|
|
426
|
+
node("a", () => new Next("b", { k: 2 })),
|
|
427
|
+
node("b", context => { seen = context.outputs.a; return "B"; })
|
|
428
|
+
],
|
|
429
|
+
entryNodes: ["a"],
|
|
430
|
+
terminalNodes: ["b"]
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const engine = new GraphExecutionEngine(definition);
|
|
434
|
+
const succeeded = capture(engine, ["node.succeeded"]);
|
|
435
|
+
const report = await engine.run();
|
|
436
|
+
|
|
437
|
+
if (report.outputs.a === undefined) throw new Error("a produced no output");
|
|
438
|
+
if (!deepEquals(seen, { k: 2 })) throw new Error(`successor saw ${JSON.stringify(seen)}`);
|
|
439
|
+
if (report.outputs.b !== "B") throw new Error("b did not run");
|
|
440
|
+
|
|
441
|
+
const payload = succeeded.find(entry => (entry.payload as { nodeId: string }).nodeId === "a")!
|
|
442
|
+
.payload as { output: unknown; route?: { nextNode: string; via: string } };
|
|
443
|
+
|
|
444
|
+
if (!deepEquals(payload.output, { k: 2 })) throw new Error("event output was not unwrapped");
|
|
445
|
+
if (payload.route?.nextNode !== "b" || payload.route.via !== "next-class") {
|
|
446
|
+
throw new Error(`route payload wrong: ${JSON.stringify(payload.route)}`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
t.log("finding: Next carries both destination and payload — successors and matchers see the payload");
|
|
450
|
+
},
|
|
451
|
+
BIG
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
edges.t(
|
|
455
|
+
"context.goto(target) + return value routes for void/imperative nodes",
|
|
456
|
+
async t => {
|
|
457
|
+
const definition = g({
|
|
458
|
+
nodes: [
|
|
459
|
+
node("a", context => {
|
|
460
|
+
context.goto("f");
|
|
461
|
+
return "payload"; // stays a's output
|
|
462
|
+
}),
|
|
463
|
+
constNode("f", "F")
|
|
464
|
+
],
|
|
465
|
+
entryNodes: ["a"],
|
|
466
|
+
terminalNodes: ["f"]
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
470
|
+
|
|
471
|
+
if (report.outputs.a !== "payload") throw new Error(String(report.outputs.a));
|
|
472
|
+
if (report.outputs.f !== "F") throw new Error("goto target did not run");
|
|
473
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
474
|
+
},
|
|
475
|
+
BIG
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
edges.t(
|
|
479
|
+
"a void node can route with goto alone",
|
|
480
|
+
async t => {
|
|
481
|
+
const definition = g({
|
|
482
|
+
nodes: [
|
|
483
|
+
node("b", context => { context.goto("f"); return undefined; }),
|
|
484
|
+
constNode("f", "F")
|
|
485
|
+
],
|
|
486
|
+
entryNodes: ["b"],
|
|
487
|
+
terminalNodes: ["f"]
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
491
|
+
if (report.outputs.f !== "F") throw new Error("f never ran");
|
|
492
|
+
if (report.outputs.b !== undefined) throw new Error("void node recorded an output");
|
|
493
|
+
},
|
|
494
|
+
BIG
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
edges.t(
|
|
498
|
+
"an explicit route replaces the whole edge list, parallel edges included",
|
|
499
|
+
async t => {
|
|
500
|
+
const definition = g({
|
|
501
|
+
nodes: [
|
|
502
|
+
node("a", () => new Next("c", 1), {
|
|
503
|
+
next: ["audit", { result: 1, nextNode: "b" }]
|
|
504
|
+
}),
|
|
505
|
+
constNode("b", "B"),
|
|
506
|
+
constNode("c", "C"),
|
|
507
|
+
constNode("audit", "AUDITED")
|
|
508
|
+
],
|
|
509
|
+
entryNodes: ["a"],
|
|
510
|
+
terminalNodes: ["b", "c", "audit"]
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
514
|
+
const ran = report.results.map(result => result.nodeId);
|
|
515
|
+
|
|
516
|
+
if (!ran.includes("c")) throw new Error("explicit target did not run");
|
|
517
|
+
if (ran.includes("b") || ran.includes("audit")) {
|
|
518
|
+
throw new Error(`edge list still fired: ${ran.join(",")}`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
t.log("finding: an explicit route is authoritative — declared edges (and parallel ones) are ignored");
|
|
522
|
+
},
|
|
523
|
+
BIG
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
edges.t(
|
|
527
|
+
"return new Next wins over context.goto when both are used",
|
|
528
|
+
async t => {
|
|
529
|
+
const definition = g({
|
|
530
|
+
nodes: [
|
|
531
|
+
node("a", context => {
|
|
532
|
+
context.goto("c");
|
|
533
|
+
return new Next("b", 1);
|
|
534
|
+
}),
|
|
535
|
+
constNode("b", "B"),
|
|
536
|
+
constNode("c", "C")
|
|
537
|
+
],
|
|
538
|
+
entryNodes: ["a"],
|
|
539
|
+
terminalNodes: ["b", "c"]
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
543
|
+
const ran = report.results.map(result => result.nodeId);
|
|
544
|
+
|
|
545
|
+
if (!ran.includes("b")) throw new Error("Next should take precedence");
|
|
546
|
+
if (ran.includes("c")) throw new Error(`goto also fired: ${ran.join(",")}`);
|
|
547
|
+
},
|
|
548
|
+
BIG
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
edges.t(
|
|
552
|
+
"goto works per attempt: the route is taken by the successful attempt",
|
|
553
|
+
async t => {
|
|
554
|
+
const definition = g({
|
|
555
|
+
nodes: [
|
|
556
|
+
node("a", context => {
|
|
557
|
+
if (context.attempt === 1) throw new Error("transient");
|
|
558
|
+
context.goto("f");
|
|
559
|
+
return `ok-after-${context.attempt}`;
|
|
560
|
+
}, { retry: { maxAttempts: 2, delayMs: 5 }, errorPolicy: "RETRY", next: [] }),
|
|
561
|
+
constNode("f", "F")
|
|
562
|
+
],
|
|
563
|
+
entryNodes: ["a"],
|
|
564
|
+
terminalNodes: ["f"]
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
568
|
+
const result = report.results.find(entry => entry.nodeId === "a")!;
|
|
569
|
+
|
|
570
|
+
if (result.attempt !== 2) throw new Error(`attempt=${result.attempt}`);
|
|
571
|
+
if (report.outputs.a !== "ok-after-2") throw new Error(String(report.outputs.a));
|
|
572
|
+
if (report.outputs.f !== "F") throw new Error("f never ran");
|
|
573
|
+
},
|
|
574
|
+
BIG
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
edges.t(
|
|
578
|
+
"routing to an unknown node records INVALID_GOTO_TARGET and schedules nothing",
|
|
579
|
+
async t => {
|
|
580
|
+
for (const [label, definition] of [
|
|
581
|
+
["goto", g({
|
|
582
|
+
nodes: [
|
|
583
|
+
node("a", context => { context.goto("nowhere"); return 1; }),
|
|
584
|
+
constNode("end", "END")
|
|
585
|
+
],
|
|
586
|
+
entryNodes: ["a"],
|
|
587
|
+
terminalNodes: ["end"]
|
|
588
|
+
})],
|
|
589
|
+
["Next", g({
|
|
590
|
+
nodes: [
|
|
591
|
+
node("a", () => new Next("nowhere", 1)),
|
|
592
|
+
constNode("end", "END")
|
|
593
|
+
],
|
|
594
|
+
entryNodes: ["a"],
|
|
595
|
+
terminalNodes: ["end"]
|
|
596
|
+
})]
|
|
597
|
+
] as const) {
|
|
598
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
599
|
+
const error = report.errors.find(entry => entry.code === "INVALID_GOTO_TARGET");
|
|
600
|
+
|
|
601
|
+
if (!error) throw new Error(`[${label}] missing INVALID_GOTO_TARGET`);
|
|
602
|
+
if (error.handled) throw new Error(`[${label}] routing bug must stay unhandled`);
|
|
603
|
+
if (report.results.length !== 1) throw new Error(`[${label}] successors were scheduled`);
|
|
604
|
+
if (report.status !== "FAILED") throw new Error(`[${label}] ${report.status}`);
|
|
605
|
+
if ((error.details as { to: string }).to !== "nowhere") throw new Error(`[${label}] details`);
|
|
606
|
+
}
|
|
607
|
+
},
|
|
608
|
+
BIG
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
edges.t(
|
|
612
|
+
"new Next with an empty target throws INVALID_NEXT_TARGET immediately",
|
|
613
|
+
() => {
|
|
614
|
+
try {
|
|
615
|
+
new Next(" ", 1);
|
|
616
|
+
throw new Error("expected throw");
|
|
617
|
+
} catch (error) {
|
|
618
|
+
if (!(error instanceof AutomateGraphError)) throw error;
|
|
619
|
+
if (error.code !== "INVALID_NEXT_TARGET") throw new Error(error.code);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
/* ------------------------------ 5. terminals & lint ------------------------------ */
|
|
625
|
+
|
|
626
|
+
edges.t(
|
|
627
|
+
"a terminal node ignores its edges and its routing, and lint warns about it",
|
|
628
|
+
async t => {
|
|
629
|
+
const definition = g({
|
|
630
|
+
nodes: [
|
|
631
|
+
node("a", () => new Next("b", 1), { next: ["b"] }),
|
|
632
|
+
constNode("b", "B")
|
|
633
|
+
],
|
|
634
|
+
entryNodes: ["a"],
|
|
635
|
+
terminalNodes: ["a", "b"] // "a" is terminal on purpose
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
639
|
+
const ran = report.results.map(result => result.nodeId);
|
|
640
|
+
|
|
641
|
+
if (ran.includes("b")) throw new Error("terminal node routed anyway");
|
|
642
|
+
if (!report.logicallyCompleted) throw new Error("terminal did not latch completion");
|
|
643
|
+
|
|
644
|
+
const issues = lintGraph(definition);
|
|
645
|
+
const warning = issues.find(issue => issue.code === "TERMINAL_EDGE_IGNORED");
|
|
646
|
+
if (!warning) throw new Error(`lint codes: ${issues.map(i => i.code).join(",")}`);
|
|
647
|
+
if (warning.level !== "warn" || warning.nodeId !== "a") throw new Error(JSON.stringify(warning));
|
|
648
|
+
|
|
649
|
+
t.log("finding: terminal detection happens before routing — a terminal node never schedules successors");
|
|
650
|
+
},
|
|
651
|
+
BIG
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
edges.t(
|
|
655
|
+
"lint flags an exclusive edge declared after an else edge as unreachable",
|
|
656
|
+
async t => {
|
|
657
|
+
const definition = g({
|
|
658
|
+
nodes: [
|
|
659
|
+
node("a", () => 1, {
|
|
660
|
+
next: [
|
|
661
|
+
{ nextNode: "d" }, // else: always matches
|
|
662
|
+
{ result: 1, nextNode: "b" } // dead
|
|
663
|
+
]
|
|
664
|
+
}),
|
|
665
|
+
constNode("b", "B", { next: ["end"] }),
|
|
666
|
+
constNode("d", "D", { next: ["end"] }),
|
|
667
|
+
constNode("end", "END")
|
|
668
|
+
],
|
|
669
|
+
entryNodes: ["a"],
|
|
670
|
+
terminalNodes: ["end"]
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const issues = lintGraph(definition);
|
|
674
|
+
const dead = issues.find(issue => issue.code === "UNREACHABLE_EDGE");
|
|
675
|
+
|
|
676
|
+
if (!dead) throw new Error(`lint codes: ${issues.map(i => i.code).join(",")}`);
|
|
677
|
+
if (dead.level !== "error") throw new Error(dead.level);
|
|
678
|
+
if (dead.nodeId !== "a" || !dead.message.includes('"b"')) throw new Error(dead.message);
|
|
679
|
+
|
|
680
|
+
const report = await new GraphExecutionEngine(definition).run();
|
|
681
|
+
const ran = report.results.map(result => result.nodeId);
|
|
682
|
+
if (!ran.includes("d") || ran.includes("b")) throw new Error(ran.join(","));
|
|
683
|
+
|
|
684
|
+
t.log("finding: an else edge shadows every exclusive edge declared after it — runtime agrees with lint");
|
|
685
|
+
},
|
|
686
|
+
BIG
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
/* ------------------------------ 6. validation ------------------------------ */
|
|
690
|
+
|
|
691
|
+
edges.t(
|
|
692
|
+
"malformed edge entries are rejected at construction time",
|
|
693
|
+
() => {
|
|
694
|
+
const cases: Array<[unknown, RegExp]> = [
|
|
695
|
+
[42, /malformed next edge/],
|
|
696
|
+
[{ nextNode: "" }, /non-empty string/],
|
|
697
|
+
// v1.3 shape rule, extended in v1.4: a STRING test is a registry
|
|
698
|
+
// name, so only a non-callable, non-string value is malformed here.
|
|
699
|
+
[{ nextNode: "b", test: 42 }, /test must be a function/],
|
|
700
|
+
[{ nextNode: "b", test: "not a name!" }, /invalid test function name/],
|
|
701
|
+
[{ nextNode: "b", parallel: "yes" }, /parallel flag must be a boolean/],
|
|
702
|
+
[{ nextNode: "ghost" }, /unknown next Node "ghost"/],
|
|
703
|
+
["", /empty next Node reference/]
|
|
704
|
+
];
|
|
705
|
+
|
|
706
|
+
for (const [entry, pattern] of cases) {
|
|
707
|
+
try {
|
|
708
|
+
new GraphExecutionEngine(g({
|
|
709
|
+
nodes: [
|
|
710
|
+
node("a", () => 1, { next: [entry as never] }),
|
|
711
|
+
constNode("b", "B")
|
|
712
|
+
],
|
|
713
|
+
entryNodes: ["a"],
|
|
714
|
+
terminalNodes: ["b"]
|
|
715
|
+
}));
|
|
716
|
+
throw new Error(`expected throw for ${JSON.stringify(entry)}`);
|
|
717
|
+
} catch (error) {
|
|
718
|
+
if (!(error instanceof GraphValidationError)) throw error;
|
|
719
|
+
if (!pattern.test(error.message)) {
|
|
720
|
+
throw new Error(`got "${error.message}", expected ${pattern}`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
);
|
|
726
|
+
|
|
727
|
+
/* ------------------------------ 7. the payoff: one execution ------------------------------ */
|
|
728
|
+
|
|
729
|
+
edges.t(
|
|
730
|
+
"the a → b/c/d/e/f picture runs as ONE execution — the router is no longer needed",
|
|
731
|
+
async t => {
|
|
732
|
+
type Seed = 1 | 2 | 3 | "throw" | "retry";
|
|
733
|
+
|
|
734
|
+
const build = (seed: Seed) => g({
|
|
735
|
+
id: "branching-edges",
|
|
736
|
+
version: "1.3.0",
|
|
737
|
+
nodes: [
|
|
738
|
+
node("a", context => {
|
|
739
|
+
if (seed === "throw") throw new Error("a: upstream exploded");
|
|
740
|
+
if (seed === "retry" && context.attempt === 1) throw new Error("a: transient");
|
|
741
|
+
|
|
742
|
+
const result = seed === "retry" ? 2 : seed;
|
|
743
|
+
|
|
744
|
+
// "on retry → f": only the node knows the attempt number
|
|
745
|
+
if (context.attempt > 1) return new Next("f", result);
|
|
746
|
+
|
|
747
|
+
return result;
|
|
748
|
+
}, {
|
|
749
|
+
next: [
|
|
750
|
+
{ result: 1, nextNode: "b" },
|
|
751
|
+
{ test: value => typeof value === "number" && value === 2, nextNode: "c" },
|
|
752
|
+
{ nextNode: "d" }
|
|
753
|
+
],
|
|
754
|
+
onThrow: "e",
|
|
755
|
+
retry: { maxAttempts: 2, delayMs: 5 },
|
|
756
|
+
errorPolicy: "RETRY"
|
|
757
|
+
}),
|
|
758
|
+
|
|
759
|
+
// b returns void and forces f; the declared edge keeps f reachable
|
|
760
|
+
// for static linting (runtime routing wins, the edge stays a fallback)
|
|
761
|
+
node("b", context => {
|
|
762
|
+
context.goto("f");
|
|
763
|
+
return undefined;
|
|
764
|
+
}, { next: ["f"] }),
|
|
765
|
+
|
|
766
|
+
constNode("c", "C"),
|
|
767
|
+
constNode("d", "D"),
|
|
768
|
+
constNode("e", "E"),
|
|
769
|
+
constNode("f", "F")
|
|
770
|
+
],
|
|
771
|
+
entryNodes: ["a"],
|
|
772
|
+
terminalNodes: ["c", "d", "e", "f"],
|
|
773
|
+
completionPolicy: "FIRST_TERMINAL"
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
const expectations: Array<[Seed, string[], number]> = [
|
|
777
|
+
[1, ["a", "b", "f"], 1],
|
|
778
|
+
[2, ["a", "c"], 1],
|
|
779
|
+
[3, ["a", "d"], 1],
|
|
780
|
+
["throw", ["a", "e"], 2],
|
|
781
|
+
["retry", ["a", "f"], 2]
|
|
782
|
+
];
|
|
783
|
+
|
|
784
|
+
for (const [seed, path, attempts] of expectations) {
|
|
785
|
+
const engine = new GraphExecutionEngine(build(seed), { lint: "error" });
|
|
786
|
+
const captured = capture(engine, ["activation.dropped"]);
|
|
787
|
+
|
|
788
|
+
let flaky = false;
|
|
789
|
+
engine.events.on("node.failed", () => { flaky = true; });
|
|
790
|
+
|
|
791
|
+
const report = await engine.run();
|
|
792
|
+
const ran = report.results.map(result => result.nodeId).join(",");
|
|
793
|
+
const a = report.results.find(result => result.nodeId === "a")!;
|
|
794
|
+
|
|
795
|
+
if (ran !== path.join(",")) throw new Error(`seed ${JSON.stringify(seed)}: ${ran} ≠ ${path.join(",")}`);
|
|
796
|
+
if (report.status !== "COMPLETED") throw new Error(`seed ${seed}: ${report.status}`);
|
|
797
|
+
if (a.attempt !== attempts) throw new Error(`seed ${seed}: attempt=${a.attempt}`);
|
|
798
|
+
if (captured.length !== 0) throw new Error(`seed ${seed}: work was dropped`);
|
|
799
|
+
|
|
800
|
+
t.log(`seed ${JSON.stringify(seed)}`, `${ran} (attempts ${a.attempt})`);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
t.log(
|
|
804
|
+
"finding: with typed edges the whole picture is ONE execution and lint-clean — " +
|
|
805
|
+
"no router, no gate, no DRAIN policy needed"
|
|
806
|
+
);
|
|
807
|
+
},
|
|
808
|
+
BIG
|
|
809
|
+
);
|
|
810
|
+
|
|
811
|
+
edges.t(
|
|
812
|
+
"lint is clean for the payoff graph (routes declared, no dead edges)",
|
|
813
|
+
async t => {
|
|
814
|
+
const definition = g({
|
|
815
|
+
nodes: [
|
|
816
|
+
node("a", () => 1, {
|
|
817
|
+
next: [
|
|
818
|
+
{ result: 1, nextNode: "b" },
|
|
819
|
+
{ test: () => false, nextNode: "c" },
|
|
820
|
+
{ nextNode: "d" }
|
|
821
|
+
],
|
|
822
|
+
onThrow: "e"
|
|
823
|
+
}),
|
|
824
|
+
node("b", context => { context.goto("f"); return undefined; }, { next: ["f"] }),
|
|
825
|
+
constNode("c", "C", { next: ["f"] }),
|
|
826
|
+
constNode("d", "D", { next: ["f"] }),
|
|
827
|
+
constNode("e", "E", { next: ["f"] }),
|
|
828
|
+
constNode("f", "F")
|
|
829
|
+
],
|
|
830
|
+
entryNodes: ["a"],
|
|
831
|
+
terminalNodes: ["f"]
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
const issues = lintGraph(definition);
|
|
835
|
+
if (issues.length !== 0) {
|
|
836
|
+
throw new Error(`lint issues: ${issues.map(issue => `${issue.code}@${issue.nodeId}`).join(",")}`);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const engine = new GraphExecutionEngine(definition, { lint: "error" });
|
|
840
|
+
const report = await engine.run();
|
|
841
|
+
|
|
842
|
+
if (report.status !== "COMPLETED") throw new Error(report.status);
|
|
843
|
+
if (!report.results.some(result => result.nodeId === "f")) throw new Error("f never ran");
|
|
844
|
+
|
|
845
|
+
t.log("graph linted clean and ran under lint: \"error\"");
|
|
846
|
+
},
|
|
847
|
+
BIG
|
|
848
|
+
);
|