automategraph 1.5.0 → 1.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- 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,970 @@
|
|
|
1
|
+
/* ============================================================
|
|
2
|
+
* SCRIPT LOADER PACK (v1.5)
|
|
3
|
+
* ------------------------------------------------------------
|
|
4
|
+
* A document may declare scripts that PROVIDE the functions its nodes
|
|
5
|
+
* and edges name:
|
|
6
|
+
*
|
|
7
|
+
* "scripts": [
|
|
8
|
+
* { "namespace": "order", "path": "./order.cjs" },
|
|
9
|
+
* { "namespace": "inline", "source": "module.exports = { … }" },
|
|
10
|
+
* { "namespace": "remote", "url": "https://host/fns.mjs", "format": "esm" }
|
|
11
|
+
* ]
|
|
12
|
+
*
|
|
13
|
+
* Two-step API (the loader is Node-only, the core stays isomorphic):
|
|
14
|
+
*
|
|
15
|
+
* const { functions, namespaces } = await loadGraphScripts(document);
|
|
16
|
+
* const engine = createEngineFromJSON(document, { functions });
|
|
17
|
+
*
|
|
18
|
+
* Under test:
|
|
19
|
+
* • strategies — EXPORTS (cjs / esm / default-object) and SELF-REGISTRATION
|
|
20
|
+
* through globalThis.GraphEngine.registerEngineNamespace(namespace, fns),
|
|
21
|
+
* including both in one script
|
|
22
|
+
* • format auto-detection (.cjs → require, .mjs → import, .js → hint +
|
|
23
|
+
* fallback) and the explicit "format" override, which is never retried
|
|
24
|
+
* • the freeze — a namespace is claimed once per PROCESS; a second claim
|
|
25
|
+
* throws, from inside the script when the script registers itself
|
|
26
|
+
* • plain names: the namespace is a claim slot, never a name prefix
|
|
27
|
+
* • validation runs before any code executes
|
|
28
|
+
* ============================================================ */
|
|
29
|
+
|
|
30
|
+
import * as path from "path";
|
|
31
|
+
|
|
32
|
+
import { GraphExecutionEngine } from "../src/automategraph";
|
|
33
|
+
import {
|
|
34
|
+
GraphJSONError,
|
|
35
|
+
createEngineFromJSON,
|
|
36
|
+
parseGraphJSON,
|
|
37
|
+
parseGraphJSONDocument
|
|
38
|
+
} from "../src/graph-json";
|
|
39
|
+
import {
|
|
40
|
+
GraphScriptError,
|
|
41
|
+
loadGraphScripts,
|
|
42
|
+
registeredNamespaces,
|
|
43
|
+
resetGraphScriptNamespaces
|
|
44
|
+
} from "../src/node/graph-scripts";
|
|
45
|
+
|
|
46
|
+
import { BIG, logCollector } from "./helpers";
|
|
47
|
+
import { suite } from "./harness";
|
|
48
|
+
|
|
49
|
+
const scripts = suite("scripts", "Script loading");
|
|
50
|
+
|
|
51
|
+
/* ------------------------------ helpers ------------------------------ */
|
|
52
|
+
|
|
53
|
+
const FIXTURES = path.join(
|
|
54
|
+
__dirname,
|
|
55
|
+
"..",
|
|
56
|
+
"..",
|
|
57
|
+
"tests",
|
|
58
|
+
"fixtures",
|
|
59
|
+
"scripts"
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
function doc(declarations: unknown[]): Record<string, unknown> {
|
|
63
|
+
return {
|
|
64
|
+
id: "scripted",
|
|
65
|
+
version: "1.5.0",
|
|
66
|
+
entryNodes: ["a"],
|
|
67
|
+
terminalNodes: ["end"],
|
|
68
|
+
scripts: declarations,
|
|
69
|
+
nodes: {
|
|
70
|
+
a: { run: "start", next: ["end"] },
|
|
71
|
+
end: { run: "finish" }
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function ok(condition: unknown, label: string): void {
|
|
77
|
+
if (!condition) throw new Error(label);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function eq(actual: unknown, expected: unknown, label: string): void {
|
|
81
|
+
if (actual !== expected) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function deepEq(actual: unknown, expected: unknown, label: string): void {
|
|
89
|
+
const a = JSON.stringify(actual ?? null);
|
|
90
|
+
const b = JSON.stringify(expected ?? null);
|
|
91
|
+
|
|
92
|
+
if (a !== b) throw new Error(`${label}: expected ${b}, got ${a}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function loadError(
|
|
96
|
+
declaration: unknown
|
|
97
|
+
): Promise<GraphScriptError> {
|
|
98
|
+
try {
|
|
99
|
+
await loadGraphScripts(doc([declaration]), { baseDir: FIXTURES });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof GraphScriptError) return error;
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
throw new Error("expected a GraphScriptError");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Claims are process-global: every test starts from a clean slate. */
|
|
109
|
+
function fresh(): void {
|
|
110
|
+
resetGraphScriptNamespaces();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ------------------------------ 1. media & formats ------------------------------ */
|
|
114
|
+
|
|
115
|
+
scripts.t(
|
|
116
|
+
"a .cjs script is required and its export table becomes the registry",
|
|
117
|
+
async t => {
|
|
118
|
+
fresh();
|
|
119
|
+
|
|
120
|
+
const loaded = await loadGraphScripts(
|
|
121
|
+
doc([{ namespace: "order", path: "./order.cjs" }]),
|
|
122
|
+
{ baseDir: FIXTURES }
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
deepEq(
|
|
126
|
+
loaded.registry.names,
|
|
127
|
+
[
|
|
128
|
+
"chargeCard",
|
|
129
|
+
"classifyOrder",
|
|
130
|
+
"isPriority",
|
|
131
|
+
"manualReview",
|
|
132
|
+
"rejectOrder",
|
|
133
|
+
"shipOrder"
|
|
134
|
+
],
|
|
135
|
+
"names: sorted, non-function export excluded"
|
|
136
|
+
);
|
|
137
|
+
deepEq(loaded.namespaces, ["order"], "namespaces");
|
|
138
|
+
eq(loaded.scripts[0].format, "cjs", "format");
|
|
139
|
+
eq(loaded.scripts[0].source, "path:./order.cjs", "source label");
|
|
140
|
+
deepEq(loaded.scripts[0].ignoredExports, ["STATUS"], "ignored exports");
|
|
141
|
+
eq(Object.isFrozen(loaded.functions), true, "the table is frozen");
|
|
142
|
+
ok(
|
|
143
|
+
loaded.registry.fingerprint.startsWith("fnr1:6:"),
|
|
144
|
+
loaded.registry.fingerprint
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
/* the functions are the real things, callable with real contexts */
|
|
148
|
+
eq((loaded.functions.classifyOrder as () => string)(), "priority", "call");
|
|
149
|
+
eq(
|
|
150
|
+
(loaded.functions.isPriority as (v: unknown) => boolean)("priority"),
|
|
151
|
+
true,
|
|
152
|
+
"matcher"
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
t.log("finding: a registered name is not rewritten — `classifyOrder` stays `classifyOrder`, the namespace only owns the claim");
|
|
156
|
+
},
|
|
157
|
+
BIG
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
scripts.t(
|
|
161
|
+
"an .mjs script is imported as an ES module",
|
|
162
|
+
async t => {
|
|
163
|
+
fresh();
|
|
164
|
+
|
|
165
|
+
const loaded = await loadGraphScripts(
|
|
166
|
+
doc([{ namespace: "esm", path: "./esm-functions.mjs" }]),
|
|
167
|
+
{ baseDir: FIXTURES }
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
eq(loaded.scripts[0].format, "esm", "format");
|
|
171
|
+
eq((loaded.functions.shipOrder as () => string)(), "shipped", "named export works");
|
|
172
|
+
deepEq(loaded.scripts[0].ignoredExports, ["version"], "non-function export ignored");
|
|
173
|
+
},
|
|
174
|
+
BIG
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
scripts.t(
|
|
178
|
+
"a .js script with ESM syntax is detected as ESM; without it, as CommonJS",
|
|
179
|
+
async t => {
|
|
180
|
+
fresh();
|
|
181
|
+
|
|
182
|
+
const esm = await loadGraphScripts(
|
|
183
|
+
doc([{ namespace: "detected", path: "./esm-syntax.js" }]),
|
|
184
|
+
{ baseDir: FIXTURES }
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
eq(esm.scripts[0].format, "esm", ".js with export syntax");
|
|
188
|
+
eq((esm.functions.greetFromJs as () => string)(), "hello from .js ESM", "works");
|
|
189
|
+
|
|
190
|
+
fresh();
|
|
191
|
+
|
|
192
|
+
const cjs = await loadGraphScripts(
|
|
193
|
+
doc([{ namespace: "defaulted", path: "./cjs-default.js" }]),
|
|
194
|
+
{ baseDir: FIXTURES }
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
eq(cjs.scripts[0].format, "cjs", ".js without export syntax");
|
|
198
|
+
eq((cjs.functions.plainCjs as () => string)(), "plain cjs", "works");
|
|
199
|
+
|
|
200
|
+
t.log("finding: .js detection is a source hint with a fallback — the other format is attempted before failing, so a mis-hint costs one retry, never a failed load");
|
|
201
|
+
},
|
|
202
|
+
BIG
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
scripts.t(
|
|
206
|
+
"an explicit format is honoured and never retried in the other format",
|
|
207
|
+
async t => {
|
|
208
|
+
fresh();
|
|
209
|
+
|
|
210
|
+
/* CJS import() of a CJS file is legal: the override to ESM succeeds */
|
|
211
|
+
const forcedEsm = await loadGraphScripts(
|
|
212
|
+
doc([{ namespace: "forcedEsm", path: "./cjs-default.js", format: "esm" }]),
|
|
213
|
+
{ baseDir: FIXTURES }
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
eq(forcedEsm.scripts[0].format, "esm", "forced esm");
|
|
217
|
+
ok("plainCjs" in forcedEsm.functions, Object.keys(forcedEsm.functions).join(","));
|
|
218
|
+
|
|
219
|
+
fresh();
|
|
220
|
+
|
|
221
|
+
/* .mjs forced to CJS cannot work — and must fail WITHOUT trying ESM */
|
|
222
|
+
const error = await loadError({
|
|
223
|
+
namespace: "forcedCjs",
|
|
224
|
+
path: "./esm-functions.mjs",
|
|
225
|
+
format: "cjs"
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
eq(error.code, "SCRIPT_LOAD_FAILED", "code");
|
|
229
|
+
deepEq(
|
|
230
|
+
(error.details as { attempts: string[] }).attempts.length,
|
|
231
|
+
1,
|
|
232
|
+
"exactly one attempt: no fallback for an explicit format"
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
BIG
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
scripts.t(
|
|
239
|
+
"inline CommonJS source runs with a real require and module",
|
|
240
|
+
async t => {
|
|
241
|
+
fresh();
|
|
242
|
+
|
|
243
|
+
const loaded = await loadGraphScripts(
|
|
244
|
+
doc([
|
|
245
|
+
{
|
|
246
|
+
namespace: "inlineCjs",
|
|
247
|
+
source:
|
|
248
|
+
"module.exports = { inlineCjs: () => 'inline cjs', " +
|
|
249
|
+
"isInline: value => value === 'inline cjs' };"
|
|
250
|
+
}
|
|
251
|
+
]),
|
|
252
|
+
{ baseDir: FIXTURES }
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
eq(loaded.scripts[0].format, "cjs", "format");
|
|
256
|
+
eq(loaded.scripts[0].source, "inline source", "label");
|
|
257
|
+
eq((loaded.functions.inlineCjs as () => string)(), "inline cjs", "works");
|
|
258
|
+
},
|
|
259
|
+
BIG
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
scripts.t(
|
|
263
|
+
"inline ESM source is evaluated from a data URL",
|
|
264
|
+
async t => {
|
|
265
|
+
fresh();
|
|
266
|
+
|
|
267
|
+
const loaded = await loadGraphScripts(
|
|
268
|
+
doc([
|
|
269
|
+
{
|
|
270
|
+
namespace: "inlineEsm",
|
|
271
|
+
source: "export const inlineEsm = () => 'inline esm';"
|
|
272
|
+
}
|
|
273
|
+
]),
|
|
274
|
+
{ baseDir: FIXTURES }
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
eq(loaded.scripts[0].format, "esm", "format");
|
|
278
|
+
eq((loaded.functions.inlineEsm as () => string)(), "inline esm", "works");
|
|
279
|
+
},
|
|
280
|
+
BIG
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
scripts.t(
|
|
284
|
+
"URL scripts are fetched and evaluated (CJS by default, ESM by hint)",
|
|
285
|
+
async t => {
|
|
286
|
+
fresh();
|
|
287
|
+
|
|
288
|
+
const cjsText = "module.exports = { fromUrl: () => 'url cjs' };";
|
|
289
|
+
|
|
290
|
+
const cjs = await loadGraphScripts(
|
|
291
|
+
doc([{ namespace: "remoteCjs", url: "https://example.test/fns.cjs" }]),
|
|
292
|
+
{
|
|
293
|
+
baseDir: FIXTURES,
|
|
294
|
+
fetchText: async url => {
|
|
295
|
+
eq(url, "https://example.test/fns.cjs", "the loader fetched the declared URL");
|
|
296
|
+
return cjsText;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
eq(cjs.scripts[0].format, "cjs", "format");
|
|
302
|
+
eq((cjs.functions.fromUrl as () => string)(), "url cjs", "works");
|
|
303
|
+
|
|
304
|
+
fresh();
|
|
305
|
+
|
|
306
|
+
const esm = await loadGraphScripts(
|
|
307
|
+
doc([{ namespace: "remoteEsm", url: "https://example.test/thing.mjs" }]),
|
|
308
|
+
{
|
|
309
|
+
baseDir: FIXTURES,
|
|
310
|
+
fetchText: async () => "export const fromUrlEsm = () => 'url esm';"
|
|
311
|
+
}
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
eq(esm.scripts[0].format, "esm", ".mjs URL");
|
|
315
|
+
eq((esm.functions.fromUrlEsm as () => string)(), "url esm", "works");
|
|
316
|
+
},
|
|
317
|
+
BIG
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
/* ------------------------------ 2. the global channel ------------------------------ */
|
|
321
|
+
|
|
322
|
+
scripts.t(
|
|
323
|
+
"a script can register itself through globalThis.GraphEngine",
|
|
324
|
+
async t => {
|
|
325
|
+
fresh();
|
|
326
|
+
|
|
327
|
+
const loaded = await loadGraphScripts(
|
|
328
|
+
doc([{ namespace: "billing", path: "./global-channel.cjs" }]),
|
|
329
|
+
{ baseDir: FIXTURES }
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
deepEq(loaded.registry.names, ["invoiceOrder", "isBillable"], "registered names");
|
|
333
|
+
eq((loaded.functions.invoiceOrder as () => string)(), "invoiced", "works");
|
|
334
|
+
|
|
335
|
+
const claim = registeredNamespaces().find(entry => entry.namespace === "billing");
|
|
336
|
+
|
|
337
|
+
ok(claim !== undefined, "the claim is recorded");
|
|
338
|
+
deepEq(claim?.names, ["invoiceOrder", "isBillable"], "claim names, sorted");
|
|
339
|
+
eq(claim?.source, "path:./global-channel.cjs", "claim source");
|
|
340
|
+
|
|
341
|
+
t.log("finding: the engine global is a per-CALL slot (registerEngineNamespace), not a shared mutable table — a script can only ever add its own namespace, and only once");
|
|
342
|
+
},
|
|
343
|
+
BIG
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
scripts.t(
|
|
347
|
+
"the realm is restored after the load — the engine global does not leak",
|
|
348
|
+
async t => {
|
|
349
|
+
fresh();
|
|
350
|
+
|
|
351
|
+
const marker = { marker: "pre-existing" };
|
|
352
|
+
(globalThis as Record<string, unknown>).GraphEngine = marker;
|
|
353
|
+
|
|
354
|
+
await loadGraphScripts(
|
|
355
|
+
doc([{ namespace: "billing", path: "./global-channel.cjs" }]),
|
|
356
|
+
{ baseDir: FIXTURES }
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const after = (globalThis as Record<string, unknown>)
|
|
360
|
+
.GraphEngine as Record<string, unknown>;
|
|
361
|
+
|
|
362
|
+
ok(after === marker, "the previous global object is restored by identity");
|
|
363
|
+
eq(
|
|
364
|
+
typeof after.registerEngineNamespace,
|
|
365
|
+
"undefined",
|
|
366
|
+
"our registrar is gone"
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
delete (globalThis as Record<string, unknown>).GraphEngine;
|
|
370
|
+
|
|
371
|
+
await loadGraphScripts(
|
|
372
|
+
doc([{ namespace: "restoreTest", path: "./order.cjs" }]),
|
|
373
|
+
{ baseDir: FIXTURES }
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
eq(
|
|
377
|
+
(globalThis as Record<string, unknown>).GraphEngine,
|
|
378
|
+
undefined,
|
|
379
|
+
"with no previous global, the loader removes what it installed"
|
|
380
|
+
);
|
|
381
|
+
},
|
|
382
|
+
BIG
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
scripts.t(
|
|
386
|
+
"a script can both self-register and export; both land in the namespace",
|
|
387
|
+
async t => {
|
|
388
|
+
fresh();
|
|
389
|
+
|
|
390
|
+
const loaded = await loadGraphScripts(
|
|
391
|
+
doc([{ namespace: "telemetry", path: "./global-channel.mjs" }]),
|
|
392
|
+
{ baseDir: FIXTURES }
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
deepEq(
|
|
396
|
+
loaded.registry.names,
|
|
397
|
+
["recordRun", "traceId"],
|
|
398
|
+
"registered + exported names"
|
|
399
|
+
);
|
|
400
|
+
eq((loaded.functions.traceId as () => string)(), "trace-1", "exported");
|
|
401
|
+
eq((loaded.functions.recordRun as () => string)(), "recorded", "registered");
|
|
402
|
+
},
|
|
403
|
+
BIG
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
scripts.t(
|
|
407
|
+
"the declared namespace must match what the script registers",
|
|
408
|
+
async t => {
|
|
409
|
+
fresh();
|
|
410
|
+
|
|
411
|
+
const error = await loadError({
|
|
412
|
+
namespace: "declared-here",
|
|
413
|
+
path: "./mismatch.cjs"
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
eq(error.code, "NAMESPACE_MISMATCH", "code");
|
|
417
|
+
deepEq(
|
|
418
|
+
(error.details as { declared: string; received: string }).declared,
|
|
419
|
+
"declared-here",
|
|
420
|
+
"declared"
|
|
421
|
+
);
|
|
422
|
+
deepEq(
|
|
423
|
+
(error.details as { received: string }).received,
|
|
424
|
+
"other",
|
|
425
|
+
"received"
|
|
426
|
+
);
|
|
427
|
+
ok(error.message.includes("declared-here"), error.message);
|
|
428
|
+
},
|
|
429
|
+
BIG
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
scripts.t(
|
|
433
|
+
"a registration error from inside the script surfaces unwrapped",
|
|
434
|
+
async t => {
|
|
435
|
+
fresh();
|
|
436
|
+
|
|
437
|
+
const error = await loadError({
|
|
438
|
+
namespace: "badmin",
|
|
439
|
+
path: "./bad-registration.cjs"
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
/* NOT SCRIPT_LOAD_FAILED: the script's verdict is preserved */
|
|
443
|
+
eq(error.code, "SCRIPT_EXPORT_INVALID", "code");
|
|
444
|
+
ok(error.message.includes("notAFunction"), error.message);
|
|
445
|
+
eq(
|
|
446
|
+
(error.details as { name: string }).name,
|
|
447
|
+
"notAFunction",
|
|
448
|
+
"the offending name"
|
|
449
|
+
);
|
|
450
|
+
},
|
|
451
|
+
BIG
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
/* ------------------------------ 3. the freeze ------------------------------ */
|
|
455
|
+
|
|
456
|
+
scripts.t(
|
|
457
|
+
"a namespace can be claimed only once per process (exports path)",
|
|
458
|
+
async t => {
|
|
459
|
+
fresh();
|
|
460
|
+
|
|
461
|
+
const document = doc([{ namespace: "once", path: "./order.cjs" }]);
|
|
462
|
+
|
|
463
|
+
const first = await loadGraphScripts(document, { baseDir: FIXTURES });
|
|
464
|
+
eq(first.namespaces[0], "once", "first load");
|
|
465
|
+
|
|
466
|
+
let second: GraphScriptError | undefined;
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
await loadGraphScripts(document, { baseDir: FIXTURES });
|
|
470
|
+
} catch (error) {
|
|
471
|
+
second = error as GraphScriptError;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
ok(second instanceof GraphScriptError, "the second load throws");
|
|
475
|
+
eq(second?.code, "NAMESPACE_ALREADY_REGISTERED", "code");
|
|
476
|
+
eq(
|
|
477
|
+
(second?.details as { claimedBy: string; requestedBy: string }).claimedBy,
|
|
478
|
+
"path:./order.cjs",
|
|
479
|
+
"the error names the original claim"
|
|
480
|
+
);
|
|
481
|
+
ok(
|
|
482
|
+
second?.message.includes("once per process") === true,
|
|
483
|
+
second?.message ?? "no message"
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
resetGraphScriptNamespaces();
|
|
487
|
+
|
|
488
|
+
const third = await loadGraphScripts(document, { baseDir: FIXTURES });
|
|
489
|
+
eq(third.namespaces[0], "once", "after reset it loads again");
|
|
490
|
+
|
|
491
|
+
t.log("finding: the freeze is PROCESS-global by design — one document, one engine per namespace. Loading the same graph file into a second engine throws until the claims are reset");
|
|
492
|
+
},
|
|
493
|
+
BIG
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
scripts.t(
|
|
497
|
+
"a self-registering script throws on the duplicate claim FROM INSIDE the script",
|
|
498
|
+
async t => {
|
|
499
|
+
fresh();
|
|
500
|
+
|
|
501
|
+
await loadGraphScripts(
|
|
502
|
+
doc([{ namespace: "billing", path: "./global-channel.cjs" }]),
|
|
503
|
+
{ baseDir: FIXTURES }
|
|
504
|
+
);
|
|
505
|
+
|
|
506
|
+
/* a second document declaring the same namespace: the script itself
|
|
507
|
+
* calls the registrar and the registrar refuses */
|
|
508
|
+
const error = await loadError({
|
|
509
|
+
namespace: "billing",
|
|
510
|
+
path: "./global-channel.cjs"
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
eq(error.code, "NAMESPACE_ALREADY_REGISTERED", "code");
|
|
514
|
+
eq(
|
|
515
|
+
(error.details as { requestedBy: string }).requestedBy,
|
|
516
|
+
"path:./global-channel.cjs",
|
|
517
|
+
"requestedBy"
|
|
518
|
+
);
|
|
519
|
+
},
|
|
520
|
+
BIG
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
scripts.t(
|
|
524
|
+
"a claim made before a crashing script survives; the load still fails",
|
|
525
|
+
async t => {
|
|
526
|
+
fresh();
|
|
527
|
+
|
|
528
|
+
const error = await loadError({
|
|
529
|
+
namespace: "halfway",
|
|
530
|
+
path: "./throwing-global.mjs"
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
eq(error.code, "SCRIPT_LOAD_FAILED", "the load failed");
|
|
534
|
+
ok(error.message.includes("exploded after registering"), error.message);
|
|
535
|
+
|
|
536
|
+
deepEq(
|
|
537
|
+
registeredNamespaces().map(claim => claim.namespace),
|
|
538
|
+
["halfway"],
|
|
539
|
+
"the claim was already made when the script threw"
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
t.log("finding: claims are made at registration time, not at load end — a script that registers and then throws leaves its namespace claimed (reload needs resetGraphScriptNamespaces)");
|
|
543
|
+
},
|
|
544
|
+
BIG
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
scripts.t(
|
|
548
|
+
"a script that throws is not replayed in the other format",
|
|
549
|
+
async t => {
|
|
550
|
+
fresh();
|
|
551
|
+
|
|
552
|
+
let executions = 0;
|
|
553
|
+
|
|
554
|
+
/* self-registration makes a replay detectable: the second run would
|
|
555
|
+
* hit the namespace freeze, and the counter proves the first */
|
|
556
|
+
const source = [
|
|
557
|
+
"globalThis.__replayProbe = (globalThis.__replayProbe || 0) + 1;",
|
|
558
|
+
"globalThis.GraphEngine.registerEngineNamespace('replayProbe', { probe: () => 1 });",
|
|
559
|
+
"throw new Error('fixture: fails after side effects');"
|
|
560
|
+
].join("\n");
|
|
561
|
+
|
|
562
|
+
/* run the real probe: inline source, format auto (cjs → esm fallback
|
|
563
|
+
* would be attempted for a FORMAT error only) */
|
|
564
|
+
fresh();
|
|
565
|
+
|
|
566
|
+
let caught: GraphScriptError | undefined;
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
await loadGraphScripts(doc([{ namespace: "replayProbe", source }]), {
|
|
570
|
+
baseDir: FIXTURES
|
|
571
|
+
});
|
|
572
|
+
} catch (thrown) {
|
|
573
|
+
caught = thrown as GraphScriptError;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
executions = Number(
|
|
577
|
+
(globalThis as Record<string, unknown>).__replayProbe ?? 0
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
delete (globalThis as Record<string, unknown>).__replayProbe;
|
|
581
|
+
|
|
582
|
+
eq(caught?.code, "SCRIPT_LOAD_FAILED", "the load failed");
|
|
583
|
+
eq(executions, 1, "the script body ran EXACTLY once");
|
|
584
|
+
|
|
585
|
+
t.log("finding: format fallback replays the script body, so it is reserved for module-format failures (ERR_REQUIRE_ESM, ESM syntax in CJS). A script that throws — or registers and then throws — is never re-executed; it ran once and its claim stands");
|
|
586
|
+
},
|
|
587
|
+
BIG
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
/* ------------------------------ 4. merging & collisions ------------------------------ */
|
|
591
|
+
|
|
592
|
+
scripts.t(
|
|
593
|
+
"two scripts providing the same name collide; override is opt-in",
|
|
594
|
+
async t => {
|
|
595
|
+
fresh();
|
|
596
|
+
|
|
597
|
+
const document = doc([
|
|
598
|
+
{ namespace: "teamA", path: "./collide-a.cjs" },
|
|
599
|
+
{ namespace: "teamB", path: "./collide-b.cjs" }
|
|
600
|
+
]);
|
|
601
|
+
|
|
602
|
+
let error: GraphScriptError | undefined;
|
|
603
|
+
|
|
604
|
+
try {
|
|
605
|
+
await loadGraphScripts(document, { baseDir: FIXTURES });
|
|
606
|
+
} catch (thrown) {
|
|
607
|
+
error = thrown as GraphScriptError;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
ok(error instanceof GraphScriptError, "a collision throws by default");
|
|
611
|
+
eq(error?.code, "FUNCTION_NAME_COLLISION", "code");
|
|
612
|
+
deepEq(
|
|
613
|
+
error?.details as { name: string; first: string; second: string },
|
|
614
|
+
{ name: "shared", first: "teamA", second: "teamB" },
|
|
615
|
+
"the error names both sides"
|
|
616
|
+
);
|
|
617
|
+
|
|
618
|
+
/* claims made before the collision stay — that is the freeze working */
|
|
619
|
+
fresh();
|
|
620
|
+
|
|
621
|
+
const log = logCollector();
|
|
622
|
+
|
|
623
|
+
const loaded = await loadGraphScripts(document, {
|
|
624
|
+
baseDir: FIXTURES,
|
|
625
|
+
collisions: "override",
|
|
626
|
+
onLog: log.onLog
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
eq(
|
|
630
|
+
(loaded.functions.shared as () => string)(),
|
|
631
|
+
"from B",
|
|
632
|
+
"with override, the later script wins"
|
|
633
|
+
);
|
|
634
|
+
eq((loaded.functions.onlyA as () => string)(), "A", "the first script still contributed");
|
|
635
|
+
eq((loaded.functions.onlyB as () => string)(), "B", "and so did the second");
|
|
636
|
+
ok(
|
|
637
|
+
log.lines.some(line => line.message.includes("overrides")),
|
|
638
|
+
"the override is logged"
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
t.log("finding: claims and name collisions are separate gates — the freeze stops the namespace, collisions stop the merged table, and only the second one has an override");
|
|
642
|
+
},
|
|
643
|
+
BIG
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
/* ------------------------------ 5. validation ------------------------------ */
|
|
647
|
+
|
|
648
|
+
scripts.t(
|
|
649
|
+
"script declarations are validated, with JSON paths",
|
|
650
|
+
async t => {
|
|
651
|
+
const cases: Array<[unknown, string]> = [
|
|
652
|
+
[{ namespace: "a" }, "scripts[0]"],
|
|
653
|
+
[
|
|
654
|
+
{ namespace: "b", path: "./x.cjs", url: "https://x" },
|
|
655
|
+
"scripts[0]"
|
|
656
|
+
],
|
|
657
|
+
[{ namespace: "c", path: "" }, "scripts[0].path"],
|
|
658
|
+
[{ namespace: "d", path: "./x.cjs", format: "wasm" }, "scripts[0].format"],
|
|
659
|
+
[{ namespace: "has space", path: "./x.cjs" }, "scripts[0].namespace"],
|
|
660
|
+
[{ namespace: "e", path: "./x.cjs", loader: "y" }, "scripts[0].loader"]
|
|
661
|
+
];
|
|
662
|
+
|
|
663
|
+
for (const [declaration, expectedPath] of cases) {
|
|
664
|
+
try {
|
|
665
|
+
parseGraphJSONDocument(doc([declaration]));
|
|
666
|
+
throw new Error(`expected an issue for ${JSON.stringify(declaration)}`);
|
|
667
|
+
} catch (error) {
|
|
668
|
+
if (!(error instanceof GraphJSONError)) throw error;
|
|
669
|
+
|
|
670
|
+
ok(
|
|
671
|
+
error.issues.some(issue => issue.path === expectedPath),
|
|
672
|
+
`${expectedPath} missing from ${error.issues
|
|
673
|
+
.map(issue => issue.path)
|
|
674
|
+
.join(",")}`
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
},
|
|
679
|
+
BIG
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
scripts.t(
|
|
683
|
+
"the same namespace twice in one document is a parse issue",
|
|
684
|
+
async t => {
|
|
685
|
+
try {
|
|
686
|
+
parseGraphJSONDocument(
|
|
687
|
+
doc([
|
|
688
|
+
{ namespace: "twice", path: "./a.cjs" },
|
|
689
|
+
{ namespace: "twice", source: "module.exports = {};" }
|
|
690
|
+
])
|
|
691
|
+
);
|
|
692
|
+
throw new Error("expected a GraphJSONError");
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (!(error instanceof GraphJSONError)) throw error;
|
|
695
|
+
|
|
696
|
+
eq(error.issues.length, 1, "one issue");
|
|
697
|
+
eq(error.issues[0].path, "scripts[1].namespace", "path");
|
|
698
|
+
ok(error.issues[0].message.includes("twice in this document"), error.issues[0].message);
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
BIG
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
scripts.t(
|
|
705
|
+
"a scripted document still parses with the v1.4 entry point",
|
|
706
|
+
async t => {
|
|
707
|
+
const document = doc([{ namespace: "parseOnly", path: "./order.cjs" }]);
|
|
708
|
+
|
|
709
|
+
const definition = parseGraphJSON(document);
|
|
710
|
+
const parsed = parseGraphJSONDocument(document);
|
|
711
|
+
|
|
712
|
+
deepEq(definition, parsed.definition, "parseGraphJSON is the definition alone");
|
|
713
|
+
eq(parsed.scripts.length, 1, "the declaration is surfaced");
|
|
714
|
+
deepEq(
|
|
715
|
+
parsed.scripts[0],
|
|
716
|
+
{ namespace: "parseOnly", path: "./order.cjs" },
|
|
717
|
+
"declaration shape"
|
|
718
|
+
);
|
|
719
|
+
eq(Object.isFrozen(parsed.scripts), true, "scripts are frozen");
|
|
720
|
+
|
|
721
|
+
t.log("finding: `scripts` is validated by the isomorphic parser and executed by the Node loader — parseGraphJSON (sync, v1.4) keeps working on scripted documents");
|
|
722
|
+
},
|
|
723
|
+
BIG
|
|
724
|
+
);
|
|
725
|
+
|
|
726
|
+
scripts.t(
|
|
727
|
+
"the document is validated BEFORE any script executes",
|
|
728
|
+
async t => {
|
|
729
|
+
fresh();
|
|
730
|
+
|
|
731
|
+
const broken = doc([{ namespace: "neverRuns", path: "./order.cjs" }]);
|
|
732
|
+
|
|
733
|
+
/* break the graph, not the script */
|
|
734
|
+
(broken.nodes as Record<string, { next: string[] }>).a.next = ["ghost"];
|
|
735
|
+
|
|
736
|
+
let error: GraphJSONError | undefined;
|
|
737
|
+
|
|
738
|
+
try {
|
|
739
|
+
await loadGraphScripts(broken, { baseDir: FIXTURES });
|
|
740
|
+
} catch (thrown) {
|
|
741
|
+
error = thrown as GraphJSONError;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
ok(error instanceof GraphJSONError, "the document is rejected");
|
|
745
|
+
ok(
|
|
746
|
+
error?.issues.some(issue => issue.path === "nodes.a.next[0]"),
|
|
747
|
+
error?.issues.map(issue => issue.path).join(",") ?? "no issues"
|
|
748
|
+
);
|
|
749
|
+
deepEq(
|
|
750
|
+
registeredNamespaces(),
|
|
751
|
+
[],
|
|
752
|
+
"no namespace was claimed: no code ran"
|
|
753
|
+
);
|
|
754
|
+
},
|
|
755
|
+
BIG
|
|
756
|
+
);
|
|
757
|
+
|
|
758
|
+
scripts.t(
|
|
759
|
+
"a missing file, a crashing script and a function-less script fail clearly",
|
|
760
|
+
async t => {
|
|
761
|
+
fresh();
|
|
762
|
+
|
|
763
|
+
const missing = await loadError({
|
|
764
|
+
namespace: "missing",
|
|
765
|
+
path: "./does-not-exist.cjs"
|
|
766
|
+
});
|
|
767
|
+
eq(missing.code, "SCRIPT_LOAD_FAILED", "missing code");
|
|
768
|
+
ok(missing.message.includes("not found"), missing.message);
|
|
769
|
+
ok(
|
|
770
|
+
(missing.details as { resolved: string }).resolved.includes("does-not-exist.cjs"),
|
|
771
|
+
"the resolved path is reported"
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
fresh();
|
|
775
|
+
|
|
776
|
+
const broken = await loadError({
|
|
777
|
+
namespace: "broken",
|
|
778
|
+
path: "./broken.cjs"
|
|
779
|
+
});
|
|
780
|
+
eq(broken.code, "SCRIPT_LOAD_FAILED", "broken code");
|
|
781
|
+
ok(broken.message.includes("fixture-broken"), "the cause message is kept");
|
|
782
|
+
|
|
783
|
+
fresh();
|
|
784
|
+
|
|
785
|
+
const empty = await loadError({
|
|
786
|
+
namespace: "empty",
|
|
787
|
+
path: "./empty.cjs"
|
|
788
|
+
});
|
|
789
|
+
eq(empty.code, "SCRIPT_NO_FUNCTIONS", "empty code");
|
|
790
|
+
ok(empty.message.includes("only non-function exports: answer"), empty.message);
|
|
791
|
+
|
|
792
|
+
fresh();
|
|
793
|
+
|
|
794
|
+
const silent = await loadError({
|
|
795
|
+
namespace: "silent",
|
|
796
|
+
source: "/* nothing at all */"
|
|
797
|
+
});
|
|
798
|
+
eq(silent.code, "SCRIPT_NO_FUNCTIONS", "silent code");
|
|
799
|
+
ok(silent.message.includes("never called registerEngineNamespace"), silent.message);
|
|
800
|
+
},
|
|
801
|
+
BIG
|
|
802
|
+
);
|
|
803
|
+
|
|
804
|
+
/* ------------------------------ 6. end to end ------------------------------ */
|
|
805
|
+
|
|
806
|
+
scripts.t(
|
|
807
|
+
"a scripted document runs: the script provides the node and edge functions",
|
|
808
|
+
async t => {
|
|
809
|
+
fresh();
|
|
810
|
+
|
|
811
|
+
const document = {
|
|
812
|
+
id: "order-flow-scripted",
|
|
813
|
+
version: "1.5.0",
|
|
814
|
+
entryNodes: ["classify"],
|
|
815
|
+
terminalNodes: ["ship", "manual"],
|
|
816
|
+
completionPolicy: "FIRST_TERMINAL",
|
|
817
|
+
scripts: [{ namespace: "order", path: "./order.cjs" }],
|
|
818
|
+
nodes: {
|
|
819
|
+
classify: {
|
|
820
|
+
run: "classifyOrder",
|
|
821
|
+
next: [
|
|
822
|
+
{ test: "isPriority", nextNode: "charge" },
|
|
823
|
+
{ nextNode: "manual" }
|
|
824
|
+
]
|
|
825
|
+
},
|
|
826
|
+
charge: {
|
|
827
|
+
run: "chargeCard",
|
|
828
|
+
next: ["ship"],
|
|
829
|
+
onThrow: "manual"
|
|
830
|
+
},
|
|
831
|
+
ship: { run: "shipOrder" },
|
|
832
|
+
manual: { run: "manualReview" }
|
|
833
|
+
}
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const loaded = await loadGraphScripts(document, { baseDir: FIXTURES });
|
|
837
|
+
const engine = createEngineFromJSON(document, {
|
|
838
|
+
functions: loaded.functions,
|
|
839
|
+
lint: "error"
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
const report = await engine.run();
|
|
843
|
+
|
|
844
|
+
eq(report.status, "COMPLETED", "status");
|
|
845
|
+
deepEq(
|
|
846
|
+
report.results.map(result => result.nodeId),
|
|
847
|
+
["classify", "charge", "ship"],
|
|
848
|
+
"path"
|
|
849
|
+
);
|
|
850
|
+
deepEq(
|
|
851
|
+
report.results.map(result => result.fn),
|
|
852
|
+
["classifyOrder", "chargeCard", "shipOrder"],
|
|
853
|
+
"every activation names its script function"
|
|
854
|
+
);
|
|
855
|
+
eq(report.outputs.ship, "shipped", "output from the script");
|
|
856
|
+
deepEq(report.lintIssues, [], "lint-clean under lint: \"error\"");
|
|
857
|
+
eq(
|
|
858
|
+
report.registry?.fingerprint,
|
|
859
|
+
loaded.registry.fingerprint,
|
|
860
|
+
"the engine reports the identity of the loaded table"
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
t.log(
|
|
864
|
+
`finding: end-to-end, ${loaded.registry.names.length} functions arrive from ${loaded.scripts[0].source} and the engine reports ${loaded.registry.fingerprint}`
|
|
865
|
+
);
|
|
866
|
+
},
|
|
867
|
+
BIG
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
scripts.t(
|
|
871
|
+
"a script's own sibling requires resolve from the script's directory",
|
|
872
|
+
async t => {
|
|
873
|
+
fresh();
|
|
874
|
+
|
|
875
|
+
const loaded = await loadGraphScripts(
|
|
876
|
+
doc([{ namespace: "sibling", path: "./needs-sibling.cjs" }]),
|
|
877
|
+
{ baseDir: FIXTURES }
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
eq(
|
|
881
|
+
(loaded.functions.usesSibling as () => string)(),
|
|
882
|
+
"sibling said hi",
|
|
883
|
+
"real require() with real relative resolution"
|
|
884
|
+
);
|
|
885
|
+
},
|
|
886
|
+
BIG
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
scripts.t(
|
|
890
|
+
"inline scripts compose with path scripts in one document",
|
|
891
|
+
async t => {
|
|
892
|
+
fresh();
|
|
893
|
+
|
|
894
|
+
const loaded = await loadGraphScripts(
|
|
895
|
+
doc([
|
|
896
|
+
{ namespace: "filePart", path: "./cjs-default.js" },
|
|
897
|
+
{
|
|
898
|
+
namespace: "inlinePart",
|
|
899
|
+
source:
|
|
900
|
+
"globalThis.GraphEngine.registerEngineNamespace('inlinePart', " +
|
|
901
|
+
"{ inlineRegistered: () => 'via global' });"
|
|
902
|
+
}
|
|
903
|
+
]),
|
|
904
|
+
{ baseDir: FIXTURES }
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
deepEq(
|
|
908
|
+
loaded.namespaces,
|
|
909
|
+
["filePart", "inlinePart"],
|
|
910
|
+
"declaration order"
|
|
911
|
+
);
|
|
912
|
+
deepEq(
|
|
913
|
+
loaded.registry.names,
|
|
914
|
+
["inlineRegistered", "plainCjs"],
|
|
915
|
+
"merged table"
|
|
916
|
+
);
|
|
917
|
+
eq((loaded.functions.plainCjs as () => string)(), "plain cjs", "file part");
|
|
918
|
+
eq((loaded.functions.inlineRegistered as () => string)(), "via global", "inline part");
|
|
919
|
+
|
|
920
|
+
t.log("finding: the three media (path, source, url) are interchangeable — one pipeline merges them into a single table with one fingerprint");
|
|
921
|
+
},
|
|
922
|
+
BIG
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
scripts.t(
|
|
926
|
+
"the loaded table is frozen and the engine can be built from it directly",
|
|
927
|
+
async t => {
|
|
928
|
+
fresh();
|
|
929
|
+
|
|
930
|
+
const loaded = await loadGraphScripts(
|
|
931
|
+
doc([{ namespace: "frozen", path: "./order.cjs" }]),
|
|
932
|
+
{ baseDir: FIXTURES }
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
eq(Object.isFrozen(loaded.functions), true, "frozen");
|
|
936
|
+
eq(Object.isFrozen(loaded.scripts), true, "scripts frozen");
|
|
937
|
+
eq(Object.isFrozen(loaded.namespaces), true, "namespaces frozen");
|
|
938
|
+
|
|
939
|
+
let mutated = false;
|
|
940
|
+
|
|
941
|
+
try {
|
|
942
|
+
(loaded.functions as Record<string, unknown>).sneaky = () => 1;
|
|
943
|
+
} catch {
|
|
944
|
+
mutated = true;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
eq(
|
|
948
|
+
mutated || !("sneaky" in loaded.functions),
|
|
949
|
+
true,
|
|
950
|
+
"the table cannot be extended"
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
const engine = new GraphExecutionEngine(
|
|
954
|
+
{
|
|
955
|
+
id: "direct",
|
|
956
|
+
version: "1.0.0",
|
|
957
|
+
entryNodes: ["a"],
|
|
958
|
+
terminalNodes: ["a"],
|
|
959
|
+
nodes: { a: { id: "a", run: () => "direct" } }
|
|
960
|
+
},
|
|
961
|
+
{ functions: loaded.functions }
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
const report = await engine.run();
|
|
965
|
+
|
|
966
|
+
eq(report.status, "COMPLETED", "engine built from the loaded table");
|
|
967
|
+
eq(report.registry?.fingerprint, loaded.registry.fingerprint, "same identity");
|
|
968
|
+
},
|
|
969
|
+
BIG
|
|
970
|
+
);
|