@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.
- package/README.md +439 -0
- package/generators/example/hello/index.ts +132 -0
- package/generators/example/hello/templates/README.md.ejs +20 -0
- package/generators/example/hello/templates/index.ts.ejs +9 -0
- package/generators/example/webapp/index.ts +509 -0
- package/generators/example/webapp/templates/ARCHITECTURE.md.ejs +180 -0
- package/generators/example/webapp/templates/App.tsx.ejs +86 -0
- package/generators/example/webapp/templates/README.md.ejs +154 -0
- package/generators/example/webapp/templates/app.test.ts.ejs +63 -0
- package/generators/example/webapp/templates/app.ts.ejs +132 -0
- package/generators/example/webapp/templates/feature.ts.ejs +264 -0
- package/generators/example/webapp/templates/index.html.ejs +20 -0
- package/generators/example/webapp/templates/main.tsx.ejs +43 -0
- package/generators/example/webapp/templates/styles.css.ejs +135 -0
- package/generators/init/index.ts +124 -0
- package/generators/init/templates/generator.ts.ejs +85 -0
- package/generators/init/templates/template-index.ts.ejs +9 -0
- package/generators/init/templates/template-test.ts.ejs +8 -0
- package/package.json +64 -0
- package/src/__tests__/combinators.test.ts +895 -0
- package/src/__tests__/dry-run.test.ts +927 -0
- package/src/__tests__/effect.test.ts +816 -0
- package/src/__tests__/interpreter.test.ts +673 -0
- package/src/__tests__/primitives.test.ts +970 -0
- package/src/__tests__/task.test.ts +929 -0
- package/src/__tests__/template.test.ts +666 -0
- package/src/cli-format.ts +165 -0
- package/src/cli-types.ts +53 -0
- package/src/cli.tsx +1322 -0
- package/src/combinators.ts +294 -0
- package/src/completion.ts +488 -0
- package/src/components/App.tsx +960 -0
- package/src/components/ExecutionProgress.tsx +205 -0
- package/src/components/FileTreePreview.tsx +97 -0
- package/src/components/PromptSequence.tsx +483 -0
- package/src/components/Spinner.tsx +36 -0
- package/src/components/index.ts +16 -0
- package/src/dry-run.ts +434 -0
- package/src/effect.ts +224 -0
- package/src/index.ts +266 -0
- package/src/interpreter.ts +463 -0
- package/src/primitives.ts +442 -0
- package/src/task.ts +245 -0
- package/src/template.ts +537 -0
- 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
|
+
});
|