@jagreehal/workflow 1.12.0 → 1.13.0
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/README.md +1197 -20
- package/dist/duration.cjs +2 -0
- package/dist/duration.cjs.map +1 -0
- package/dist/duration.d.cts +246 -0
- package/dist/duration.d.ts +246 -0
- package/dist/duration.js +2 -0
- package/dist/duration.js.map +1 -0
- package/dist/index.cjs +5 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/match.cjs +2 -0
- package/dist/match.cjs.map +1 -0
- package/dist/match.d.cts +216 -0
- package/dist/match.d.ts +216 -0
- package/dist/match.js +2 -0
- package/dist/match.js.map +1 -0
- package/dist/schedule.cjs +2 -0
- package/dist/schedule.cjs.map +1 -0
- package/dist/schedule.d.cts +387 -0
- package/dist/schedule.d.ts +387 -0
- package/dist/schedule.js +2 -0
- package/dist/schedule.js.map +1 -0
- package/docs/api.md +30 -0
- package/docs/coming-from-neverthrow.md +103 -10
- package/docs/effect-features-to-port.md +210 -0
- package/docs/match-examples.test.ts +558 -0
- package/docs/match.md +417 -0
- package/docs/policies-examples.test.ts +750 -0
- package/docs/policies.md +508 -0
- package/docs/resource-management-examples.test.ts +729 -0
- package/docs/resource-management.md +509 -0
- package/docs/schedule-examples.test.ts +736 -0
- package/docs/schedule.md +467 -0
- package/docs/tagged-error-examples.test.ts +494 -0
- package/docs/tagged-error.md +730 -0
- package/docs/visualization-examples.test.ts +663 -0
- package/docs/visualization.md +395 -0
- package/docs/visualize-examples.md +1 -1
- package/package.json +17 -2
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test file to verify all code examples in visualization.md actually work
|
|
3
|
+
* This file should compile and run without errors
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
createWorkflow,
|
|
9
|
+
ok,
|
|
10
|
+
err,
|
|
11
|
+
type AsyncResult,
|
|
12
|
+
} from "../src/index";
|
|
13
|
+
import {
|
|
14
|
+
createVisualizer,
|
|
15
|
+
createEventCollector,
|
|
16
|
+
trackIf,
|
|
17
|
+
trackSwitch,
|
|
18
|
+
createTimeTravelController,
|
|
19
|
+
createPerformanceAnalyzer,
|
|
20
|
+
createLiveVisualizer,
|
|
21
|
+
} from "../src/visualize";
|
|
22
|
+
|
|
23
|
+
// ============================================================================
|
|
24
|
+
// Mock Dependencies
|
|
25
|
+
// ============================================================================
|
|
26
|
+
|
|
27
|
+
type User = { id: string; name: string; tier: string; region: string };
|
|
28
|
+
type Order = { id: string; items: string[] };
|
|
29
|
+
type Cart = { items: string[] };
|
|
30
|
+
type Payment = { amount: number };
|
|
31
|
+
|
|
32
|
+
async function fetchUser(id: string): AsyncResult<User, "USER_NOT_FOUND"> {
|
|
33
|
+
if (id === "404") return err("USER_NOT_FOUND");
|
|
34
|
+
return ok({ id, name: "Test User", tier: "premium", region: "US" });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function fetchOrders(_id: string): AsyncResult<Order[], "ORDERS_ERROR"> {
|
|
38
|
+
return ok([{ id: "order-1", items: ["item-1"] }]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function fetchRecommendations(
|
|
42
|
+
_id: string
|
|
43
|
+
): AsyncResult<string[], "REC_ERROR"> {
|
|
44
|
+
return ok(["rec-1", "rec-2"]);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function fetchSettings(
|
|
48
|
+
_id: string
|
|
49
|
+
): AsyncResult<{ theme: string }, "SETTINGS_ERROR"> {
|
|
50
|
+
return ok({ theme: "dark" });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function validateCart(_cart: Cart): AsyncResult<boolean, "INVALID_CART"> {
|
|
54
|
+
return ok(true);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function processPayment(
|
|
58
|
+
_payment: Payment
|
|
59
|
+
): AsyncResult<string, "PAYMENT_FAILED"> {
|
|
60
|
+
return ok("payment-123");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function applyDiscount(
|
|
64
|
+
percent: number
|
|
65
|
+
): AsyncResult<number, "DISCOUNT_ERROR"> {
|
|
66
|
+
return ok(percent);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function calculateTax(
|
|
70
|
+
_region: string
|
|
71
|
+
): AsyncResult<number, "TAX_ERROR"> {
|
|
72
|
+
return ok(0.08);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ============================================================================
|
|
76
|
+
// Basic Usage Examples
|
|
77
|
+
// ============================================================================
|
|
78
|
+
|
|
79
|
+
describe("visualization", () => {
|
|
80
|
+
describe("createVisualizer", () => {
|
|
81
|
+
it("creates visualizer and renders output", async () => {
|
|
82
|
+
const viz = createVisualizer({ workflowName: "checkout" });
|
|
83
|
+
|
|
84
|
+
const deps = {
|
|
85
|
+
validateCart,
|
|
86
|
+
processPayment,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const workflow = createWorkflow(deps, {
|
|
90
|
+
onEvent: viz.handleEvent,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
await workflow(async (step, { validateCart, processPayment }) => {
|
|
94
|
+
await step(validateCart({ items: ["item-1"] }), {
|
|
95
|
+
name: "Validate cart",
|
|
96
|
+
});
|
|
97
|
+
await step(processPayment({ amount: 100 }), {
|
|
98
|
+
name: "Process payment",
|
|
99
|
+
});
|
|
100
|
+
return ok("done");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const output = viz.render();
|
|
104
|
+
expect(output).toContain("checkout");
|
|
105
|
+
expect(output).toContain("Validate cart");
|
|
106
|
+
expect(output).toContain("Process payment");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders as mermaid", async () => {
|
|
110
|
+
const viz = createVisualizer({ workflowName: "test" });
|
|
111
|
+
|
|
112
|
+
const workflow = createWorkflow(
|
|
113
|
+
{ fetchUser },
|
|
114
|
+
{ onEvent: viz.handleEvent }
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
await workflow(async (step, { fetchUser }) => {
|
|
118
|
+
await step(fetchUser("123"), { name: "Fetch user" });
|
|
119
|
+
return ok("done");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const mermaid = viz.renderAs("mermaid");
|
|
123
|
+
expect(mermaid).toContain("flowchart");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("renders as json", async () => {
|
|
127
|
+
const viz = createVisualizer({ workflowName: "test" });
|
|
128
|
+
|
|
129
|
+
const workflow = createWorkflow(
|
|
130
|
+
{ fetchUser },
|
|
131
|
+
{ onEvent: viz.handleEvent }
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
await workflow(async (step, { fetchUser }) => {
|
|
135
|
+
await step(fetchUser("123"), { name: "Fetch user" });
|
|
136
|
+
return ok("done");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const json = viz.renderAs("json");
|
|
140
|
+
const parsed = JSON.parse(json);
|
|
141
|
+
expect(parsed).toHaveProperty("root");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("createEventCollector", () => {
|
|
146
|
+
it("collects events for later visualization", async () => {
|
|
147
|
+
const collector = createEventCollector({ workflowName: "checkout" });
|
|
148
|
+
|
|
149
|
+
const workflow = createWorkflow(
|
|
150
|
+
{ fetchUser, fetchOrders },
|
|
151
|
+
{ onEvent: collector.handleEvent }
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
await workflow(async (step, { fetchUser, fetchOrders }) => {
|
|
155
|
+
await step(fetchUser("123"), { name: "Fetch user" });
|
|
156
|
+
await step(fetchOrders("123"), { name: "Fetch orders" });
|
|
157
|
+
return ok("done");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const events = collector.getEvents();
|
|
161
|
+
expect(events.length).toBeGreaterThan(0);
|
|
162
|
+
|
|
163
|
+
const workflowEvents = collector.getWorkflowEvents();
|
|
164
|
+
expect(workflowEvents.length).toBeGreaterThan(0);
|
|
165
|
+
|
|
166
|
+
const output = collector.visualize();
|
|
167
|
+
expect(output).toContain("Fetch user");
|
|
168
|
+
expect(output).toContain("Fetch orders");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("can clear collected events", async () => {
|
|
172
|
+
const collector = createEventCollector();
|
|
173
|
+
|
|
174
|
+
const workflow = createWorkflow(
|
|
175
|
+
{ fetchUser },
|
|
176
|
+
{ onEvent: collector.handleEvent }
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
await workflow(async (step, { fetchUser }) => {
|
|
180
|
+
await step(fetchUser("123"));
|
|
181
|
+
return ok("done");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(collector.getEvents().length).toBeGreaterThan(0);
|
|
185
|
+
|
|
186
|
+
collector.clear();
|
|
187
|
+
expect(collector.getEvents().length).toBe(0);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("visualizer options", () => {
|
|
192
|
+
it("accepts configuration options", async () => {
|
|
193
|
+
const viz = createVisualizer({
|
|
194
|
+
workflowName: "checkout",
|
|
195
|
+
showTimings: true,
|
|
196
|
+
showKeys: true,
|
|
197
|
+
detectParallel: true,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(viz).toBeDefined();
|
|
201
|
+
expect(typeof viz.handleEvent).toBe("function");
|
|
202
|
+
expect(typeof viz.render).toBe("function");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("decision tracking", () => {
|
|
207
|
+
it("tracks if/else decisions", async () => {
|
|
208
|
+
const collector = createEventCollector();
|
|
209
|
+
|
|
210
|
+
const workflow = createWorkflow(
|
|
211
|
+
{ fetchUser, applyDiscount },
|
|
212
|
+
{ onEvent: collector.handleEvent }
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
await workflow(async (step, { fetchUser, applyDiscount }) => {
|
|
216
|
+
const user = await step(fetchUser("123"), { name: "Fetch user" });
|
|
217
|
+
|
|
218
|
+
const isPremium = trackIf("premium-check", user.tier === "premium", {
|
|
219
|
+
emit: collector.handleDecisionEvent,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (user.tier === "premium") {
|
|
223
|
+
isPremium.takeBranch("premium");
|
|
224
|
+
await step(applyDiscount(20), { name: "Apply premium discount" });
|
|
225
|
+
} else {
|
|
226
|
+
isPremium.takeBranch("standard");
|
|
227
|
+
await step(applyDiscount(5), { name: "Apply standard discount" });
|
|
228
|
+
}
|
|
229
|
+
isPremium.end();
|
|
230
|
+
|
|
231
|
+
return ok("done");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const decisionEvents = collector.getDecisionEvents();
|
|
235
|
+
expect(decisionEvents.length).toBeGreaterThan(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("tracks switch decisions", async () => {
|
|
239
|
+
const collector = createEventCollector();
|
|
240
|
+
|
|
241
|
+
const workflow = createWorkflow(
|
|
242
|
+
{ fetchUser, calculateTax },
|
|
243
|
+
{ onEvent: collector.handleEvent }
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
await workflow(async (step, { fetchUser, calculateTax }) => {
|
|
247
|
+
const user = await step(fetchUser("123"), { name: "Fetch user" });
|
|
248
|
+
|
|
249
|
+
const regionSwitch = trackSwitch("region", user.region, {
|
|
250
|
+
emit: collector.handleDecisionEvent,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
switch (user.region) {
|
|
254
|
+
case "US":
|
|
255
|
+
regionSwitch.takeBranch("US");
|
|
256
|
+
await step(calculateTax("US"), { name: "Calculate US tax" });
|
|
257
|
+
break;
|
|
258
|
+
case "EU":
|
|
259
|
+
regionSwitch.takeBranch("EU");
|
|
260
|
+
await step(calculateTax("EU"), { name: "Calculate EU tax" });
|
|
261
|
+
break;
|
|
262
|
+
default:
|
|
263
|
+
regionSwitch.takeBranch("other");
|
|
264
|
+
}
|
|
265
|
+
regionSwitch.end();
|
|
266
|
+
|
|
267
|
+
return ok("done");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const output = collector.visualize();
|
|
271
|
+
expect(output).toBeDefined();
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("time travel controller", () => {
|
|
276
|
+
it("navigates through workflow execution", async () => {
|
|
277
|
+
// Create time travel controller that receives events directly
|
|
278
|
+
const timeTravel = createTimeTravelController();
|
|
279
|
+
|
|
280
|
+
const workflow = createWorkflow(
|
|
281
|
+
{ fetchUser, fetchOrders },
|
|
282
|
+
{ onEvent: timeTravel.handleEvent }
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
await workflow(async (step, { fetchUser, fetchOrders }) => {
|
|
286
|
+
await step(fetchUser("123"), { name: "Fetch user" });
|
|
287
|
+
await step(fetchOrders("123"), { name: "Fetch orders" });
|
|
288
|
+
return ok("done");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Navigate using the correct API
|
|
292
|
+
const ir0 = timeTravel.seek(0);
|
|
293
|
+
expect(ir0).toBeDefined();
|
|
294
|
+
|
|
295
|
+
const ir1 = timeTravel.stepForward();
|
|
296
|
+
expect(ir1).toBeDefined();
|
|
297
|
+
|
|
298
|
+
const ir2 = timeTravel.stepBackward();
|
|
299
|
+
expect(ir2).toBeDefined();
|
|
300
|
+
|
|
301
|
+
// Get state
|
|
302
|
+
const state = timeTravel.getState();
|
|
303
|
+
expect(state).toBeDefined();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("supports state change subscription", async () => {
|
|
307
|
+
const timeTravel = createTimeTravelController();
|
|
308
|
+
|
|
309
|
+
const states: unknown[] = [];
|
|
310
|
+
const unsubscribe = timeTravel.onStateChange((state) => {
|
|
311
|
+
states.push(state);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
const workflow = createWorkflow(
|
|
315
|
+
{ fetchUser },
|
|
316
|
+
{ onEvent: timeTravel.handleEvent }
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
await workflow(async (step, { fetchUser }) => {
|
|
320
|
+
await step(fetchUser("123"), { name: "Fetch user" });
|
|
321
|
+
return ok("done");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Events trigger state changes during workflow execution
|
|
325
|
+
expect(states.length).toBeGreaterThan(0);
|
|
326
|
+
|
|
327
|
+
unsubscribe();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("performance analyzer", () => {
|
|
332
|
+
it("analyzes workflow execution patterns (exact markdown example)", async () => {
|
|
333
|
+
const analyzer = createPerformanceAnalyzer();
|
|
334
|
+
|
|
335
|
+
// Collect multiple runs
|
|
336
|
+
for (let i = 0; i < 3; i++) {
|
|
337
|
+
const collector = createEventCollector();
|
|
338
|
+
const workflow = createWorkflow(
|
|
339
|
+
{ fetchUser, fetchOrders },
|
|
340
|
+
{ onEvent: collector.handleEvent }
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
const startTime = Date.now();
|
|
344
|
+
await workflow(async (step, { fetchUser, fetchOrders }) => {
|
|
345
|
+
await step(() => fetchUser("123"), { name: "Fetch user" });
|
|
346
|
+
await step(() => fetchOrders("123"), { name: "Fetch orders" });
|
|
347
|
+
return ok("done");
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
analyzer.addRun({
|
|
351
|
+
id: `run-${i}`,
|
|
352
|
+
startTime,
|
|
353
|
+
events: collector.getEvents(),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Analyze
|
|
358
|
+
const slowest = analyzer.getSlowestNodes(5);
|
|
359
|
+
const errorProne = analyzer.getErrorProneNodes(5);
|
|
360
|
+
const retryProne = analyzer.getRetryProneNodes(5);
|
|
361
|
+
|
|
362
|
+
expect(Array.isArray(slowest)).toBe(true);
|
|
363
|
+
expect(Array.isArray(errorProne)).toBe(true);
|
|
364
|
+
expect(Array.isArray(retryProne)).toBe(true);
|
|
365
|
+
|
|
366
|
+
// Get heatmap data for visualization
|
|
367
|
+
const collector = createEventCollector();
|
|
368
|
+
const workflow = createWorkflow(
|
|
369
|
+
{ fetchUser },
|
|
370
|
+
{ onEvent: collector.handleEvent }
|
|
371
|
+
);
|
|
372
|
+
await workflow(async (step, { fetchUser }) => {
|
|
373
|
+
await step(() => fetchUser("123"), { name: "Fetch user" });
|
|
374
|
+
return ok("done");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
const viz = createVisualizer();
|
|
378
|
+
// Build IR from events for heatmap
|
|
379
|
+
collector.getEvents().forEach((e) => viz.handleEvent(e));
|
|
380
|
+
const ir = viz.getIR();
|
|
381
|
+
|
|
382
|
+
const heatmap = analyzer.getHeatmap(ir, "duration");
|
|
383
|
+
expect(heatmap).toHaveProperty("heat");
|
|
384
|
+
expect(heatmap).toHaveProperty("metric", "duration");
|
|
385
|
+
expect(heatmap).toHaveProperty("stats");
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe("parallel operations", () => {
|
|
390
|
+
it("visualizes parallel execution (using named object form)", async () => {
|
|
391
|
+
const viz = createVisualizer({ workflowName: "checkout" });
|
|
392
|
+
|
|
393
|
+
const deps = {
|
|
394
|
+
fetchUser,
|
|
395
|
+
fetchOrders,
|
|
396
|
+
fetchSettings,
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const workflow = createWorkflow(deps, { onEvent: viz.handleEvent });
|
|
400
|
+
|
|
401
|
+
await workflow(
|
|
402
|
+
async (step, { fetchUser, fetchOrders, fetchSettings }) => {
|
|
403
|
+
// Use named object form - matches actual API
|
|
404
|
+
const { user, orders, settings } = await step.parallel(
|
|
405
|
+
{
|
|
406
|
+
user: () => fetchUser("123"),
|
|
407
|
+
orders: () => fetchOrders("123"),
|
|
408
|
+
settings: () => fetchSettings("123"),
|
|
409
|
+
},
|
|
410
|
+
{ name: "Fetch all data" }
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
return ok({ user, orders, settings });
|
|
414
|
+
}
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const output = viz.render();
|
|
418
|
+
expect(output).toContain("Fetch all data");
|
|
419
|
+
// Individual step names may not appear in parallel visualization
|
|
420
|
+
// but the parallel group should be visible
|
|
421
|
+
expect(output).toBeDefined();
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe("live visualization", () => {
|
|
426
|
+
it("updates terminal in real-time", async () => {
|
|
427
|
+
const live = createLiveVisualizer({
|
|
428
|
+
workflowName: "checkout",
|
|
429
|
+
clearOnUpdate: true, // Clear terminal between updates
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const workflow = createWorkflow(
|
|
433
|
+
{ fetchUser },
|
|
434
|
+
{ onEvent: live.handleEvent }
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
live.start(); // Begin live updates
|
|
438
|
+
|
|
439
|
+
await workflow(async (step, { fetchUser }) => {
|
|
440
|
+
await step(() => fetchUser("123"), { name: "Processing..." });
|
|
441
|
+
return ok("done");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
live.stop(); // Stop updates, show final state
|
|
445
|
+
|
|
446
|
+
const output = live.render();
|
|
447
|
+
expect(output).toContain("checkout");
|
|
448
|
+
expect(output).toContain("Processing...");
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
describe("visualizer reset", () => {
|
|
453
|
+
it("can reset for new workflow", async () => {
|
|
454
|
+
const viz = createVisualizer({ workflowName: "test" });
|
|
455
|
+
|
|
456
|
+
const workflow = createWorkflow(
|
|
457
|
+
{ fetchUser },
|
|
458
|
+
{ onEvent: viz.handleEvent }
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
await workflow(async (step, { fetchUser }) => {
|
|
462
|
+
await step(fetchUser("123"));
|
|
463
|
+
return ok("done");
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const output1 = viz.render();
|
|
467
|
+
expect(output1).toBeDefined();
|
|
468
|
+
|
|
469
|
+
viz.reset();
|
|
470
|
+
|
|
471
|
+
const ir = viz.getIR();
|
|
472
|
+
expect(ir.root.children.length).toBe(0);
|
|
473
|
+
});
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
describe("update subscription", () => {
|
|
477
|
+
it("notifies on IR updates", async () => {
|
|
478
|
+
const viz = createVisualizer({ workflowName: "test" });
|
|
479
|
+
const updates: unknown[] = [];
|
|
480
|
+
|
|
481
|
+
const unsubscribe = viz.onUpdate((ir) => {
|
|
482
|
+
updates.push(ir);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const workflow = createWorkflow(
|
|
486
|
+
{ fetchUser },
|
|
487
|
+
{ onEvent: viz.handleEvent }
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
await workflow(async (step, { fetchUser }) => {
|
|
491
|
+
await step(fetchUser("123"), { name: "Fetch user" });
|
|
492
|
+
return ok("done");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
expect(updates.length).toBeGreaterThan(0);
|
|
496
|
+
|
|
497
|
+
unsubscribe();
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("matches exact markdown time-travel example", async () => {
|
|
501
|
+
// Create controller that receives events directly
|
|
502
|
+
const timeTravel = createTimeTravelController();
|
|
503
|
+
|
|
504
|
+
const workflow = createWorkflow(
|
|
505
|
+
{ fetchUser, fetchOrders },
|
|
506
|
+
{ onEvent: timeTravel.handleEvent }
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
await workflow(async (step, { fetchUser, fetchOrders }) => {
|
|
510
|
+
await step(() => fetchUser("123"), { name: "Fetch user" });
|
|
511
|
+
await step(() => fetchOrders("123"), { name: "Fetch orders" });
|
|
512
|
+
return ok("done");
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// Navigate
|
|
516
|
+
timeTravel.seek(0); // Jump to start
|
|
517
|
+
timeTravel.stepForward(); // Next event
|
|
518
|
+
timeTravel.stepBackward(); // Previous event
|
|
519
|
+
timeTravel.seek(5); // Jump to event 5
|
|
520
|
+
|
|
521
|
+
// Get current IR
|
|
522
|
+
const ir = timeTravel.getCurrentIR();
|
|
523
|
+
expect(ir).toBeDefined();
|
|
524
|
+
|
|
525
|
+
// Get state (includes position info)
|
|
526
|
+
const state = timeTravel.getState();
|
|
527
|
+
expect(state).toHaveProperty("currentIndex");
|
|
528
|
+
expect(state).toHaveProperty("snapshots");
|
|
529
|
+
|
|
530
|
+
// Subscribe to changes
|
|
531
|
+
const states: unknown[] = [];
|
|
532
|
+
const unsubscribe = timeTravel.onStateChange((s) => {
|
|
533
|
+
states.push(s);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// State changes happen during workflow execution, not during navigation
|
|
537
|
+
// The subscription was set up after workflow completed, so states may be empty
|
|
538
|
+
// This is expected behavior - state changes fire during event processing
|
|
539
|
+
unsubscribe();
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
describe("Best Practices", () => {
|
|
544
|
+
it("DO: Name your steps for readable output", async () => {
|
|
545
|
+
const collector = createEventCollector();
|
|
546
|
+
const workflow = createWorkflow(
|
|
547
|
+
{ fetchUser, validateCart },
|
|
548
|
+
{ onEvent: collector.handleEvent }
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
await workflow(async (step, { fetchUser, validateCart }) => {
|
|
552
|
+
// ✓ Clear names
|
|
553
|
+
await step(() => fetchUser("123"), {
|
|
554
|
+
name: "Fetch user profile",
|
|
555
|
+
});
|
|
556
|
+
await step(() => validateCart({ items: [] }), {
|
|
557
|
+
name: "Validate cart items",
|
|
558
|
+
});
|
|
559
|
+
return ok("done");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
const events = collector.getWorkflowEvents();
|
|
563
|
+
// Check that events exist and have step information
|
|
564
|
+
expect(events.length).toBeGreaterThan(0);
|
|
565
|
+
// Events may have name in different properties depending on event type
|
|
566
|
+
const hasUserStep = events.some((e) => {
|
|
567
|
+
const event = e as { name?: string; stepName?: string };
|
|
568
|
+
return (
|
|
569
|
+
event.name === "Fetch user profile" ||
|
|
570
|
+
event.stepName === "Fetch user profile"
|
|
571
|
+
);
|
|
572
|
+
});
|
|
573
|
+
const hasCartStep = events.some((e) => {
|
|
574
|
+
const event = e as { name?: string; stepName?: string };
|
|
575
|
+
return (
|
|
576
|
+
event.name === "Validate cart items" ||
|
|
577
|
+
event.stepName === "Validate cart items"
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
expect(hasUserStep || hasCartStep).toBe(true);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("DO: Use the event collector for tests", async () => {
|
|
584
|
+
const collector = createEventCollector();
|
|
585
|
+
const workflow = createWorkflow(
|
|
586
|
+
{ fetchUser, fetchOrders, fetchSettings },
|
|
587
|
+
{ onEvent: collector.handleEvent }
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
await workflow(
|
|
591
|
+
async (step, { fetchUser, fetchOrders, fetchSettings }) => {
|
|
592
|
+
await step(() => fetchUser("123"), { name: "Fetch user" });
|
|
593
|
+
await step(() => fetchOrders("123"), { name: "Validate input" });
|
|
594
|
+
await step(() => fetchSettings("123"), { name: "Process data" });
|
|
595
|
+
return ok("done");
|
|
596
|
+
}
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
const events = collector.getWorkflowEvents();
|
|
600
|
+
// Extract step names from events (may be in different properties)
|
|
601
|
+
const stepNames = events
|
|
602
|
+
.map((e) => {
|
|
603
|
+
const event = e as { name?: string; stepName?: string };
|
|
604
|
+
return event.name || event.stepName;
|
|
605
|
+
})
|
|
606
|
+
.filter((name): name is string => typeof name === "string");
|
|
607
|
+
|
|
608
|
+
// Verify we have the expected steps (order may vary)
|
|
609
|
+
expect(stepNames.length).toBeGreaterThanOrEqual(3);
|
|
610
|
+
expect(stepNames).toContain("Fetch user");
|
|
611
|
+
expect(stepNames).toContain("Validate input");
|
|
612
|
+
expect(stepNames).toContain("Process data");
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("DO: Track decisions that affect flow", async () => {
|
|
616
|
+
const collector = createEventCollector();
|
|
617
|
+
const workflow = createWorkflow(
|
|
618
|
+
{ fetchUser, applyDiscount },
|
|
619
|
+
{ onEvent: collector.handleEvent }
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
await workflow(async (step, { fetchUser, applyDiscount }) => {
|
|
623
|
+
const user = await step(() => fetchUser("123"), { name: "Fetch user" });
|
|
624
|
+
|
|
625
|
+
// Decisions show WHY a path was taken
|
|
626
|
+
const decision = trackIf(
|
|
627
|
+
"can-refund",
|
|
628
|
+
user.tier === "premium",
|
|
629
|
+
{
|
|
630
|
+
emit: collector.handleDecisionEvent,
|
|
631
|
+
}
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
if (user.tier === "premium") {
|
|
635
|
+
decision.takeBranch("premium");
|
|
636
|
+
await step(() => applyDiscount(20), { name: "Apply premium" });
|
|
637
|
+
} else {
|
|
638
|
+
decision.takeBranch("standard");
|
|
639
|
+
await step(() => applyDiscount(5), { name: "Apply standard" });
|
|
640
|
+
}
|
|
641
|
+
decision.end();
|
|
642
|
+
|
|
643
|
+
return ok("done");
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const decisionEvents = collector.getDecisionEvents();
|
|
647
|
+
expect(decisionEvents.length).toBeGreaterThan(0);
|
|
648
|
+
// Visualization shows: ◇ can-refund [true] → Refund branch taken
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// ============================================================================
|
|
654
|
+
// Export to avoid unused variable warnings
|
|
655
|
+
// ============================================================================
|
|
656
|
+
|
|
657
|
+
export {
|
|
658
|
+
fetchUser,
|
|
659
|
+
fetchOrders,
|
|
660
|
+
fetchRecommendations,
|
|
661
|
+
validateCart,
|
|
662
|
+
processPayment,
|
|
663
|
+
};
|