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.
Files changed (88) hide show
  1. package/CHANGELOG.md +18 -5
  2. package/UPGRADE-PROPOSAL.md +141 -0
  3. package/docs/diagrams/c19-resilience-scenario.md +61 -0
  4. package/docs/diagrams/c19-resilience-scenario.mmd +28 -0
  5. package/docs/diagrams/c19-resilience-scenario.svg +181 -0
  6. package/examples/README.md +206 -0
  7. package/examples/branching-edges.ts +192 -0
  8. package/examples/branching-graph.ts +333 -0
  9. package/examples/branching-router.ts +475 -0
  10. package/examples/json-graph.ts +231 -0
  11. package/examples/json-scripts.ts +152 -0
  12. package/examples/order-flow.functions.cjs +32 -0
  13. package/examples/order-flow.graph.json +27 -0
  14. package/examples/order-flow.scripted.graph.json +68 -0
  15. package/js/examples/branching-edges.js +133 -0
  16. package/js/examples/branching-edges.js.map +1 -0
  17. package/js/examples/branching-graph.js +232 -0
  18. package/js/examples/branching-graph.js.map +1 -0
  19. package/js/examples/branching-router.js +305 -0
  20. package/js/examples/branching-router.js.map +1 -0
  21. package/js/examples/json-graph.js +197 -0
  22. package/js/examples/json-graph.js.map +1 -0
  23. package/js/examples/json-scripts.js +131 -0
  24. package/js/examples/json-scripts.js.map +1 -0
  25. package/js/tests/async.test.js +592 -0
  26. package/js/tests/async.test.js.map +1 -0
  27. package/js/tests/complex.test.js +972 -0
  28. package/js/tests/complex.test.js.map +1 -0
  29. package/js/tests/conformance.test.js +365 -0
  30. package/js/tests/conformance.test.js.map +1 -0
  31. package/js/tests/edges.test.js +702 -0
  32. package/js/tests/edges.test.js.map +1 -0
  33. package/js/tests/graphjson.test.js +504 -0
  34. package/js/tests/graphjson.test.js.map +1 -0
  35. package/js/tests/harness.js +192 -0
  36. package/js/tests/harness.js.map +1 -0
  37. package/js/tests/helpers.js +136 -0
  38. package/js/tests/helpers.js.map +1 -0
  39. package/js/tests/lint.test.js +317 -0
  40. package/js/tests/lint.test.js.map +1 -0
  41. package/js/tests/registry.test.js +705 -0
  42. package/js/tests/registry.test.js.map +1 -0
  43. package/js/tests/report.js +422 -0
  44. package/js/tests/report.js.map +1 -0
  45. package/js/tests/runner.js +67 -0
  46. package/js/tests/runner.js.map +1 -0
  47. package/js/tests/scripts.test.js +552 -0
  48. package/js/tests/scripts.test.js.map +1 -0
  49. package/js/tests/unit.test.js +1239 -0
  50. package/js/tests/unit.test.js.map +1 -0
  51. package/js/tests/upgrade.test.js +618 -0
  52. package/js/tests/upgrade.test.js.map +1 -0
  53. package/package.json +2 -9
  54. package/report/index.html +3727 -0
  55. package/report/report.json +3053 -0
  56. package/tests/async.test.ts +668 -0
  57. package/tests/complex.test.ts +1123 -0
  58. package/tests/conformance.test.ts +442 -0
  59. package/tests/edges.test.ts +848 -0
  60. package/tests/fixtures/scripts/bad-registration.cjs +4 -0
  61. package/tests/fixtures/scripts/broken.cjs +2 -0
  62. package/tests/fixtures/scripts/cjs-default.js +4 -0
  63. package/tests/fixtures/scripts/collide-a.cjs +4 -0
  64. package/tests/fixtures/scripts/collide-b.cjs +4 -0
  65. package/tests/fixtures/scripts/empty.cjs +2 -0
  66. package/tests/fixtures/scripts/esm-functions.mjs +10 -0
  67. package/tests/fixtures/scripts/esm-syntax.js +3 -0
  68. package/tests/fixtures/scripts/global-channel.cjs +6 -0
  69. package/tests/fixtures/scripts/global-channel.mjs +7 -0
  70. package/tests/fixtures/scripts/mismatch.cjs +4 -0
  71. package/tests/fixtures/scripts/needs-sibling.cjs +7 -0
  72. package/tests/fixtures/scripts/order.cjs +14 -0
  73. package/tests/fixtures/scripts/sibling-helper.cjs +3 -0
  74. package/tests/fixtures/scripts/throwing-global.mjs +6 -0
  75. package/tests/graphjson.test.ts +737 -0
  76. package/tests/harness.ts +291 -0
  77. package/tests/helpers.ts +216 -0
  78. package/tests/lint.test.ts +381 -0
  79. package/tests/registry.test.ts +1134 -0
  80. package/tests/report.ts +452 -0
  81. package/tests/runner.ts +58 -0
  82. package/tests/scripts.test.ts +970 -0
  83. package/tests/unit.test.ts +1192 -0
  84. package/tests/upgrade.test.ts +725 -0
  85. package/tools/server.mjs +152 -0
  86. package/tsconfig.browser.json +19 -0
  87. package/tsconfig.json +18 -0
  88. package/tsconfig.publish.json +13 -0
