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,737 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* GRAPH JSON LOADER PACK (v1.4)
|
|
3
|
+
* ------------------------------------------------------------
|
|
4
|
+
* src/graph-json.ts turns a data document into a GraphDefinition:
|
|
5
|
+
*
|
|
6
|
+
* { "id", "version", "entryNodes", "terminalNodes",
|
|
7
|
+
* "nodes": { "a": { "run": "fnName",
|
|
8
|
+
* "next": [ { "test": "predName", "nextNode": "b" },
|
|
9
|
+
* { "nextNode": "c" } ] } } }
|
|
10
|
+
*
|
|
11
|
+
* Under test:
|
|
12
|
+
* • parsing needs NO registry (names resolve lazily in the engine)
|
|
13
|
+
* • validation is total — every problem is collected with its JSON
|
|
14
|
+
* path and thrown as one GraphJSONError carrying `issues`
|
|
15
|
+
* • createEngineFromJSON(document, { functions }) is the whole
|
|
16
|
+
* pipeline: data file → running engine → report with fingerprint
|
|
17
|
+
* ============================================================ */
|
|
18
|
+
|
|
19
|
+
import { GraphExecutionEngine, GraphValidationError } from "../src/automategraph";
|
|
20
|
+
import {
|
|
21
|
+
GraphJSONError,
|
|
22
|
+
createEngineFromJSON,
|
|
23
|
+
isGraphJSON,
|
|
24
|
+
parseGraphJSON
|
|
25
|
+
} from "../src/graph-json";
|
|
26
|
+
|
|
27
|
+
import { BIG, capture } from "./helpers";
|
|
28
|
+
import { suite } from "./harness";
|
|
29
|
+
|
|
30
|
+
const json = suite("graphjson", "Graph JSON loader");
|
|
31
|
+
|
|
32
|
+
/* ------------------------------ helpers ------------------------------ */
|
|
33
|
+
|
|
34
|
+
const DOCUMENT = {
|
|
35
|
+
id: "order-flow",
|
|
36
|
+
version: "1.0.0",
|
|
37
|
+
entryNodes: ["classify"],
|
|
38
|
+
terminalNodes: ["ship", "manual", "reject"],
|
|
39
|
+
completionPolicy: "FIRST_TERMINAL",
|
|
40
|
+
nodes: {
|
|
41
|
+
classify: {
|
|
42
|
+
run: "classifyOrder",
|
|
43
|
+
next: [
|
|
44
|
+
{ test: "isPriority", nextNode: "charge" },
|
|
45
|
+
{ result: "manual", nextNode: "manual" },
|
|
46
|
+
{ nextNode: "reject" }
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
charge: {
|
|
50
|
+
run: "chargeCard",
|
|
51
|
+
next: ["ship"],
|
|
52
|
+
onThrow: "manual",
|
|
53
|
+
errorPolicy: "RETRY",
|
|
54
|
+
retry: { maxAttempts: 2, delayMs: 1 }
|
|
55
|
+
},
|
|
56
|
+
ship: { run: "shipOrder" },
|
|
57
|
+
manual: { run: "manualReview" },
|
|
58
|
+
reject: { run: "rejectOrder" }
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const FUNCTIONS = {
|
|
63
|
+
classifyOrder: () => "priority",
|
|
64
|
+
isPriority: (value: unknown) => value === "priority",
|
|
65
|
+
chargeCard: () => "charged",
|
|
66
|
+
shipOrder: () => "shipped",
|
|
67
|
+
manualReview: () => "queued",
|
|
68
|
+
rejectOrder: () => "rejected"
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function issuesOf(input: unknown): Array<{ path: string; message: string }> {
|
|
72
|
+
try {
|
|
73
|
+
parseGraphJSON(input);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error instanceof GraphJSONError) return [...error.issues];
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new Error("expected a GraphJSONError");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function issuePaths(input: unknown): string[] {
|
|
83
|
+
return issuesOf(input).map(issue => issue.path);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function catchJSONError(input: unknown): GraphJSONError {
|
|
87
|
+
try {
|
|
88
|
+
parseGraphJSON(input);
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof GraphJSONError) return error;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error("expected a GraphJSONError");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function ok(condition: unknown, label: string): void {
|
|
98
|
+
if (!condition) throw new Error(label);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function eq(actual: unknown, expected: unknown, label: string): void {
|
|
102
|
+
if (actual !== expected) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stable(value: unknown): string {
|
|
110
|
+
return JSON.stringify(sortKeys(value)) ?? "undefined";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sortKeys(value: unknown): unknown {
|
|
114
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
115
|
+
if (value && typeof value === "object") {
|
|
116
|
+
return Object.fromEntries(
|
|
117
|
+
Object.keys(value as Record<string, unknown>)
|
|
118
|
+
.sort()
|
|
119
|
+
.map(key => [key, sortKeys((value as Record<string, unknown>)[key])])
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function deepEq(actual: unknown, expected: unknown, label: string): void {
|
|
126
|
+
const a = stable(actual);
|
|
127
|
+
const b = stable(expected);
|
|
128
|
+
|
|
129
|
+
if (a !== b) throw new Error(`${label}: expected ${b}, got ${a}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ------------------------------ 1. parsing ------------------------------ */
|
|
133
|
+
|
|
134
|
+
json.t(
|
|
135
|
+
"a valid document parses into a GraphDefinition (string and object input agree)",
|
|
136
|
+
async t => {
|
|
137
|
+
const fromObject = parseGraphJSON(DOCUMENT);
|
|
138
|
+
const fromText = parseGraphJSON(JSON.stringify(DOCUMENT));
|
|
139
|
+
|
|
140
|
+
deepEq(fromText, fromObject, "text and object input agree");
|
|
141
|
+
|
|
142
|
+
eq(fromObject.id, "order-flow", "id");
|
|
143
|
+
eq(fromObject.version, "1.0.0", "version");
|
|
144
|
+
deepEq(fromObject.entryNodes, ["classify"], "entryNodes");
|
|
145
|
+
deepEq(
|
|
146
|
+
fromObject.terminalNodes,
|
|
147
|
+
["ship", "manual", "reject"],
|
|
148
|
+
"terminalNodes"
|
|
149
|
+
);
|
|
150
|
+
eq(fromObject.completionPolicy, "FIRST_TERMINAL", "completionPolicy");
|
|
151
|
+
deepEq(Object.keys(fromObject.nodes), ["classify", "charge", "ship", "manual", "reject"], "nodes");
|
|
152
|
+
|
|
153
|
+
eq(fromObject.nodes.classify.run, "classifyOrder", "run stays a name");
|
|
154
|
+
eq(
|
|
155
|
+
typeof fromObject.nodes.classify.next?.[0],
|
|
156
|
+
"object",
|
|
157
|
+
"edge objects survive parsing"
|
|
158
|
+
);
|
|
159
|
+
deepEq(
|
|
160
|
+
fromObject.nodes.charge.next,
|
|
161
|
+
["ship"],
|
|
162
|
+
"string edges stay strings"
|
|
163
|
+
);
|
|
164
|
+
eq(fromObject.nodes.charge.onThrow, "manual", "onThrow");
|
|
165
|
+
eq(fromObject.nodes.charge.errorPolicy, "RETRY", "errorPolicy");
|
|
166
|
+
deepEq(
|
|
167
|
+
fromObject.nodes.charge.retry,
|
|
168
|
+
{ maxAttempts: 2, delayMs: 1 },
|
|
169
|
+
"retry"
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
t.log("finding: parsing never needs the registry — a data file is valid even when a function name is unknown; the engine decides that at run time");
|
|
173
|
+
},
|
|
174
|
+
BIG
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
json.t(
|
|
178
|
+
"node ids default to their keys, and a mismatching explicit id is an issue",
|
|
179
|
+
async t => {
|
|
180
|
+
const parsed = parseGraphJSON({
|
|
181
|
+
id: "g",
|
|
182
|
+
version: "1",
|
|
183
|
+
entryNodes: ["a"],
|
|
184
|
+
terminalNodes: ["a"],
|
|
185
|
+
nodes: { a: { run: "fn" } }
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
eq(parsed.nodes.a.id, "a", "id defaults to the key");
|
|
189
|
+
|
|
190
|
+
const paths = issuePaths({
|
|
191
|
+
id: "g",
|
|
192
|
+
version: "1",
|
|
193
|
+
entryNodes: ["a"],
|
|
194
|
+
terminalNodes: ["a"],
|
|
195
|
+
nodes: { a: { id: "elsewhere", run: "fn" } }
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
ok(paths.includes("nodes.a.id"), paths.join(","));
|
|
199
|
+
},
|
|
200
|
+
BIG
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
json.t(
|
|
204
|
+
"deep-equality results, named tests and parallel flags survive the round trip",
|
|
205
|
+
async t => {
|
|
206
|
+
const parsed = parseGraphJSON({
|
|
207
|
+
id: "g",
|
|
208
|
+
version: "1",
|
|
209
|
+
entryNodes: ["a"],
|
|
210
|
+
terminalNodes: ["b", "c", "d"],
|
|
211
|
+
nodes: {
|
|
212
|
+
a: {
|
|
213
|
+
run: "fn",
|
|
214
|
+
next: [
|
|
215
|
+
{ test: "isBig", nextNode: "b" },
|
|
216
|
+
{ result: { nested: [1, { two: "2" }], nil: null }, nextNode: "c" },
|
|
217
|
+
"d"
|
|
218
|
+
]
|
|
219
|
+
},
|
|
220
|
+
b: { run: "fn" },
|
|
221
|
+
c: { run: "fn" },
|
|
222
|
+
d: { run: "fn" }
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const edges = parsed.nodes.a.next ?? [];
|
|
227
|
+
|
|
228
|
+
deepEq(edges[0], { test: "isBig", nextNode: "b" }, "named test edge");
|
|
229
|
+
deepEq(
|
|
230
|
+
edges[1],
|
|
231
|
+
{ result: { nested: [1, { two: "2" }], nil: null }, nextNode: "c" },
|
|
232
|
+
"structured result matcher"
|
|
233
|
+
);
|
|
234
|
+
eq(edges[2], "d", "string edge");
|
|
235
|
+
},
|
|
236
|
+
BIG
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
/* ------------------------------ 2. end-to-end ------------------------------ */
|
|
240
|
+
|
|
241
|
+
json.t(
|
|
242
|
+
"createEngineFromJSON runs the data file and reports the registry fingerprint",
|
|
243
|
+
async t => {
|
|
244
|
+
const engine = createEngineFromJSON(JSON.stringify(DOCUMENT), {
|
|
245
|
+
functions: FUNCTIONS,
|
|
246
|
+
lint: "error"
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const captured = capture(engine, ["node.succeeded"]);
|
|
250
|
+
const report = await engine.run();
|
|
251
|
+
|
|
252
|
+
eq(report.status, "COMPLETED", "status");
|
|
253
|
+
deepEq(
|
|
254
|
+
report.results.map(result => result.nodeId),
|
|
255
|
+
["classify", "charge", "ship"],
|
|
256
|
+
"path"
|
|
257
|
+
);
|
|
258
|
+
deepEq(
|
|
259
|
+
report.results.map(result => result.fn),
|
|
260
|
+
["classifyOrder", "chargeCard", "shipOrder"],
|
|
261
|
+
"fn per activation"
|
|
262
|
+
);
|
|
263
|
+
deepEq(report.lintIssues, [], "the data file is lint-clean");
|
|
264
|
+
deepEq(
|
|
265
|
+
report.registry?.names,
|
|
266
|
+
[
|
|
267
|
+
"chargeCard",
|
|
268
|
+
"classifyOrder",
|
|
269
|
+
"isPriority",
|
|
270
|
+
"manualReview",
|
|
271
|
+
"rejectOrder",
|
|
272
|
+
"shipOrder"
|
|
273
|
+
],
|
|
274
|
+
"registry names are sorted"
|
|
275
|
+
);
|
|
276
|
+
ok(
|
|
277
|
+
(report.registry?.fingerprint ?? "").startsWith("fnr1:6:"),
|
|
278
|
+
`fingerprint: ${report.registry?.fingerprint}`
|
|
279
|
+
);
|
|
280
|
+
eq(
|
|
281
|
+
(captured.at(-1)?.payload as { fn?: string }).fn,
|
|
282
|
+
"shipOrder",
|
|
283
|
+
"events carry the function name"
|
|
284
|
+
);
|
|
285
|
+
},
|
|
286
|
+
BIG
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
json.t(
|
|
290
|
+
"a named test in the document routes via the supplied registry",
|
|
291
|
+
async t => {
|
|
292
|
+
const engine = createEngineFromJSON(DOCUMENT, {
|
|
293
|
+
functions: { ...FUNCTIONS, classifyOrder: () => "standard" }
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const report = await engine.run();
|
|
297
|
+
|
|
298
|
+
eq(report.status, "COMPLETED", "status");
|
|
299
|
+
deepEq(
|
|
300
|
+
report.results.map(result => result.nodeId),
|
|
301
|
+
["classify", "reject"],
|
|
302
|
+
"the else edge caught the unmatched value"
|
|
303
|
+
);
|
|
304
|
+
eq(report.outputs.reject, "rejected", "output");
|
|
305
|
+
},
|
|
306
|
+
BIG
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
json.t(
|
|
310
|
+
"a data file with an unknown name runs and fails with UNKNOWN_FUNCTION",
|
|
311
|
+
async t => {
|
|
312
|
+
const engine = createEngineFromJSON(DOCUMENT, {
|
|
313
|
+
functions: { ...FUNCTIONS, shipOrder: undefined }
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const report = await engine.run();
|
|
317
|
+
|
|
318
|
+
eq(report.status, "FAILED", "status");
|
|
319
|
+
eq(report.errors[0].code, "UNKNOWN_FUNCTION", "code");
|
|
320
|
+
eq(report.errors[0].nodeId, "ship", "nodeId");
|
|
321
|
+
eq(report.errors[0].handled, false, "handled");
|
|
322
|
+
eq(
|
|
323
|
+
(report.errors[0].details as { name: string }).name,
|
|
324
|
+
"shipOrder",
|
|
325
|
+
"the missing name is named"
|
|
326
|
+
);
|
|
327
|
+
deepEq(
|
|
328
|
+
report.results.map(result => result.status),
|
|
329
|
+
["SUCCEEDED", "SUCCEEDED", "FAILED"],
|
|
330
|
+
"everything before the missing node ran"
|
|
331
|
+
);
|
|
332
|
+
},
|
|
333
|
+
BIG
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
json.t(
|
|
337
|
+
"the parsed definition is accepted by the engine unchanged",
|
|
338
|
+
async t => {
|
|
339
|
+
const definition = parseGraphJSON(DOCUMENT);
|
|
340
|
+
const engine = new GraphExecutionEngine(definition, {
|
|
341
|
+
functions: FUNCTIONS
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const report = await engine.run();
|
|
345
|
+
|
|
346
|
+
eq(report.status, "COMPLETED", "the loader output needs no fixing up");
|
|
347
|
+
eq(engine.registry.names.length, 6, "registry intact");
|
|
348
|
+
|
|
349
|
+
/* the engine's own validation would have thrown on a malformed graph */
|
|
350
|
+
ok(definition.nodes.classify.run === "classifyOrder", "run name preserved");
|
|
351
|
+
},
|
|
352
|
+
BIG
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
/* ------------------------------ 3. validation: structure ------------------------------ */
|
|
356
|
+
|
|
357
|
+
json.t(
|
|
358
|
+
"invalid JSON text is a single, clear GraphJSONError",
|
|
359
|
+
async t => {
|
|
360
|
+
let caught: unknown;
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
parseGraphJSON("{ not json ");
|
|
364
|
+
} catch (error) {
|
|
365
|
+
caught = error;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
ok(caught instanceof GraphJSONError, "GraphJSONError expected");
|
|
369
|
+
eq((caught as GraphJSONError).code, "GRAPH_JSON_ERROR", "code");
|
|
370
|
+
deepEq(
|
|
371
|
+
(caught as GraphJSONError).issues.map(issue => issue.path),
|
|
372
|
+
["$"],
|
|
373
|
+
"path is the document root"
|
|
374
|
+
);
|
|
375
|
+
ok(
|
|
376
|
+
(caught as GraphJSONError).message.includes("not parseable JSON"),
|
|
377
|
+
(caught as GraphJSONError).message
|
|
378
|
+
);
|
|
379
|
+
},
|
|
380
|
+
BIG
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
json.t(
|
|
384
|
+
"a non-object document is rejected with one issue",
|
|
385
|
+
async t => {
|
|
386
|
+
const paths = issuePaths([1, 2, 3]);
|
|
387
|
+
|
|
388
|
+
deepEq(paths, ["$"], "one issue at the root");
|
|
389
|
+
ok(
|
|
390
|
+
issuesOf([1, 2, 3])[0].message.includes("array"),
|
|
391
|
+
"the message names what arrived"
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
deepEq(issuePaths(null), ["$"], "null");
|
|
395
|
+
deepEq(issuePaths(42), ["$"], "a number");
|
|
396
|
+
},
|
|
397
|
+
BIG
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
json.t(
|
|
401
|
+
"unknown properties are rejected at every level",
|
|
402
|
+
async t => {
|
|
403
|
+
const paths = issuePaths({
|
|
404
|
+
id: "g",
|
|
405
|
+
version: "1",
|
|
406
|
+
entryNodes: ["a"],
|
|
407
|
+
terminalNodes: ["a"],
|
|
408
|
+
extra: true,
|
|
409
|
+
nodes: {
|
|
410
|
+
a: { run: "fn", wat: 1, next: [{ nextNode: "a", nope: 2 }] }
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
ok(paths.includes("extra"), paths.join(","));
|
|
415
|
+
ok(paths.includes("nodes.a.wat"), paths.join(","));
|
|
416
|
+
ok(paths.includes("nodes.a.next[0].nope"), paths.join(","));
|
|
417
|
+
},
|
|
418
|
+
BIG
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
json.t(
|
|
422
|
+
"functions cannot live in the document — the issue points at the engine options",
|
|
423
|
+
async t => {
|
|
424
|
+
const issues = issuesOf({ ...DOCUMENT, functions: { a: 1 } });
|
|
425
|
+
|
|
426
|
+
eq(issues.length, 1, "one issue");
|
|
427
|
+
eq(issues[0].path, "functions", "path");
|
|
428
|
+
ok(
|
|
429
|
+
issues[0].message.includes("createEngineFromJSON(json, { functions })"),
|
|
430
|
+
issues[0].message
|
|
431
|
+
);
|
|
432
|
+
},
|
|
433
|
+
BIG
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
/* ------------------------------ 4. validation: semantics ------------------------------ */
|
|
437
|
+
|
|
438
|
+
json.t(
|
|
439
|
+
"dangling references are reported with their paths",
|
|
440
|
+
async t => {
|
|
441
|
+
const paths = issuePaths({
|
|
442
|
+
id: "g",
|
|
443
|
+
version: "1",
|
|
444
|
+
entryNodes: ["ghost"],
|
|
445
|
+
terminalNodes: ["a"],
|
|
446
|
+
nodes: {
|
|
447
|
+
a: {
|
|
448
|
+
run: "fn",
|
|
449
|
+
next: ["nowhere", { nextNode: "void" }],
|
|
450
|
+
onThrow: "nobody",
|
|
451
|
+
fallback: "nobody"
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
ok(paths.includes("entryNodes[0]"), paths.join(","));
|
|
457
|
+
ok(paths.includes("nodes.a.next[0]"), paths.join(","));
|
|
458
|
+
ok(paths.includes("nodes.a.next[1]"), paths.join(","));
|
|
459
|
+
ok(paths.includes("nodes.a.onThrow"), paths.join(","));
|
|
460
|
+
ok(paths.includes("nodes.a.fallback"), paths.join(","));
|
|
461
|
+
},
|
|
462
|
+
BIG
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
json.t(
|
|
466
|
+
"malformed edges are rejected entry by entry",
|
|
467
|
+
async t => {
|
|
468
|
+
const paths = issuePaths({
|
|
469
|
+
id: "g",
|
|
470
|
+
version: "1",
|
|
471
|
+
entryNodes: ["a"],
|
|
472
|
+
terminalNodes: ["a"],
|
|
473
|
+
nodes: {
|
|
474
|
+
a: {
|
|
475
|
+
run: "fn",
|
|
476
|
+
next: [
|
|
477
|
+
42,
|
|
478
|
+
{},
|
|
479
|
+
{ nextNode: "" },
|
|
480
|
+
{ nextNode: "a", parallel: "yes" },
|
|
481
|
+
{ nextNode: "a", test: 7 },
|
|
482
|
+
{ nextNode: "a", test: "not a name!" }
|
|
483
|
+
]
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
for (const expected of [
|
|
489
|
+
"nodes.a.next[0]",
|
|
490
|
+
"nodes.a.next[1].nextNode",
|
|
491
|
+
"nodes.a.next[2].nextNode",
|
|
492
|
+
"nodes.a.next[3].parallel",
|
|
493
|
+
"nodes.a.next[4].test",
|
|
494
|
+
"nodes.a.next[5].test"
|
|
495
|
+
]) {
|
|
496
|
+
ok(paths.includes(expected), `${expected} missing from ${paths.join(",")}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/* `next: {}` is not an array either */
|
|
500
|
+
const scalarPaths = issuePaths({
|
|
501
|
+
id: "g",
|
|
502
|
+
version: "1",
|
|
503
|
+
entryNodes: ["a"],
|
|
504
|
+
terminalNodes: ["a"],
|
|
505
|
+
nodes: { a: { run: "fn", next: {} } }
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
ok(scalarPaths.includes("nodes.a.next"), scalarPaths.join(","));
|
|
509
|
+
},
|
|
510
|
+
BIG
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
json.t(
|
|
514
|
+
"invalid scalars, policies and retry blocks are rejected",
|
|
515
|
+
async t => {
|
|
516
|
+
const paths = issuePaths({
|
|
517
|
+
id: "",
|
|
518
|
+
version: "",
|
|
519
|
+
defaultNodeTimeoutMs: 0,
|
|
520
|
+
errorPolicy: "NOPE",
|
|
521
|
+
checkpointEveryNode: "yes",
|
|
522
|
+
completionPolicy: "SOMETIMES",
|
|
523
|
+
entryNodes: "a",
|
|
524
|
+
terminalNodes: ["a"],
|
|
525
|
+
nodes: {
|
|
526
|
+
a: {
|
|
527
|
+
run: 42,
|
|
528
|
+
timeoutMs: -5,
|
|
529
|
+
errorPolicy: "MAYBE",
|
|
530
|
+
retry: { maxAttempts: 0, delayMs: -1, extra: true }
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
for (const expected of [
|
|
536
|
+
"id",
|
|
537
|
+
"version",
|
|
538
|
+
"defaultNodeTimeoutMs",
|
|
539
|
+
"errorPolicy",
|
|
540
|
+
"checkpointEveryNode",
|
|
541
|
+
"completionPolicy",
|
|
542
|
+
"entryNodes",
|
|
543
|
+
"nodes.a.run",
|
|
544
|
+
"nodes.a.timeoutMs",
|
|
545
|
+
"nodes.a.errorPolicy",
|
|
546
|
+
"nodes.a.retry.maxAttempts",
|
|
547
|
+
"nodes.a.retry.delayMs",
|
|
548
|
+
"nodes.a.retry.extra"
|
|
549
|
+
]) {
|
|
550
|
+
ok(paths.includes(expected), `${expected} missing from ${paths.join(",")}`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/* a run name with illegal characters is a shape error too */
|
|
554
|
+
const namePaths = issuePaths({
|
|
555
|
+
id: "g",
|
|
556
|
+
version: "1",
|
|
557
|
+
entryNodes: ["a"],
|
|
558
|
+
terminalNodes: ["a"],
|
|
559
|
+
nodes: { a: { run: "has space" } }
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
ok(namePaths.includes("nodes.a.run"), namePaths.join(","));
|
|
563
|
+
},
|
|
564
|
+
BIG
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
json.t(
|
|
568
|
+
"EVERY issue is collected — one throw carries them all",
|
|
569
|
+
async t => {
|
|
570
|
+
const issues = issuesOf({
|
|
571
|
+
id: "",
|
|
572
|
+
version: "1",
|
|
573
|
+
entryNodes: ["ghost"],
|
|
574
|
+
terminalNodes: ["a"],
|
|
575
|
+
extra: 1,
|
|
576
|
+
nodes: {
|
|
577
|
+
a: { run: "fn", next: [{ nextNode: "nowhere" }, 7] },
|
|
578
|
+
b: { run: 12 }
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
ok(issues.length >= 6, `expected several issues, got ${issues.length}`);
|
|
583
|
+
eq(
|
|
584
|
+
new Set(issues.map(issue => issue.path)).size,
|
|
585
|
+
issues.length,
|
|
586
|
+
"paths are distinct"
|
|
587
|
+
);
|
|
588
|
+
ok(
|
|
589
|
+
issues.every(issue => issue.message.length > 0),
|
|
590
|
+
"every issue has a message"
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const caught = catchJSONError({
|
|
594
|
+
id: "",
|
|
595
|
+
version: "",
|
|
596
|
+
entryNodes: [],
|
|
597
|
+
terminalNodes: [],
|
|
598
|
+
nodes: {}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
ok(/problem\(s\)/.test(caught.message), caught.message);
|
|
602
|
+
ok(caught.issues.length >= 5, `issues=${caught.issues.length}`);
|
|
603
|
+
|
|
604
|
+
t.log(`finding: one document reported ${caught.issues.length} problems in a single throw — no fix-one-rerun loop`);
|
|
605
|
+
},
|
|
606
|
+
BIG
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
/* ------------------------------ 5. surface ------------------------------ */
|
|
610
|
+
|
|
611
|
+
json.t(
|
|
612
|
+
"isGraphJSON() is a cheap shape probe, not a validator",
|
|
613
|
+
async t => {
|
|
614
|
+
eq(isGraphJSON(JSON.stringify(DOCUMENT)), true, "text");
|
|
615
|
+
eq(isGraphJSON(DOCUMENT), true, "object");
|
|
616
|
+
eq(isGraphJSON({ id: "g" }), false, "no nodes");
|
|
617
|
+
eq(isGraphJSON("{ not json"), false, "broken text");
|
|
618
|
+
eq(isGraphJSON(null), false, "null");
|
|
619
|
+
eq(isGraphJSON([1]), false, "array");
|
|
620
|
+
eq(isGraphJSON(parseGraphJSON(DOCUMENT)), true, "parser output");
|
|
621
|
+
},
|
|
622
|
+
BIG
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
json.t(
|
|
626
|
+
"an in-memory object may pass real functions through (escape hatch)",
|
|
627
|
+
async t => {
|
|
628
|
+
const parsed = parseGraphJSON({
|
|
629
|
+
id: "in-memory",
|
|
630
|
+
version: "1.0.0",
|
|
631
|
+
entryNodes: ["a"],
|
|
632
|
+
terminalNodes: ["b"],
|
|
633
|
+
nodes: {
|
|
634
|
+
a: { run: () => "inline through the loader", next: ["b"] },
|
|
635
|
+
b: { run: () => "B" }
|
|
636
|
+
}
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
const engine = new GraphExecutionEngine(parsed);
|
|
640
|
+
|
|
641
|
+
const report = await engine.run();
|
|
642
|
+
|
|
643
|
+
eq(report.status, "COMPLETED", "status");
|
|
644
|
+
eq(report.outputs.a, "inline through the loader", "output");
|
|
645
|
+
eq(report.registry, undefined, "no names, no registry");
|
|
646
|
+
eq(report.results[0].fn, undefined, "inline activation has no fn");
|
|
647
|
+
},
|
|
648
|
+
BIG
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
json.t(
|
|
652
|
+
"a parsed document with dangling ids is still caught by the engine if it is hand-edited",
|
|
653
|
+
async t => {
|
|
654
|
+
const definition = parseGraphJSON(DOCUMENT);
|
|
655
|
+
const tampered = {
|
|
656
|
+
...definition,
|
|
657
|
+
nodes: {
|
|
658
|
+
...definition.nodes,
|
|
659
|
+
classify: { ...definition.nodes.classify, next: ["ghost"] }
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
try {
|
|
664
|
+
new GraphExecutionEngine(tampered as typeof definition, {
|
|
665
|
+
functions: FUNCTIONS
|
|
666
|
+
});
|
|
667
|
+
throw new Error("expected the engine to reject the tampered graph");
|
|
668
|
+
} catch (error) {
|
|
669
|
+
if (!(error instanceof GraphValidationError)) throw error;
|
|
670
|
+
ok(
|
|
671
|
+
error.message.includes('unknown next Node "ghost"'),
|
|
672
|
+
error.message
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
t.log("finding: the loader and the engine validate different layers (paths vs. final shape) — both stay in force");
|
|
677
|
+
},
|
|
678
|
+
BIG
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
json.t(
|
|
682
|
+
"the loader example document is valid (regression: the shipped .json file)",
|
|
683
|
+
async t => {
|
|
684
|
+
/* mirrors examples/order-flow.graph.json */
|
|
685
|
+
const shipped = {
|
|
686
|
+
id: "order-flow",
|
|
687
|
+
version: "1.0.0",
|
|
688
|
+
entryNodes: ["classify"],
|
|
689
|
+
terminalNodes: ["ship", "manual", "reject"],
|
|
690
|
+
completionPolicy: "FIRST_TERMINAL",
|
|
691
|
+
nodes: {
|
|
692
|
+
classify: {
|
|
693
|
+
run: "classifyOrder",
|
|
694
|
+
next: [
|
|
695
|
+
{ test: "isPriority", nextNode: "charge" },
|
|
696
|
+
{ result: "manual", nextNode: "manual" },
|
|
697
|
+
{ nextNode: "reject" }
|
|
698
|
+
]
|
|
699
|
+
},
|
|
700
|
+
charge: {
|
|
701
|
+
run: "chargeCard",
|
|
702
|
+
next: ["ship"],
|
|
703
|
+
onThrow: "manual",
|
|
704
|
+
errorPolicy: "RETRY",
|
|
705
|
+
retry: { maxAttempts: 2, delayMs: 5 }
|
|
706
|
+
},
|
|
707
|
+
ship: { run: "shipOrder" },
|
|
708
|
+
manual: { run: "manualReview" },
|
|
709
|
+
reject: { run: "rejectOrder" }
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const engine = createEngineFromJSON(shipped, {
|
|
714
|
+
functions: FUNCTIONS,
|
|
715
|
+
lint: "error"
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
const report = await engine.run();
|
|
719
|
+
|
|
720
|
+
eq(report.status, "COMPLETED", "status");
|
|
721
|
+
deepEq(report.lintIssues, [], "lint-clean");
|
|
722
|
+
|
|
723
|
+
/* and the same document, missing one name, is still lint-clean but fails
|
|
724
|
+
* exactly once at the missing node */
|
|
725
|
+
const partial = createEngineFromJSON(shipped, {
|
|
726
|
+
functions: { ...FUNCTIONS, chargeCard: undefined },
|
|
727
|
+
lint: "error"
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
const partialReport = await partial.run();
|
|
731
|
+
|
|
732
|
+
eq(partialReport.errors.length, 1, "one defect");
|
|
733
|
+
eq(partialReport.errors[0].code, "UNKNOWN_FUNCTION", "code");
|
|
734
|
+
eq(partialReport.status, "FAILED", "status");
|
|
735
|
+
},
|
|
736
|
+
BIG
|
|
737
|
+
);
|