@canonical/summon 0.1.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.
Files changed (45) hide show
  1. package/README.md +439 -0
  2. package/generators/example/hello/index.ts +132 -0
  3. package/generators/example/hello/templates/README.md.ejs +20 -0
  4. package/generators/example/hello/templates/index.ts.ejs +9 -0
  5. package/generators/example/webapp/index.ts +509 -0
  6. package/generators/example/webapp/templates/ARCHITECTURE.md.ejs +180 -0
  7. package/generators/example/webapp/templates/App.tsx.ejs +86 -0
  8. package/generators/example/webapp/templates/README.md.ejs +154 -0
  9. package/generators/example/webapp/templates/app.test.ts.ejs +63 -0
  10. package/generators/example/webapp/templates/app.ts.ejs +132 -0
  11. package/generators/example/webapp/templates/feature.ts.ejs +264 -0
  12. package/generators/example/webapp/templates/index.html.ejs +20 -0
  13. package/generators/example/webapp/templates/main.tsx.ejs +43 -0
  14. package/generators/example/webapp/templates/styles.css.ejs +135 -0
  15. package/generators/init/index.ts +124 -0
  16. package/generators/init/templates/generator.ts.ejs +85 -0
  17. package/generators/init/templates/template-index.ts.ejs +9 -0
  18. package/generators/init/templates/template-test.ts.ejs +8 -0
  19. package/package.json +64 -0
  20. package/src/__tests__/combinators.test.ts +895 -0
  21. package/src/__tests__/dry-run.test.ts +927 -0
  22. package/src/__tests__/effect.test.ts +816 -0
  23. package/src/__tests__/interpreter.test.ts +673 -0
  24. package/src/__tests__/primitives.test.ts +970 -0
  25. package/src/__tests__/task.test.ts +929 -0
  26. package/src/__tests__/template.test.ts +666 -0
  27. package/src/cli-format.ts +165 -0
  28. package/src/cli-types.ts +53 -0
  29. package/src/cli.tsx +1322 -0
  30. package/src/combinators.ts +294 -0
  31. package/src/completion.ts +488 -0
  32. package/src/components/App.tsx +960 -0
  33. package/src/components/ExecutionProgress.tsx +205 -0
  34. package/src/components/FileTreePreview.tsx +97 -0
  35. package/src/components/PromptSequence.tsx +483 -0
  36. package/src/components/Spinner.tsx +36 -0
  37. package/src/components/index.ts +16 -0
  38. package/src/dry-run.ts +434 -0
  39. package/src/effect.ts +224 -0
  40. package/src/index.ts +266 -0
  41. package/src/interpreter.ts +463 -0
  42. package/src/primitives.ts +442 -0
  43. package/src/task.ts +245 -0
  44. package/src/template.ts +537 -0
  45. package/src/types.ts +453 -0