@@ -0,0 +1,291 @@
1
+ /* ============================================================
2
+ * Minimal zero-dependency test harness
3
+ * ------------------------------------------------------------
4
+ * - deterministic, sequential execution (isolation per test)
5
+ * - per-test timeout
6
+ * - per-test log capture (surfaced in the web report)
7
+ * - findings tagging (documents observed engine semantics)
8
+ * ============================================================ */
9
+
10
+ export const TEST_KINDS = [
11
+ "unit",
12
+ "integration",
13
+ "async",
14
+ "upgrade",
15
+ "conformance",
16
+ "lint",
17
+ "edges",
18
+ "registry",
19
+ "graphjson",
20
+ "scripts"
21
+ ] as const;
22
+
23
+ export type TestKind = (typeof TEST_KINDS)[number];
24
+
25
+ export interface KindTotals {
26
+ total: number;
27
+ passed: number;
28
+ failed: number;
29
+ }
30
+
31
+ export interface TestContext {
32
+ /** Free-form observation log, rendered in the web report. */
33
+ log(message: string, ...values: unknown[]): void;
34
+ }
35
+
36
+ export interface TestCase {
37
+ readonly id: string;
38
+ readonly kind: TestKind;
39
+ readonly category: string;
40
+ readonly name: string;
41
+ /** Non-empty => this test documents a noteworthy engine semantic. */
42
+ readonly finding?: string;
43
+ readonly timeoutMs: number;
44
+ readonly fn: (ctx: TestContext) => unknown | Promise<unknown>;
45
+ }
46
+
47
+ export interface TestResultRecord {
48
+ id: string;
49
+ kind: TestKind;
50
+ category: string;
51
+ name: string;
52
+ finding?: string;
53
+ status: "passed" | "failed";
54
+ durationMs: number;
55
+ logs: string[];
56
+ error?: { name: string; message: string; stack?: string };
57
+ }
58
+
59
+ const registry: TestCase[] = [];
60
+
61
+ let counters: Record<string, number> = { unit: 0, integration: 0, async: 0, upgrade: 0, conformance: 0, lint: 0, edges: 0, registry: 0, graphjson: 0, scripts: 0 };
62
+
63
+ export function suite(kind: TestKind, category: string) {
64
+ return {
65
+ /**
66
+ * Register a test case.
67
+ */
68
+ t(
69
+ name: string,
70
+ fn: TestCase["fn"],
71
+ options: { timeoutMs?: number; finding?: string } = {}
72
+ ): void {
73
+ counters[kind] += 1;
74
+ registry.push({
75
+ id: `${kind[0].toUpperCase()}${counters[kind]}`,
76
+ kind,
77
+ category,
78
+ name,
79
+ finding: options.finding,
80
+ timeoutMs: options.timeoutMs ?? 10_000,
81
+ fn
82
+ });
83
+ }
84
+ };
85
+ }
86
+
87
+ export function allTests(): readonly TestCase[] {
88
+ return registry;
89
+ }
90
+
91
+ function formatLogValue(value: unknown): string {
92
+ if (value === undefined) return "";
93
+ try {
94
+ return JSON.stringify(value, (_k, v) =>
95
+ typeof v === "bigint" ? v.toString() : v
96
+ )!;
97
+ } catch {
98
+ return String(value);
99
+ }
100
+ }
101
+
102
+ function errorShape(value: unknown): TestResultRecord["error"] {
103
+ if (value instanceof Error) {
104
+ return {
105
+ name: value.name,
106
+ message: value.message,
107
+ stack: value.stack
108
+ };
109
+ }
110
+ return { name: "NonErrorThrow", message: String(value) };
111
+ }
112
+
113
+ async function withTimeout<T>(
114
+ operation: Promise<T>,
115
+ timeoutMs: number,
116
+ label: string
117
+ ): Promise<T> {
118
+ let timer: ReturnType<typeof setTimeout> | undefined;
119
+
120
+ const guard = new Promise<never>((_, reject) => {
121
+ timer = setTimeout(
122
+ () =>
123
+ reject(
124
+ new Error(
125
+ `Test "${label}" exceeded harness timeout of ${timeoutMs}ms ` +
126
+ `(possible engine hang)`
127
+ )
128
+ ),
129
+ timeoutMs
130
+ );
131
+ });
132
+
133
+ try {
134
+ return await Promise.race([operation, guard]);
135
+ } finally {
136
+ if (timer !== undefined) clearTimeout(timer);
137
+ }
138
+ }
139
+
140
+ export interface RunSummary {
141
+ generatedAt: string;
142
+ nodeVersion: string;
143
+ durationMs: number;
144
+ totals: {
145
+ total: number;
146
+ passed: number;
147
+ failed: number;
148
+ byKind: Record<TestKind, KindTotals>;
149
+ };
150
+ byCategory: Array<{
151
+ category: string;
152
+ kind: TestKind;
153
+ total: number;
154
+ passed: number;
155
+ failed: number;
156
+ durationMs: number;
157
+ }>;
158
+ unhandledRejections: string[];
159
+ tests: TestResultRecord[];
160
+ }
161
+
162
+ export async function runAll(): Promise<RunSummary> {
163
+ const unhandledRejections: string[] = [];
164
+
165
+ const onUnhandled = (reason: unknown) => {
166
+ unhandledRejections.push(
167
+ reason instanceof Error
168
+ ? `${reason.name}: ${reason.message}`
169
+ : String(reason)
170
+ );
171
+ };
172
+
173
+ process.on("unhandledRejection", onUnhandled);
174
+
175
+ const results: TestResultRecord[] = [];
176
+ const startedAll = performance.now();
177
+
178
+ for (const test of allTests()) {
179
+ const logs: string[] = [];
180
+ const ctx: TestContext = {
181
+ log(message, ...values) {
182
+ const suffix = values
183
+ .filter(value => value !== undefined)
184
+ .map(value => formatLogValue(value))
185
+ .join(" ; ");
186
+ logs.push(`${message}${suffix ? ` :: ${suffix}` : ""}`);
187
+ }
188
+ };
189
+
190
+ const started = performance.now();
191
+ let record: TestResultRecord;
192
+
193
+ try {
194
+ await withTimeout(
195
+ Promise.resolve().then(() => test.fn(ctx)),
196
+ test.timeoutMs,
197
+ test.name
198
+ );
199
+ record = {
200
+ id: test.id,
201
+ kind: test.kind,
202
+ category: test.category,
203
+ name: test.name,
204
+ finding: test.finding,
205
+ status: "passed",
206
+ durationMs: Math.round((performance.now() - started) * 100) / 100,
207
+ logs
208
+ };
209
+ } catch (error) {
210
+ record = {
211
+ id: test.id,
212
+ kind: test.kind,
213
+ category: test.category,
214
+ name: test.name,
215
+ finding: test.finding,
216
+ status: "failed",
217
+ durationMs: Math.round((performance.now() - started) * 100) / 100,
218
+ logs,
219
+ error: errorShape(error)
220
+ };
221
+ }
222
+
223
+ results.push(record);
224
+
225
+ const mark = record.status === "passed" ? "PASS" : "FAIL";
226
+ process.stdout.write(
227
+ `[${mark}] ${record.id} ${record.category} :: ${record.name} (${record.durationMs}ms)\n`
228
+ );
229
+ if (record.error) {
230
+ process.stdout.write(` -> ${record.error.name}: ${record.error.message}\n`);
231
+ }
232
+ }
233
+
234
+ const durationMs = Math.round((performance.now() - startedAll) * 100) / 100;
235
+
236
+ // Give detached (non-required) listeners a chance to surface problems
237
+ // before we snapshot unhandled rejections.
238
+ await new Promise(resolve => setTimeout(resolve, 50));
239
+ process.off("unhandledRejection", onUnhandled);
240
+
241
+ const categories = new Map<string, RunSummary["byCategory"][number]>();
242
+
243
+ for (const record of results) {
244
+ const key = `${record.kind}::${record.category}`;
245
+ let entry = categories.get(key);
246
+ if (!entry) {
247
+ entry = {
248
+ category: record.category,
249
+ kind: record.kind,
250
+ total: 0,
251
+ passed: 0,
252
+ failed: 0,
253
+ durationMs: 0
254
+ };
255
+ categories.set(key, entry);
256
+ }
257
+ entry.total += 1;
258
+ entry.passed += record.status === "passed" ? 1 : 0;
259
+ entry.failed += record.status === "failed" ? 1 : 0;
260
+ entry.durationMs = Math.round((entry.durationMs + record.durationMs) * 100) / 100;
261
+ }
262
+
263
+ const byKind = Object.fromEntries(
264
+ TEST_KINDS.map(kind => {
265
+ const group = results.filter(record => record.kind === kind);
266
+ return [
267
+ kind,
268
+ {
269
+ total: group.length,
270
+ passed: group.filter(record => record.status === "passed").length,
271
+ failed: group.filter(record => record.status === "failed").length
272
+ }
273
+ ];
274
+ })
275
+ ) as Record<TestKind, KindTotals>;
276
+
277
+ return {
278
+ generatedAt: new Date().toISOString(),
279
+ nodeVersion: process.version,
280
+ durationMs,
281
+ totals: {
282
+ total: results.length,
283
+ passed: results.filter(record => record.status === "passed").length,
284
+ failed: results.filter(record => record.status === "failed").length,
285
+ byKind
286
+ },
287
+ byCategory: [...categories.values()],
288
+ unhandledRejections,
289
+ tests: results
290
+ };
291
+ }
@@ -0,0 +1,216 @@
1
+ /* ============================================================
2
+ * Shared fixtures / builders / mocks for the AutomateGraph suite
3
+ * ============================================================ */
4
+
5
+ import {
6
+ EnginePlugin,
7
+ ErrorPolicy,
8
+ ExecutionSnapshot,
9
+ GraphDefinition,
10
+ NodeContext,
11
+ NodeDefinition,
12
+ StoragePlugin
13
+ } from "../src/automategraph";
14
+
15
+ export const sleep = (ms: number): Promise<void> =>
16
+ new Promise(resolve => setTimeout(resolve, ms));
17
+
18
+ /** Generous per-test timeout for engine round-trips (macrotasks + storage). */
19
+ export const BIG = { timeoutMs: 20_000 };
20
+
21
+ export function node(
22
+ id: string,
23
+ run: NodeDefinition["run"],
24
+ extra: Omit<Partial<NodeDefinition>, "id" | "run"> = {}
25
+ ): NodeDefinition {
26
+ return { id, run, ...extra };
27
+ }
28
+
29
+ export function constNode(
30
+ id: string,
31
+ output: unknown,
32
+ extra: Omit<Partial<NodeDefinition>, "id" | "run"> = {}
33
+ ): NodeDefinition {
34
+ return node(id, () => output, extra);
35
+ }
36
+
37
+ export interface GraphSpec {
38
+ nodes: NodeDefinition[];
39
+ entryNodes: string[];
40
+ terminalNodes: string[];
41
+ id?: string;
42
+ version?: string;
43
+ defaultNodeTimeoutMs?: number;
44
+ errorPolicy?: ErrorPolicy;
45
+ checkpointEveryNode?: boolean;
46
+ completionPolicy?: GraphDefinition["completionPolicy"];
47
+ /** v1.4: graph-local registry merged over the engine's. */
48
+ functions?: GraphDefinition["functions"];
49
+ }
50
+
51
+ export function g(spec: GraphSpec): GraphDefinition {
52
+ const nodes: Record<string, NodeDefinition> = {};
53
+ for (const definition of spec.nodes) nodes[definition.id] = definition;
54
+
55
+ return {
56
+ id: spec.id ?? "test-graph",
57
+ version: spec.version ?? "1.0.0",
58
+ entryNodes: spec.entryNodes,
59
+ terminalNodes: spec.terminalNodes,
60
+ nodes,
61
+ defaultNodeTimeoutMs: spec.defaultNodeTimeoutMs,
62
+ errorPolicy: spec.errorPolicy,
63
+ checkpointEveryNode: spec.checkpointEveryNode,
64
+ completionPolicy: spec.completionPolicy,
65
+ functions: spec.functions
66
+ };
67
+ }
68
+
69
+ /** Linear graph: a -> b -> c ... ; last node is the terminal. */
70
+ export function linear(
71
+ ids: string[],
72
+ run?: (id: string, ctx: NodeContext) => unknown,
73
+ extra: Partial<GraphSpec> = {}
74
+ ): GraphDefinition {
75
+ const nodes = ids.map((id, index) =>
76
+ node(
77
+ id,
78
+ ctx => (run ? run(id, ctx) : `${id}-out`),
79
+ index < ids.length - 1 ? { next: [ids[index + 1]] } : {}
80
+ )
81
+ );
82
+
83
+ return g({
84
+ nodes,
85
+ entryNodes: [ids[0]],
86
+ terminalNodes: [ids[ids.length - 1]],
87
+ ...extra
88
+ });
89
+ }
90
+
91
+ /* ------------------------- storages ------------------------- */
92
+
93
+ /** Records snapshots by reference, can be made to fail on the Nth call. */
94
+ export class SpyStorage implements StoragePlugin {
95
+ readonly id = "test.spy-storage";
96
+ readonly version = "9.9.9";
97
+
98
+ snapshots: ExecutionSnapshot[] = [];
99
+ calls = 0;
100
+ failOn: (callIndex: number, snapshot: ExecutionSnapshot) => boolean =
101
+ () => false;
102
+
103
+ async saveAtomic(snapshot: ExecutionSnapshot): Promise<void> {
104
+ this.calls += 1;
105
+ if (this.failOn(this.calls, snapshot)) {
106
+ throw new Error(`disk-full@${this.calls}`);
107
+ }
108
+ this.snapshots.push(snapshot);
109
+ }
110
+
111
+ async load(executionId: string): Promise<ExecutionSnapshot | null> {
112
+ for (let i = this.snapshots.length - 1; i >= 0; i -= 1) {
113
+ if (this.snapshots[i].executionId === executionId) {
114
+ return this.snapshots[i];
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /* ------------------------- plugins ------------------------- */
122
+
123
+ export interface PluginSpec {
124
+ id: string;
125
+ version?: string;
126
+ required?: boolean;
127
+ onInitialize?: () => Promise<void> | void;
128
+ onClose?: () => Promise<void> | void;
129
+ }
130
+
131
+ export function plugin(spec: PluginSpec): EnginePlugin & {
132
+ events: string[];
133
+ } {
134
+ const events: string[] = [];
135
+
136
+ return {
137
+ id: spec.id,
138
+ version: spec.version ?? "1.0.0",
139
+ required: spec.required,
140
+ events,
141
+ async initialize() {
142
+ events.push(`init:${spec.id}`);
143
+ await spec.onInitialize?.();
144
+ },
145
+ async close() {
146
+ events.push(`close:${spec.id}`);
147
+ await spec.onClose?.();
148
+ }
149
+ };
150
+ }
151
+
152
+ /** Plugin with only a close() hook (no initialize). */
153
+ export function closeOnlyPlugin(id: string, events: string[]): EnginePlugin {
154
+ return {
155
+ id,
156
+ version: "1.0.0",
157
+ async close() {
158
+ events.push(`close:${id}`);
159
+ }
160
+ };
161
+ }
162
+
163
+ /* ------------------------- event capture ------------------------- */
164
+
165
+ export interface CapturedEvent {
166
+ event: string;
167
+ payload: unknown;
168
+ }
169
+
170
+ /**
171
+ * Attaches required listeners (awaited by the bus, therefore
172
+ * deterministically ordered) and records payloads.
173
+ */
174
+ export function capture(
175
+ engine: { events: { on: Function } },
176
+ eventNames: string[]
177
+ ): CapturedEvent[] {
178
+ const captured: CapturedEvent[] = [];
179
+
180
+ for (const name of eventNames) {
181
+ engine.events.on(
182
+ name,
183
+ (payload: unknown) => {
184
+ captured.push({ event: name, payload });
185
+ },
186
+ { required: true }
187
+ );
188
+ }
189
+
190
+ return captured;
191
+ }
192
+
193
+ export function errorsOnly(captured: CapturedEvent[]): CapturedEvent[] {
194
+ return captured.filter(entry => entry.event === "node.failed");
195
+ }
196
+
197
+ /** Creates an onLog collector for assertions. */
198
+ export function logCollector() {
199
+ const lines: Array<{ level: string; message: string; details?: unknown }> = [];
200
+ const onLog = (
201
+ level: "debug" | "info" | "warn" | "error",
202
+ message: string,
203
+ details?: unknown
204
+ ) => {
205
+ lines.push({ level, message, details });
206
+ };
207
+ return { lines, onLog };
208
+ }
209
+
210
+ /** A promise that resolves on the next macrotask tick. */
211
+ export const tick = (): Promise<void> =>
212
+ new Promise(resolve => setImmediate(resolve));
213
+
214
+ export function sortedIds(values: string[]): string[] {
215
+ return [...values].sort();
216
+ }