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
package/tests/harness.ts
ADDED
|
@@ -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
|
+
}
|
package/tests/helpers.ts
ADDED
|
@@ -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
|
+
}
|