@@ -0,0 +1,673 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { sequence_ } from "../combinators.js";
3
+ import {
4
+ executeEffect,
5
+ run,
6
+ runTask,
7
+ TaskExecutionError,
8
+ } from "../interpreter.js";
9
+ import { info, succeed, warn } from "../primitives.js";
10
+ import { effect, fail, flatMap, map, pure } from "../task.js";
11
+ import type { Effect, TaskError } from "../types.js";
12
+
13
+ // Note: These tests focus on the interpreter's logic without actually
14
+ // performing I/O. For real I/O testing, integration tests should be used.
15
+
16
+ // =============================================================================
17
+ // TaskExecutionError
18
+ // =============================================================================
19
+
20
+ describe("Interpreter - TaskExecutionError", () => {
21
+ it("extends Error", () => {
22
+ const taskError: TaskError = { code: "TEST_ERR", message: "Test error" };
23
+ const err = new TaskExecutionError(taskError);
24
+
25
+ expect(err).toBeInstanceOf(Error);
26
+ expect(err).toBeInstanceOf(TaskExecutionError);
27
+ });
28
+
29
+ it("has correct name", () => {
30
+ const taskError: TaskError = { code: "TEST_ERR", message: "Test error" };
31
+ const err = new TaskExecutionError(taskError);
32
+
33
+ expect(err.name).toBe("TaskExecutionError");
34
+ });
35
+
36
+ it("preserves error code", () => {
37
+ const taskError: TaskError = { code: "ERR_CODE", message: "Message" };
38
+ const err = new TaskExecutionError(taskError);
39
+
40
+ expect(err.code).toBe("ERR_CODE");
41
+ });
42
+
43
+ it("preserves error message", () => {
44
+ const taskError: TaskError = { code: "ERR", message: "Detailed message" };
45
+ const err = new TaskExecutionError(taskError);
46
+
47
+ expect(err.message).toBe("Detailed message");
48
+ });
49
+
50
+ it("preserves taskError object", () => {
51
+ const taskError: TaskError = {
52
+ code: "ERR",
53
+ message: "Message",
54
+ context: { extra: "data" },
55
+ };
56
+ const err = new TaskExecutionError(taskError);
57
+
58
+ expect(err.taskError).toEqual(taskError);
59
+ expect(err.taskError.context).toEqual({ extra: "data" });
60
+ });
61
+
62
+ it("preserves stack trace from taskError", () => {
63
+ const stack = "Error: test\n at file.ts:1:1\n at main.ts:10:5";
64
+ const taskError: TaskError = { code: "ERR", message: "Message", stack };
65
+ const err = new TaskExecutionError(taskError);
66
+
67
+ expect(err.stack).toBe(stack);
68
+ });
69
+ });
70
+
71
+ // =============================================================================
72
+ // runTask - Pure Tasks
73
+ // =============================================================================
74
+
75
+ describe("Interpreter - runTask with Pure Tasks", () => {
76
+ it("returns value from pure task", async () => {
77
+ const result = await runTask(pure(42));
78
+ expect(result).toBe(42);
79
+ });
80
+
81
+ it("handles string values", async () => {
82
+ const result = await runTask(pure("hello"));
83
+ expect(result).toBe("hello");
84
+ });
85
+
86
+ it("handles object values", async () => {
87
+ const obj = { a: 1, b: "test" };
88
+ const result = await runTask(pure(obj));
89
+ expect(result).toEqual(obj);
90
+ });
91
+
92
+ it("handles array values", async () => {
93
+ const arr = [1, 2, 3];
94
+ const result = await runTask(pure(arr));
95
+ expect(result).toEqual(arr);
96
+ });
97
+
98
+ it("handles null value", async () => {
99
+ const result = await runTask(pure(null));
100
+ expect(result).toBeNull();
101
+ });
102
+
103
+ it("handles undefined value", async () => {
104
+ const result = await runTask(pure(undefined));
105
+ expect(result).toBeUndefined();
106
+ });
107
+
108
+ it("preserves referential equality", async () => {
109
+ const obj = { a: 1 };
110
+ const result = await runTask(pure(obj));
111
+ expect(result).toBe(obj);
112
+ });
113
+ });
114
+
115
+ // =============================================================================
116
+ // runTask - Failed Tasks
117
+ // =============================================================================
118
+
119
+ describe("Interpreter - runTask with Failed Tasks", () => {
120
+ it("throws TaskExecutionError for failed tasks", async () => {
121
+ const error: TaskError = { code: "TEST_ERR", message: "Test error" };
122
+ const task = fail<number>(error);
123
+
124
+ await expect(runTask(task)).rejects.toThrow(TaskExecutionError);
125
+ });
126
+
127
+ it("error has correct code", async () => {
128
+ const error: TaskError = { code: "SPECIFIC_CODE", message: "Message" };
129
+ const task = fail<number>(error);
130
+
131
+ try {
132
+ await runTask(task);
133
+ expect.fail("Should have thrown");
134
+ } catch (e) {
135
+ expect((e as TaskExecutionError).code).toBe("SPECIFIC_CODE");
136
+ }
137
+ });
138
+
139
+ it("error has correct message", async () => {
140
+ const error: TaskError = { code: "ERR", message: "Detailed error message" };
141
+ const task = fail<number>(error);
142
+
143
+ try {
144
+ await runTask(task);
145
+ expect.fail("Should have thrown");
146
+ } catch (e) {
147
+ expect((e as TaskExecutionError).message).toBe("Detailed error message");
148
+ }
149
+ });
150
+
151
+ it("propagates failure through chains", async () => {
152
+ const error: TaskError = { code: "ERR", message: "error" };
153
+ const task = flatMap(fail<number>(error), (x) => pure(x * 2));
154
+
155
+ await expect(runTask(task)).rejects.toThrow(TaskExecutionError);
156
+ });
157
+ });
158
+
159
+ // =============================================================================
160
+ // runTask - Map and FlatMap
161
+ // =============================================================================
162
+
163
+ describe("Interpreter - runTask with Map and FlatMap", () => {
164
+ it("handles map on pure task", async () => {
165
+ const task = map(pure(10), (x) => x * 2);
166
+ const result = await runTask(task);
167
+
168
+ expect(result).toBe(20);
169
+ });
170
+
171
+ it("handles flatMap on pure task", async () => {
172
+ const task = flatMap(pure(5), (x) => pure(x + 3));
173
+ const result = await runTask(task);
174
+
175
+ expect(result).toBe(8);
176
+ });
177
+
178
+ it("handles nested flatMaps", async () => {
179
+ const task = flatMap(pure(1), (a) =>
180
+ flatMap(pure(2), (b) => flatMap(pure(3), (c) => pure(a + b + c))),
181
+ );
182
+ const result = await runTask(task);
183
+
184
+ expect(result).toBe(6);
185
+ });
186
+
187
+ it("handles chain of maps", async () => {
188
+ const task = map(
189
+ map(
190
+ map(pure(2), (x) => x + 1),
191
+ (x) => x * 2,
192
+ ),
193
+ (x) => x + 3,
194
+ );
195
+ const result = await runTask(task);
196
+
197
+ expect(result).toBe(9); // ((2 + 1) * 2) + 3
198
+ });
199
+ });
200
+
201
+ // =============================================================================
202
+ // runTask - Log Effects
203
+ // =============================================================================
204
+
205
+ describe("Interpreter - runTask with Log Effects", () => {
206
+ it("calls onLog handler for log effects", async () => {
207
+ const logs: Array<{ level: string; message: string }> = [];
208
+ const onLog = (
209
+ level: "debug" | "info" | "warn" | "error",
210
+ message: string,
211
+ ) => {
212
+ logs.push({ level, message });
213
+ };
214
+
215
+ const task = sequence_([info("Info message"), warn("Warning message")]);
216
+
217
+ await runTask(task, { onLog });
218
+
219
+ expect(logs).toHaveLength(2);
220
+ expect(logs[0]).toEqual({ level: "info", message: "Info message" });
221
+ expect(logs[1]).toEqual({ level: "warn", message: "Warning message" });
222
+ });
223
+
224
+ it("falls back to console.log when no onLog handler", async () => {
225
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
226
+
227
+ const task = info("Test message");
228
+ await runTask(task);
229
+
230
+ expect(consoleSpy).toHaveBeenCalledWith("[INFO] Test message");
231
+ consoleSpy.mockRestore();
232
+ });
233
+ });
234
+
235
+ // =============================================================================
236
+ // runTask - Context
237
+ // =============================================================================
238
+
239
+ describe("Interpreter - runTask with Context", () => {
240
+ it("uses provided context", async () => {
241
+ const context = new Map<string, unknown>([["key", "value"]]);
242
+
243
+ const task = effect<string>({ _tag: "ReadContext", key: "key" });
244
+ const result = await runTask(task, { context });
245
+
246
+ expect(result).toBe("value");
247
+ });
248
+
249
+ it("writes to context", async () => {
250
+ const context = new Map<string, unknown>();
251
+
252
+ const task = effect<void>({
253
+ _tag: "WriteContext",
254
+ key: "testKey",
255
+ value: "testValue",
256
+ });
257
+ await runTask(task, { context });
258
+
259
+ expect(context.get("testKey")).toBe("testValue");
260
+ });
261
+
262
+ it("context persists across effects", async () => {
263
+ const context = new Map<string, unknown>();
264
+
265
+ const writeTask = effect<void>({
266
+ _tag: "WriteContext",
267
+ key: "counter",
268
+ value: 42,
269
+ });
270
+ const readTask = effect<number>({ _tag: "ReadContext", key: "counter" });
271
+
272
+ const task = flatMap(writeTask, () => readTask);
273
+ const result = await runTask(task, { context });
274
+
275
+ expect(result).toBe(42);
276
+ });
277
+
278
+ it("returns undefined for missing context keys", async () => {
279
+ const context = new Map<string, unknown>();
280
+
281
+ const task = effect<unknown>({ _tag: "ReadContext", key: "missing" });
282
+ const result = await runTask(task, { context });
283
+
284
+ expect(result).toBeUndefined();
285
+ });
286
+ });
287
+
288
+ // =============================================================================
289
+ // runTask - Effect Hooks
290
+ // =============================================================================
291
+
292
+ describe("Interpreter - runTask with Effect Hooks", () => {
293
+ it("calls onEffectStart before effect execution", async () => {
294
+ const effects: Effect[] = [];
295
+ const onEffectStart = (effect: Effect) => {
296
+ effects.push(effect);
297
+ };
298
+
299
+ const task = info("Test message");
300
+ await runTask(task, {
301
+ onEffectStart,
302
+ onLog: () => {}, // Suppress console output
303
+ });
304
+
305
+ expect(effects).toHaveLength(1);
306
+ expect(effects[0]._tag).toBe("Log");
307
+ });
308
+
309
+ it("calls onEffectComplete after effect execution", async () => {
310
+ const completions: Array<{ effect: Effect; duration: number }> = [];
311
+ const onEffectComplete = (effect: Effect, duration: number) => {
312
+ completions.push({ effect, duration });
313
+ };
314
+
315
+ const task = info("Test message");
316
+ await runTask(task, {
317
+ onEffectComplete,
318
+ onLog: () => {}, // Suppress console output
319
+ });
320
+
321
+ expect(completions).toHaveLength(1);
322
+ expect(completions[0].effect._tag).toBe("Log");
323
+ expect(completions[0].duration).toBeGreaterThanOrEqual(0);
324
+ });
325
+
326
+ it("calls hooks for multiple effects in sequence", async () => {
327
+ const startOrder: string[] = [];
328
+ const completeOrder: string[] = [];
329
+
330
+ const onEffectStart = (effect: Effect) => {
331
+ startOrder.push(effect._tag);
332
+ };
333
+ const onEffectComplete = (effect: Effect) => {
334
+ completeOrder.push(effect._tag);
335
+ };
336
+
337
+ const task = sequence_([info("First"), warn("Second")]);
338
+
339
+ await runTask(task, {
340
+ onEffectStart,
341
+ onEffectComplete,
342
+ onLog: () => {},
343
+ });
344
+
345
+ expect(startOrder).toEqual(["Log", "Log"]);
346
+ expect(completeOrder).toEqual(["Log", "Log"]);
347
+ });
348
+ });
349
+
350
+ // =============================================================================
351
+ // runTask - Prompt Effects
352
+ // =============================================================================
353
+
354
+ describe("Interpreter - runTask with Prompt Effects", () => {
355
+ it("calls promptHandler for prompt effects", async () => {
356
+ const promptHandler = vi.fn().mockResolvedValue("user input");
357
+
358
+ const task = effect<string>({
359
+ _tag: "Prompt",
360
+ question: {
361
+ type: "text",
362
+ name: "input",
363
+ message: "Enter something:",
364
+ },
365
+ });
366
+
367
+ const result = await runTask(task, { promptHandler });
368
+
369
+ expect(promptHandler).toHaveBeenCalled();
370
+ expect(result).toBe("user input");
371
+ });
372
+
373
+ it("throws error when no promptHandler provided", async () => {
374
+ const task = effect<string>({
375
+ _tag: "Prompt",
376
+ question: {
377
+ type: "text",
378
+ name: "input",
379
+ message: "Enter something:",
380
+ },
381
+ });
382
+
383
+ await expect(runTask(task)).rejects.toThrow(TaskExecutionError);
384
+ try {
385
+ await runTask(task);
386
+ } catch (e) {
387
+ expect((e as TaskExecutionError).code).toBe("NO_PROMPT_HANDLER");
388
+ }
389
+ });
390
+
391
+ it("passes question to promptHandler", async () => {
392
+ let capturedQuestion: unknown;
393
+ const promptHandler = vi.fn().mockImplementation((q) => {
394
+ capturedQuestion = q;
395
+ return Promise.resolve("answer");
396
+ });
397
+
398
+ const question = {
399
+ type: "select" as const,
400
+ name: "choice",
401
+ message: "Pick one:",
402
+ choices: [
403
+ { label: "A", value: "a" },
404
+ { label: "B", value: "b" },
405
+ ],
406
+ };
407
+
408
+ const task = effect<string>({ _tag: "Prompt", question });
409
+ await runTask(task, { promptHandler });
410
+
411
+ expect(
412
+ (capturedQuestion as { question: typeof question }).question,
413
+ ).toEqual(question);
414
+ });
415
+ });
416
+
417
+ // =============================================================================
418
+ // run - Simple API
419
+ // =============================================================================
420
+
421
+ describe("Interpreter - run (simple API)", () => {
422
+ it("runs pure task without options", async () => {
423
+ const result = await run(pure(42));
424
+ expect(result).toBe(42);
425
+ });
426
+
427
+ it("throws for failed task", async () => {
428
+ const error: TaskError = { code: "ERR", message: "error" };
429
+ await expect(run(fail<number>(error))).rejects.toThrow(TaskExecutionError);
430
+ });
431
+
432
+ it("accepts promptHandler as second argument", async () => {
433
+ const promptHandler = vi.fn().mockResolvedValue("response");
434
+
435
+ const task = effect<string>({
436
+ _tag: "Prompt",
437
+ question: { type: "text", name: "q", message: "?" },
438
+ });
439
+
440
+ const result = await run(task, promptHandler);
441
+
442
+ expect(promptHandler).toHaveBeenCalled();
443
+ expect(result).toBe("response");
444
+ });
445
+ });
446
+
447
+ // =============================================================================
448
+ // executeEffect - ReadContext and WriteContext
449
+ // =============================================================================
450
+
451
+ describe("Interpreter - executeEffect for Context", () => {
452
+ it("reads from context map", async () => {
453
+ const context = new Map<string, unknown>([["myKey", "myValue"]]);
454
+ const effect: Effect = { _tag: "ReadContext", key: "myKey" };
455
+
456
+ const result = await executeEffect(effect, context);
457
+
458
+ expect(result).toBe("myValue");
459
+ });
460
+
461
+ it("writes to context map", async () => {
462
+ const context = new Map<string, unknown>();
463
+ const effect: Effect = { _tag: "WriteContext", key: "newKey", value: 123 };
464
+
465
+ await executeEffect(effect, context);
466
+
467
+ expect(context.get("newKey")).toBe(123);
468
+ });
469
+
470
+ it("overwrites existing context values", async () => {
471
+ const context = new Map<string, unknown>([["key", "old"]]);
472
+ const effect: Effect = { _tag: "WriteContext", key: "key", value: "new" };
473
+
474
+ await executeEffect(effect, context);
475
+
476
+ expect(context.get("key")).toBe("new");
477
+ });
478
+ });
479
+
480
+ // =============================================================================
481
+ // executeEffect - Log
482
+ // =============================================================================
483
+
484
+ describe("Interpreter - executeEffect for Log", () => {
485
+ it("calls onLog handler", async () => {
486
+ const logs: Array<{ level: string; message: string }> = [];
487
+ const onLog = (
488
+ level: "debug" | "info" | "warn" | "error",
489
+ message: string,
490
+ ) => {
491
+ logs.push({ level, message });
492
+ };
493
+
494
+ const effect: Effect = { _tag: "Log", level: "info", message: "Test" };
495
+ await executeEffect(effect, new Map(), undefined, onLog);
496
+
497
+ expect(logs).toEqual([{ level: "info", message: "Test" }]);
498
+ });
499
+
500
+ it("handles all log levels", async () => {
501
+ const logs: string[] = [];
502
+ const onLog = (level: "debug" | "info" | "warn" | "error") => {
503
+ logs.push(level);
504
+ };
505
+
506
+ const levels: Array<"debug" | "info" | "warn" | "error"> = [
507
+ "debug",
508
+ "info",
509
+ "warn",
510
+ "error",
511
+ ];
512
+
513
+ for (const level of levels) {
514
+ const effect: Effect = { _tag: "Log", level, message: "test" };
515
+ await executeEffect(effect, new Map(), undefined, onLog);
516
+ }
517
+
518
+ expect(logs).toEqual(["debug", "info", "warn", "error"]);
519
+ });
520
+ });
521
+
522
+ // =============================================================================
523
+ // executeEffect - Parallel and Race
524
+ // =============================================================================
525
+
526
+ describe("Interpreter - executeEffect for Parallel/Race", () => {
527
+ it("throws for Parallel effect (must be handled by runTask)", async () => {
528
+ const effect: Effect = { _tag: "Parallel", tasks: [] };
529
+
530
+ await expect(executeEffect(effect, new Map())).rejects.toThrow(
531
+ "Parallel effect must be handled by runTask",
532
+ );
533
+ });
534
+
535
+ it("throws for Race effect (must be handled by runTask)", async () => {
536
+ const effect: Effect = { _tag: "Race", tasks: [] };
537
+
538
+ await expect(executeEffect(effect, new Map())).rejects.toThrow(
539
+ "Race effect must be handled by runTask",
540
+ );
541
+ });
542
+ });
543
+
544
+ // =============================================================================
545
+ // runTask - Parallel Effects
546
+ // =============================================================================
547
+
548
+ describe("Interpreter - runTask with Parallel Effects", () => {
549
+ it("runs parallel tasks and returns results array", async () => {
550
+ const task = effect<number[]>({
551
+ _tag: "Parallel",
552
+ tasks: [pure(1), pure(2), pure(3)],
553
+ });
554
+
555
+ const result = await runTask(task);
556
+
557
+ expect(result).toEqual([1, 2, 3]);
558
+ });
559
+
560
+ it("handles empty parallel tasks", async () => {
561
+ const task = effect<unknown[]>({ _tag: "Parallel", tasks: [] });
562
+ const result = await runTask(task);
563
+
564
+ expect(result).toEqual([]);
565
+ });
566
+
567
+ it("calls effect hooks for parallel effects", async () => {
568
+ const effectTags: string[] = [];
569
+ const onEffectStart = (e: Effect) => effectTags.push(e._tag);
570
+
571
+ const task = effect<number[]>({
572
+ _tag: "Parallel",
573
+ tasks: [pure(1), pure(2)],
574
+ });
575
+
576
+ await runTask(task, { onEffectStart });
577
+
578
+ expect(effectTags).toContain("Parallel");
579
+ });
580
+ });
581
+
582
+ // =============================================================================
583
+ // runTask - Race Effects
584
+ // =============================================================================
585
+
586
+ describe("Interpreter - runTask with Race Effects", () => {
587
+ it("runs race tasks and returns first result", async () => {
588
+ const task = effect<number>({
589
+ _tag: "Race",
590
+ tasks: [pure(1), pure(2), pure(3)],
591
+ });
592
+
593
+ const result = await runTask(task);
594
+
595
+ // All tasks are pure, so the first one wins
596
+ expect(result).toBe(1);
597
+ });
598
+
599
+ it("calls effect hooks for race effects", async () => {
600
+ const effectTags: string[] = [];
601
+ const onEffectStart = (e: Effect) => effectTags.push(e._tag);
602
+
603
+ const task = effect<number>({
604
+ _tag: "Race",
605
+ tasks: [pure(1), pure(2)],
606
+ });
607
+
608
+ await runTask(task, { onEffectStart });
609
+
610
+ expect(effectTags).toContain("Race");
611
+ });
612
+ });
613
+
614
+ // =============================================================================
615
+ // Integration Tests
616
+ // =============================================================================
617
+
618
+ describe("Interpreter - Integration", () => {
619
+ it("can run complex task chains", async () => {
620
+ const context = new Map<string, unknown>();
621
+ const logs: string[] = [];
622
+
623
+ const task = flatMap(
624
+ effect<void>({ _tag: "WriteContext", key: "counter", value: 0 }),
625
+ () =>
626
+ flatMap(
627
+ effect<number>({ _tag: "ReadContext", key: "counter" }),
628
+ (count) =>
629
+ flatMap(
630
+ effect<void>({
631
+ _tag: "WriteContext",
632
+ key: "counter",
633
+ value: count + 1,
634
+ }),
635
+ () =>
636
+ flatMap(
637
+ effect<void>({
638
+ _tag: "Log",
639
+ level: "info",
640
+ message: `Count: ${count + 1}`,
641
+ }),
642
+ () => effect<number>({ _tag: "ReadContext", key: "counter" }),
643
+ ),
644
+ ),
645
+ ),
646
+ );
647
+
648
+ const result = await runTask(task, {
649
+ context,
650
+ onLog: (_, msg) => logs.push(msg),
651
+ });
652
+
653
+ expect(result).toBe(1);
654
+ expect(context.get("counter")).toBe(1);
655
+ expect(logs).toEqual(["Count: 1"]);
656
+ });
657
+
658
+ it("can combine effects with succeed", async () => {
659
+ const logs: string[] = [];
660
+ const onLog = (_: string, msg: string) => logs.push(msg);
661
+
662
+ const task = flatMap(info("Starting"), () =>
663
+ flatMap(succeed(42), (value) =>
664
+ flatMap(info(`Value: ${value}`), () => pure(value * 2)),
665
+ ),
666
+ );
667
+
668
+ const result = await runTask(task, { onLog });
669
+
670
+ expect(result).toBe(84);
671
+ expect(logs).toEqual(["Starting", "Value: 42"]);
672
+ });
673
+ });