@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,927 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { sequence, sequence_, traverse } from "../combinators.js";
|
|
3
|
+
import {
|
|
4
|
+
assertEffects,
|
|
5
|
+
assertFileWrites,
|
|
6
|
+
collectEffects,
|
|
7
|
+
countEffects,
|
|
8
|
+
dryRun,
|
|
9
|
+
dryRunWith,
|
|
10
|
+
expectTask,
|
|
11
|
+
filterEffects,
|
|
12
|
+
getAffectedFiles,
|
|
13
|
+
getFileWrites,
|
|
14
|
+
mockEffect,
|
|
15
|
+
} from "../dry-run.js";
|
|
16
|
+
import {
|
|
17
|
+
copyFile,
|
|
18
|
+
exec,
|
|
19
|
+
exists,
|
|
20
|
+
getContext,
|
|
21
|
+
glob,
|
|
22
|
+
info,
|
|
23
|
+
mkdir,
|
|
24
|
+
promptConfirm,
|
|
25
|
+
promptSelect,
|
|
26
|
+
promptText,
|
|
27
|
+
readFile,
|
|
28
|
+
setContext,
|
|
29
|
+
writeFile,
|
|
30
|
+
} from "../primitives.js";
|
|
31
|
+
import { fail, flatMap, map, pure } from "../task.js";
|
|
32
|
+
import type { Effect, TaskError } from "../types.js";
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Core dryRun Function
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
describe("Dry-Run - Core dryRun Function", () => {
|
|
39
|
+
describe("basic functionality", () => {
|
|
40
|
+
it("collects effects from a task", () => {
|
|
41
|
+
const task = sequence_([
|
|
42
|
+
mkdir("/tmp/test"),
|
|
43
|
+
writeFile("/tmp/test/file.txt", "hello"),
|
|
44
|
+
info("Done"),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const result = dryRun(task);
|
|
48
|
+
|
|
49
|
+
expect(result.effects.length).toBe(3);
|
|
50
|
+
expect(result.effects[0]._tag).toBe("MakeDir");
|
|
51
|
+
expect(result.effects[1]._tag).toBe("WriteFile");
|
|
52
|
+
expect(result.effects[2]._tag).toBe("Log");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns the final value", () => {
|
|
56
|
+
const task = flatMap(pure(10), (x) => pure(x * 2));
|
|
57
|
+
const result = dryRun(task);
|
|
58
|
+
|
|
59
|
+
expect(result.value).toBe(20);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("handles empty tasks (pure values)", () => {
|
|
63
|
+
const task = pure(42);
|
|
64
|
+
const result = dryRun(task);
|
|
65
|
+
|
|
66
|
+
expect(result.value).toBe(42);
|
|
67
|
+
expect(result.effects.length).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("throws TaskExecutionError for failed tasks", () => {
|
|
71
|
+
const error: TaskError = { code: "ERR", message: "test error" };
|
|
72
|
+
const task = fail<number>(error);
|
|
73
|
+
|
|
74
|
+
expect(() => dryRun(task)).toThrow("test error");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("throws on failure in chains", () => {
|
|
78
|
+
const error: TaskError = { code: "ERR", message: "chain error" };
|
|
79
|
+
const task = flatMap(fail<number>(error), (x) => pure(x * 2));
|
|
80
|
+
|
|
81
|
+
expect(() => dryRun(task)).toThrow("chain error");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("effect handling", () => {
|
|
86
|
+
it("collects ReadFile effects", () => {
|
|
87
|
+
const task = readFile("/test.txt");
|
|
88
|
+
const { effects } = dryRun(task);
|
|
89
|
+
|
|
90
|
+
expect(effects.length).toBe(1);
|
|
91
|
+
expect(effects[0]._tag).toBe("ReadFile");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("collects WriteFile effects", () => {
|
|
95
|
+
const task = writeFile("/output.txt", "content");
|
|
96
|
+
const { effects } = dryRun(task);
|
|
97
|
+
|
|
98
|
+
expect(effects.length).toBe(1);
|
|
99
|
+
expect(effects[0]._tag).toBe("WriteFile");
|
|
100
|
+
expect((effects[0] as { content: string }).content).toBe("content");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("collects Exec effects", () => {
|
|
104
|
+
const task = exec("npm", ["install"], "/project");
|
|
105
|
+
const { effects } = dryRun(task);
|
|
106
|
+
|
|
107
|
+
expect(effects.length).toBe(1);
|
|
108
|
+
expect(effects[0]._tag).toBe("Exec");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("collects Prompt effects", () => {
|
|
112
|
+
const task = promptText("name", "Enter name:");
|
|
113
|
+
const { effects } = dryRun(task);
|
|
114
|
+
|
|
115
|
+
expect(effects.length).toBe(1);
|
|
116
|
+
expect(effects[0]._tag).toBe("Prompt");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("collects Log effects", () => {
|
|
120
|
+
const task = info("test message");
|
|
121
|
+
const { effects } = dryRun(task);
|
|
122
|
+
|
|
123
|
+
expect(effects.length).toBe(1);
|
|
124
|
+
expect(effects[0]._tag).toBe("Log");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("collects Context effects", () => {
|
|
128
|
+
const task = sequence_([setContext("key", "value"), getContext("key")]);
|
|
129
|
+
const { effects } = dryRun(task);
|
|
130
|
+
|
|
131
|
+
expect(effects.length).toBe(2);
|
|
132
|
+
expect(effects[0]._tag).toBe("WriteContext");
|
|
133
|
+
expect(effects[1]._tag).toBe("ReadContext");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// =============================================================================
|
|
139
|
+
// mockEffect
|
|
140
|
+
// =============================================================================
|
|
141
|
+
|
|
142
|
+
describe("Dry-Run - mockEffect", () => {
|
|
143
|
+
it("returns mock content for ReadFile", () => {
|
|
144
|
+
const effect: Effect = { _tag: "ReadFile", path: "/test.txt" };
|
|
145
|
+
const result = mockEffect(effect);
|
|
146
|
+
|
|
147
|
+
expect(result).toBe("[mock content of /test.txt]");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns true for Exists", () => {
|
|
151
|
+
const effect: Effect = { _tag: "Exists", path: "/any/path" };
|
|
152
|
+
const result = mockEffect(effect);
|
|
153
|
+
|
|
154
|
+
expect(result).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("returns empty array for Glob", () => {
|
|
158
|
+
const effect: Effect = { _tag: "Glob", pattern: "**/*.ts", cwd: "/src" };
|
|
159
|
+
const result = mockEffect(effect);
|
|
160
|
+
|
|
161
|
+
expect(result).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("returns undefined for WriteFile", () => {
|
|
165
|
+
const effect: Effect = {
|
|
166
|
+
_tag: "WriteFile",
|
|
167
|
+
path: "/out.txt",
|
|
168
|
+
content: "x",
|
|
169
|
+
};
|
|
170
|
+
const result = mockEffect(effect);
|
|
171
|
+
|
|
172
|
+
expect(result).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns undefined for MakeDir", () => {
|
|
176
|
+
const effect: Effect = { _tag: "MakeDir", path: "/dir", recursive: true };
|
|
177
|
+
const result = mockEffect(effect);
|
|
178
|
+
|
|
179
|
+
expect(result).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("returns mock ExecResult for Exec", () => {
|
|
183
|
+
const effect: Effect = { _tag: "Exec", command: "npm", args: ["install"] };
|
|
184
|
+
const result = mockEffect(effect);
|
|
185
|
+
|
|
186
|
+
expect(result).toEqual({ stdout: "", stderr: "", exitCode: 0 });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("returns default for text Prompt", () => {
|
|
190
|
+
const effect: Effect = {
|
|
191
|
+
_tag: "Prompt",
|
|
192
|
+
question: { type: "text", name: "x", message: "?", default: "default" },
|
|
193
|
+
};
|
|
194
|
+
const result = mockEffect(effect);
|
|
195
|
+
|
|
196
|
+
expect(result).toBe("default");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("returns default for confirm Prompt", () => {
|
|
200
|
+
const effect: Effect = {
|
|
201
|
+
_tag: "Prompt",
|
|
202
|
+
question: { type: "confirm", name: "x", message: "?", default: true },
|
|
203
|
+
};
|
|
204
|
+
const result = mockEffect(effect);
|
|
205
|
+
|
|
206
|
+
expect(result).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("returns first choice for select Prompt without default", () => {
|
|
210
|
+
const effect: Effect = {
|
|
211
|
+
_tag: "Prompt",
|
|
212
|
+
question: {
|
|
213
|
+
type: "select",
|
|
214
|
+
name: "x",
|
|
215
|
+
message: "?",
|
|
216
|
+
choices: [
|
|
217
|
+
{ label: "A", value: "a" },
|
|
218
|
+
{ label: "B", value: "b" },
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
const result = mockEffect(effect);
|
|
223
|
+
|
|
224
|
+
expect(result).toBe("a");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("returns undefined for Log", () => {
|
|
228
|
+
const effect: Effect = { _tag: "Log", level: "info", message: "test" };
|
|
229
|
+
const result = mockEffect(effect);
|
|
230
|
+
|
|
231
|
+
expect(result).toBeUndefined();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("returns undefined for WriteContext", () => {
|
|
235
|
+
const effect: Effect = {
|
|
236
|
+
_tag: "WriteContext",
|
|
237
|
+
key: "k",
|
|
238
|
+
value: "v",
|
|
239
|
+
};
|
|
240
|
+
const result = mockEffect(effect);
|
|
241
|
+
|
|
242
|
+
expect(result).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("returns undefined for ReadContext", () => {
|
|
246
|
+
const effect: Effect = { _tag: "ReadContext", key: "k" };
|
|
247
|
+
const result = mockEffect(effect);
|
|
248
|
+
|
|
249
|
+
expect(result).toBeUndefined();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// =============================================================================
|
|
254
|
+
// dryRun mock values in tasks
|
|
255
|
+
// =============================================================================
|
|
256
|
+
|
|
257
|
+
describe("Dry-Run - mock values in tasks", () => {
|
|
258
|
+
it("returns mock content for ReadFile task", () => {
|
|
259
|
+
const task = readFile("/test.txt");
|
|
260
|
+
const { value } = dryRun(task);
|
|
261
|
+
|
|
262
|
+
expect(value).toBe("[mock content of /test.txt]");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("returns false for Exists task when file not created during dry-run", () => {
|
|
266
|
+
const task = exists("/any/path");
|
|
267
|
+
const { value } = dryRun(task);
|
|
268
|
+
|
|
269
|
+
expect(value).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("returns true for Exists task when file was created during dry-run", () => {
|
|
273
|
+
const task = flatMap(writeFile("/any/path", "content"), () =>
|
|
274
|
+
exists("/any/path"),
|
|
275
|
+
);
|
|
276
|
+
const { value } = dryRun(task);
|
|
277
|
+
|
|
278
|
+
expect(value).toBe(true);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("returns empty array for Glob task", () => {
|
|
282
|
+
const task = glob("**/*.ts", "/src");
|
|
283
|
+
const { value } = dryRun(task);
|
|
284
|
+
|
|
285
|
+
expect(value).toEqual([]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("returns mock ExecResult for Exec task", () => {
|
|
289
|
+
const task = exec("npm", ["install"]);
|
|
290
|
+
const { value } = dryRun(task);
|
|
291
|
+
|
|
292
|
+
expect(value).toEqual({ stdout: "", stderr: "", exitCode: 0 });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("returns default for promptText task", () => {
|
|
296
|
+
const task = promptText("name", "Name?", "default");
|
|
297
|
+
const { value } = dryRun(task);
|
|
298
|
+
|
|
299
|
+
expect(value).toBe("default");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("returns default for promptConfirm task", () => {
|
|
303
|
+
const task = promptConfirm("proceed", "Continue?", true);
|
|
304
|
+
const { value } = dryRun(task);
|
|
305
|
+
|
|
306
|
+
expect(value).toBe(true);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("returns default for promptSelect task", () => {
|
|
310
|
+
const choices = [
|
|
311
|
+
{ label: "A", value: "a" },
|
|
312
|
+
{ label: "B", value: "b" },
|
|
313
|
+
];
|
|
314
|
+
const task = promptSelect("choice", "Pick:", choices, "b");
|
|
315
|
+
const { value } = dryRun(task);
|
|
316
|
+
|
|
317
|
+
expect(value).toBe("b");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("returns first choice when no default for promptSelect task", () => {
|
|
321
|
+
const choices = [
|
|
322
|
+
{ label: "First", value: "first" },
|
|
323
|
+
{ label: "Second", value: "second" },
|
|
324
|
+
];
|
|
325
|
+
const task = promptSelect("choice", "Pick:", choices);
|
|
326
|
+
const { value } = dryRun(task);
|
|
327
|
+
|
|
328
|
+
expect(value).toBe("first");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
// =============================================================================
|
|
333
|
+
// dryRunWith - custom mocks
|
|
334
|
+
// =============================================================================
|
|
335
|
+
|
|
336
|
+
describe("Dry-Run - dryRunWith", () => {
|
|
337
|
+
it("uses custom mock for specified effect type", () => {
|
|
338
|
+
const customMocks = new Map<string, (e: Effect) => unknown>();
|
|
339
|
+
customMocks.set("ReadFile", () => "custom content");
|
|
340
|
+
|
|
341
|
+
const task = readFile("/test.txt");
|
|
342
|
+
const { value } = dryRunWith(task, customMocks);
|
|
343
|
+
|
|
344
|
+
expect(value).toBe("custom content");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("falls back to default mock for unspecified types", () => {
|
|
348
|
+
const customMocks = new Map<string, (e: Effect) => unknown>();
|
|
349
|
+
customMocks.set("ReadFile", () => "custom content");
|
|
350
|
+
|
|
351
|
+
const task = sequence([readFile("/test.txt"), exists("/other.txt")]);
|
|
352
|
+
const { value } = dryRunWith(task, customMocks);
|
|
353
|
+
|
|
354
|
+
expect(value[0]).toBe("custom content");
|
|
355
|
+
expect(value[1]).toBe(true); // default mock for Exists
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("receives the effect in custom mock function", () => {
|
|
359
|
+
const customMocks = new Map<string, (e: Effect) => unknown>();
|
|
360
|
+
customMocks.set("ReadFile", (e) => {
|
|
361
|
+
if (e._tag === "ReadFile") {
|
|
362
|
+
return `content of ${e.path}`;
|
|
363
|
+
}
|
|
364
|
+
return "";
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
const task = readFile("/my-file.txt");
|
|
368
|
+
const { value } = dryRunWith(task, customMocks);
|
|
369
|
+
|
|
370
|
+
expect(value).toBe("content of /my-file.txt");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("collects effects same as dryRun", () => {
|
|
374
|
+
const customMocks = new Map<string, (e: Effect) => unknown>();
|
|
375
|
+
|
|
376
|
+
const task = sequence_([
|
|
377
|
+
writeFile("/a.txt", "a"),
|
|
378
|
+
writeFile("/b.txt", "b"),
|
|
379
|
+
]);
|
|
380
|
+
const { effects } = dryRunWith(task, customMocks);
|
|
381
|
+
|
|
382
|
+
expect(effects.length).toBe(2);
|
|
383
|
+
expect(effects[0]._tag).toBe("WriteFile");
|
|
384
|
+
expect(effects[1]._tag).toBe("WriteFile");
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// =============================================================================
|
|
389
|
+
// collectEffects
|
|
390
|
+
// =============================================================================
|
|
391
|
+
|
|
392
|
+
describe("Dry-Run - collectEffects", () => {
|
|
393
|
+
it("collects all effects from a task", () => {
|
|
394
|
+
const task = sequence_([
|
|
395
|
+
writeFile("/a.txt", "a"),
|
|
396
|
+
writeFile("/b.txt", "b"),
|
|
397
|
+
]);
|
|
398
|
+
|
|
399
|
+
const effects = collectEffects(task);
|
|
400
|
+
|
|
401
|
+
expect(effects.length).toBe(2);
|
|
402
|
+
expect(effects.every((e) => e._tag === "WriteFile")).toBe(true);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("returns empty array for pure tasks", () => {
|
|
406
|
+
const effects = collectEffects(pure(42));
|
|
407
|
+
expect(effects.length).toBe(0);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("handles complex nested tasks", () => {
|
|
411
|
+
const task = traverse([1, 2, 3], (n) =>
|
|
412
|
+
sequence_([info(`Processing ${n}`), writeFile(`/${n}.txt`, String(n))]),
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
const effects = collectEffects(task);
|
|
416
|
+
|
|
417
|
+
expect(effects.filter((e) => e._tag === "Log").length).toBe(3);
|
|
418
|
+
expect(effects.filter((e) => e._tag === "WriteFile").length).toBe(3);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("does not throw on failed tasks - stops at failure", () => {
|
|
422
|
+
const error: TaskError = { code: "ERR", message: "error" };
|
|
423
|
+
const task = sequence_([writeFile("/a.txt", "a"), fail(error)]);
|
|
424
|
+
|
|
425
|
+
// collectEffects stops at Fail node, does not throw
|
|
426
|
+
const effects = collectEffects(task);
|
|
427
|
+
expect(effects.length).toBe(1);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// =============================================================================
|
|
432
|
+
// countEffects
|
|
433
|
+
// =============================================================================
|
|
434
|
+
|
|
435
|
+
describe("Dry-Run - countEffects", () => {
|
|
436
|
+
it("counts effects by type", () => {
|
|
437
|
+
const task = sequence_([
|
|
438
|
+
mkdir("/tmp/a"),
|
|
439
|
+
mkdir("/tmp/b"),
|
|
440
|
+
writeFile("/tmp/a/file.txt", "content"),
|
|
441
|
+
info("Log 1"),
|
|
442
|
+
info("Log 2"),
|
|
443
|
+
info("Log 3"),
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
const { effects } = dryRun(task);
|
|
447
|
+
const counts = countEffects(effects);
|
|
448
|
+
|
|
449
|
+
expect(counts.MakeDir).toBe(2);
|
|
450
|
+
expect(counts.WriteFile).toBe(1);
|
|
451
|
+
expect(counts.Log).toBe(3);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("returns undefined for missing types (not 0)", () => {
|
|
455
|
+
const task = writeFile("/test.txt", "content");
|
|
456
|
+
const { effects } = dryRun(task);
|
|
457
|
+
const counts = countEffects(effects);
|
|
458
|
+
|
|
459
|
+
expect(counts.WriteFile).toBe(1);
|
|
460
|
+
expect(counts.ReadFile).toBeUndefined();
|
|
461
|
+
expect(counts.MakeDir).toBeUndefined();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("handles empty effects array", () => {
|
|
465
|
+
const counts = countEffects([]);
|
|
466
|
+
|
|
467
|
+
expect(counts.WriteFile).toBeUndefined();
|
|
468
|
+
expect(counts.ReadFile).toBeUndefined();
|
|
469
|
+
expect(Object.keys(counts).length).toBe(0);
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
// =============================================================================
|
|
474
|
+
// filterEffects
|
|
475
|
+
// =============================================================================
|
|
476
|
+
|
|
477
|
+
describe("Dry-Run - filterEffects", () => {
|
|
478
|
+
it("filters effects by tag", () => {
|
|
479
|
+
const task = sequence_([
|
|
480
|
+
mkdir("/tmp/test"),
|
|
481
|
+
writeFile("/tmp/test/a.txt", "a"),
|
|
482
|
+
writeFile("/tmp/test/b.txt", "b"),
|
|
483
|
+
info("Done"),
|
|
484
|
+
]);
|
|
485
|
+
|
|
486
|
+
const { effects } = dryRun(task);
|
|
487
|
+
const writes = filterEffects(effects, "WriteFile");
|
|
488
|
+
|
|
489
|
+
expect(writes.length).toBe(2);
|
|
490
|
+
expect(writes[0].path).toBe("/tmp/test/a.txt");
|
|
491
|
+
expect(writes[1].path).toBe("/tmp/test/b.txt");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("returns empty array when no matches", () => {
|
|
495
|
+
const task = writeFile("/test.txt", "content");
|
|
496
|
+
const { effects } = dryRun(task);
|
|
497
|
+
const logs = filterEffects(effects, "Log");
|
|
498
|
+
|
|
499
|
+
expect(logs.length).toBe(0);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("works with all effect types", () => {
|
|
503
|
+
const task = sequence_([
|
|
504
|
+
readFile("/input.txt"),
|
|
505
|
+
mkdir("/output"),
|
|
506
|
+
writeFile("/output/file.txt", "content"),
|
|
507
|
+
exec("npm", ["install"]),
|
|
508
|
+
info("Done"),
|
|
509
|
+
]);
|
|
510
|
+
|
|
511
|
+
const { effects } = dryRun(task);
|
|
512
|
+
|
|
513
|
+
expect(filterEffects(effects, "ReadFile").length).toBe(1);
|
|
514
|
+
expect(filterEffects(effects, "MakeDir").length).toBe(1);
|
|
515
|
+
expect(filterEffects(effects, "WriteFile").length).toBe(1);
|
|
516
|
+
expect(filterEffects(effects, "Exec").length).toBe(1);
|
|
517
|
+
expect(filterEffects(effects, "Log").length).toBe(1);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it("returns typed results", () => {
|
|
521
|
+
const task = writeFile("/test.txt", "content");
|
|
522
|
+
const { effects } = dryRun(task);
|
|
523
|
+
const writes = filterEffects(effects, "WriteFile");
|
|
524
|
+
|
|
525
|
+
// Type should allow accessing WriteFile-specific properties
|
|
526
|
+
expect(writes[0].path).toBe("/test.txt");
|
|
527
|
+
expect(writes[0].content).toBe("content");
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// =============================================================================
|
|
532
|
+
// getFileWrites
|
|
533
|
+
// =============================================================================
|
|
534
|
+
|
|
535
|
+
describe("Dry-Run - getFileWrites", () => {
|
|
536
|
+
it("extracts file write paths and contents", () => {
|
|
537
|
+
const task = sequence_([
|
|
538
|
+
writeFile("/a.txt", "content a"),
|
|
539
|
+
writeFile("/b.txt", "content b"),
|
|
540
|
+
]);
|
|
541
|
+
|
|
542
|
+
const { effects } = dryRun(task);
|
|
543
|
+
const writes = getFileWrites(effects);
|
|
544
|
+
|
|
545
|
+
expect(writes).toEqual([
|
|
546
|
+
{ path: "/a.txt", content: "content a" },
|
|
547
|
+
{ path: "/b.txt", content: "content b" },
|
|
548
|
+
]);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("returns empty array when no writes", () => {
|
|
552
|
+
const task = readFile("/test.txt");
|
|
553
|
+
const { effects } = dryRun(task);
|
|
554
|
+
const writes = getFileWrites(effects);
|
|
555
|
+
|
|
556
|
+
expect(writes).toEqual([]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("preserves order", () => {
|
|
560
|
+
const task = sequence_([
|
|
561
|
+
writeFile("/1.txt", "1"),
|
|
562
|
+
writeFile("/2.txt", "2"),
|
|
563
|
+
writeFile("/3.txt", "3"),
|
|
564
|
+
]);
|
|
565
|
+
|
|
566
|
+
const { effects } = dryRun(task);
|
|
567
|
+
const writes = getFileWrites(effects);
|
|
568
|
+
|
|
569
|
+
expect(writes.map((w) => w.path)).toEqual(["/1.txt", "/2.txt", "/3.txt"]);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("only includes WriteFile effects", () => {
|
|
573
|
+
const task = sequence_([
|
|
574
|
+
mkdir("/dir"),
|
|
575
|
+
writeFile("/dir/file.txt", "content"),
|
|
576
|
+
info("done"),
|
|
577
|
+
]);
|
|
578
|
+
|
|
579
|
+
const { effects } = dryRun(task);
|
|
580
|
+
const writes = getFileWrites(effects);
|
|
581
|
+
|
|
582
|
+
expect(writes.length).toBe(1);
|
|
583
|
+
expect(writes[0].path).toBe("/dir/file.txt");
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// =============================================================================
|
|
588
|
+
// getAffectedFiles
|
|
589
|
+
// =============================================================================
|
|
590
|
+
|
|
591
|
+
describe("Dry-Run - getAffectedFiles", () => {
|
|
592
|
+
it("returns unique sorted list of affected files", () => {
|
|
593
|
+
const task = sequence_([
|
|
594
|
+
mkdir("/tmp/dir"),
|
|
595
|
+
writeFile("/tmp/dir/a.txt", "a"),
|
|
596
|
+
writeFile("/tmp/dir/b.txt", "b"),
|
|
597
|
+
]);
|
|
598
|
+
|
|
599
|
+
const { effects } = dryRun(task);
|
|
600
|
+
const files = getAffectedFiles(effects);
|
|
601
|
+
|
|
602
|
+
expect(files).toEqual(["/tmp/dir", "/tmp/dir/a.txt", "/tmp/dir/b.txt"]);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("deduplicates paths", () => {
|
|
606
|
+
const task = sequence_([
|
|
607
|
+
writeFile("/same.txt", "first"),
|
|
608
|
+
readFile("/same.txt"),
|
|
609
|
+
writeFile("/same.txt", "second"),
|
|
610
|
+
]);
|
|
611
|
+
|
|
612
|
+
const { effects } = dryRun(task);
|
|
613
|
+
const files = getAffectedFiles(effects);
|
|
614
|
+
|
|
615
|
+
// Should only appear once
|
|
616
|
+
expect(files.filter((f) => f === "/same.txt").length).toBe(1);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it("includes dest (not source) for CopyFile", () => {
|
|
620
|
+
const task = copyFile("/source.txt", "/dest.txt");
|
|
621
|
+
const { effects } = dryRun(task);
|
|
622
|
+
const files = getAffectedFiles(effects);
|
|
623
|
+
|
|
624
|
+
// Only dest is considered "affected"
|
|
625
|
+
expect(files).toContain("/dest.txt");
|
|
626
|
+
expect(files).not.toContain("/source.txt");
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("returns empty array for non-file effects", () => {
|
|
630
|
+
const task = sequence_([info("message"), exec("ls", [])]);
|
|
631
|
+
const { effects } = dryRun(task);
|
|
632
|
+
const files = getAffectedFiles(effects);
|
|
633
|
+
|
|
634
|
+
expect(files).toEqual([]);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it("sorts paths alphabetically", () => {
|
|
638
|
+
const task = sequence_([
|
|
639
|
+
writeFile("/z.txt", "z"),
|
|
640
|
+
writeFile("/a.txt", "a"),
|
|
641
|
+
writeFile("/m.txt", "m"),
|
|
642
|
+
]);
|
|
643
|
+
|
|
644
|
+
const { effects } = dryRun(task);
|
|
645
|
+
const files = getAffectedFiles(effects);
|
|
646
|
+
|
|
647
|
+
expect(files).toEqual(["/a.txt", "/m.txt", "/z.txt"]);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// =============================================================================
|
|
652
|
+
// assertEffects
|
|
653
|
+
// =============================================================================
|
|
654
|
+
|
|
655
|
+
describe("Dry-Run - assertEffects", () => {
|
|
656
|
+
it("passes when effects match", () => {
|
|
657
|
+
const task = sequence_([writeFile("/a.txt", "a"), info("done")]);
|
|
658
|
+
|
|
659
|
+
expect(() =>
|
|
660
|
+
assertEffects(task, [
|
|
661
|
+
{ _tag: "WriteFile", path: "/a.txt" },
|
|
662
|
+
{ _tag: "Log", message: "done" },
|
|
663
|
+
]),
|
|
664
|
+
).not.toThrow();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it("throws when effect count differs", () => {
|
|
668
|
+
const task = writeFile("/a.txt", "a");
|
|
669
|
+
|
|
670
|
+
expect(() =>
|
|
671
|
+
assertEffects(task, [{ _tag: "WriteFile" }, { _tag: "WriteFile" }]),
|
|
672
|
+
).toThrow("Expected 2 effects, got 1");
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("throws when effect property differs", () => {
|
|
676
|
+
const task = writeFile("/a.txt", "a");
|
|
677
|
+
|
|
678
|
+
expect(() =>
|
|
679
|
+
assertEffects(task, [{ _tag: "WriteFile", path: "/b.txt" }]),
|
|
680
|
+
).toThrow();
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("only checks specified properties", () => {
|
|
684
|
+
const task = writeFile("/a.txt", "content");
|
|
685
|
+
|
|
686
|
+
// Should pass - only checking _tag
|
|
687
|
+
expect(() => assertEffects(task, [{ _tag: "WriteFile" }])).not.toThrow();
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// =============================================================================
|
|
692
|
+
// assertFileWrites
|
|
693
|
+
// =============================================================================
|
|
694
|
+
|
|
695
|
+
describe("Dry-Run - assertFileWrites", () => {
|
|
696
|
+
it("passes when file writes match", () => {
|
|
697
|
+
const task = sequence_([
|
|
698
|
+
mkdir("/dir"),
|
|
699
|
+
writeFile("/a.txt", "a"),
|
|
700
|
+
writeFile("/b.txt", "b"),
|
|
701
|
+
]);
|
|
702
|
+
|
|
703
|
+
// assertFileWrites checks getAffectedFiles which includes mkdir paths
|
|
704
|
+
expect(() =>
|
|
705
|
+
assertFileWrites(task, ["/a.txt", "/b.txt", "/dir"]),
|
|
706
|
+
).not.toThrow();
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("throws when file count differs", () => {
|
|
710
|
+
const task = writeFile("/a.txt", "a");
|
|
711
|
+
|
|
712
|
+
expect(() => assertFileWrites(task, ["/a.txt", "/b.txt"])).toThrow();
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it("throws when file path differs", () => {
|
|
716
|
+
const task = writeFile("/a.txt", "a");
|
|
717
|
+
|
|
718
|
+
expect(() => assertFileWrites(task, ["/b.txt"])).toThrow();
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("sorts both actual and expected for comparison", () => {
|
|
722
|
+
const task = sequence_([
|
|
723
|
+
writeFile("/z.txt", "z"),
|
|
724
|
+
writeFile("/a.txt", "a"),
|
|
725
|
+
]);
|
|
726
|
+
|
|
727
|
+
// Order in expected doesn't matter since both are sorted
|
|
728
|
+
expect(() => assertFileWrites(task, ["/a.txt", "/z.txt"])).not.toThrow();
|
|
729
|
+
});
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
// =============================================================================
|
|
733
|
+
// expectTask
|
|
734
|
+
// =============================================================================
|
|
735
|
+
|
|
736
|
+
describe("Dry-Run - expectTask", () => {
|
|
737
|
+
describe("toHaveValue", () => {
|
|
738
|
+
it("passes when value matches", () => {
|
|
739
|
+
const task = pure(42);
|
|
740
|
+
const matcher = expectTask(task);
|
|
741
|
+
|
|
742
|
+
expect(() => matcher.toHaveValue(42)).not.toThrow();
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("throws when value does not match", () => {
|
|
746
|
+
const task = pure(42);
|
|
747
|
+
const matcher = expectTask(task);
|
|
748
|
+
|
|
749
|
+
expect(() => matcher.toHaveValue(99)).toThrow();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("uses strict equality for primitive values", () => {
|
|
753
|
+
const task = pure("hello");
|
|
754
|
+
const matcher = expectTask(task);
|
|
755
|
+
|
|
756
|
+
expect(() => matcher.toHaveValue("hello")).not.toThrow();
|
|
757
|
+
expect(() => matcher.toHaveValue("world")).toThrow();
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
describe("toHaveEffectCount", () => {
|
|
762
|
+
it("passes when count matches", () => {
|
|
763
|
+
const task = sequence_([
|
|
764
|
+
writeFile("/a.txt", "a"),
|
|
765
|
+
writeFile("/b.txt", "b"),
|
|
766
|
+
]);
|
|
767
|
+
|
|
768
|
+
const matcher = expectTask(task);
|
|
769
|
+
expect(() => matcher.toHaveEffectCount(2)).not.toThrow();
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
it("throws when count does not match", () => {
|
|
773
|
+
const task = sequence_([
|
|
774
|
+
writeFile("/a.txt", "a"),
|
|
775
|
+
writeFile("/b.txt", "b"),
|
|
776
|
+
]);
|
|
777
|
+
|
|
778
|
+
const matcher = expectTask(task);
|
|
779
|
+
expect(() => matcher.toHaveEffectCount(3)).toThrow();
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
it("works with zero effects", () => {
|
|
783
|
+
const task = pure(42);
|
|
784
|
+
const matcher = expectTask(task);
|
|
785
|
+
|
|
786
|
+
expect(() => matcher.toHaveEffectCount(0)).not.toThrow();
|
|
787
|
+
});
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
describe("toWriteFile", () => {
|
|
791
|
+
it("passes when file is written", () => {
|
|
792
|
+
const task = writeFile("/test.txt", "content");
|
|
793
|
+
const matcher = expectTask(task);
|
|
794
|
+
|
|
795
|
+
expect(() => matcher.toWriteFile("/test.txt")).not.toThrow();
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it("throws when file is not written", () => {
|
|
799
|
+
const task = writeFile("/test.txt", "content");
|
|
800
|
+
const matcher = expectTask(task);
|
|
801
|
+
|
|
802
|
+
expect(() => matcher.toWriteFile("/other.txt")).toThrow();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("works with multiple writes", () => {
|
|
806
|
+
const task = sequence_([
|
|
807
|
+
writeFile("/a.txt", "a"),
|
|
808
|
+
writeFile("/b.txt", "b"),
|
|
809
|
+
writeFile("/c.txt", "c"),
|
|
810
|
+
]);
|
|
811
|
+
|
|
812
|
+
const matcher = expectTask(task);
|
|
813
|
+
expect(() => matcher.toWriteFile("/b.txt")).not.toThrow();
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
describe("toNotWriteFile", () => {
|
|
818
|
+
it("passes when file is not written", () => {
|
|
819
|
+
const task = writeFile("/test.txt", "content");
|
|
820
|
+
const matcher = expectTask(task);
|
|
821
|
+
|
|
822
|
+
expect(() => matcher.toNotWriteFile("/other.txt")).not.toThrow();
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
it("throws when file is written", () => {
|
|
826
|
+
const task = writeFile("/test.txt", "content");
|
|
827
|
+
const matcher = expectTask(task);
|
|
828
|
+
|
|
829
|
+
expect(() => matcher.toNotWriteFile("/test.txt")).toThrow();
|
|
830
|
+
});
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
describe("exposed properties", () => {
|
|
834
|
+
it("exposes effects array", () => {
|
|
835
|
+
const task = sequence_([
|
|
836
|
+
writeFile("/a.txt", "a"),
|
|
837
|
+
writeFile("/b.txt", "b"),
|
|
838
|
+
]);
|
|
839
|
+
|
|
840
|
+
const matcher = expectTask(task);
|
|
841
|
+
|
|
842
|
+
expect(matcher.effects.length).toBe(2);
|
|
843
|
+
expect(matcher.effects[0]._tag).toBe("WriteFile");
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it("exposes value", () => {
|
|
847
|
+
const task = pure(42);
|
|
848
|
+
const matcher = expectTask(task);
|
|
849
|
+
|
|
850
|
+
expect(matcher.value).toBe(42);
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("exposes value for effect tasks", () => {
|
|
854
|
+
const task = map(writeFile("/test.txt", "content"), () => "done");
|
|
855
|
+
const matcher = expectTask(task);
|
|
856
|
+
|
|
857
|
+
expect(matcher.value).toBe("done");
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// =============================================================================
|
|
863
|
+
// Integration Tests
|
|
864
|
+
// =============================================================================
|
|
865
|
+
|
|
866
|
+
describe("Dry-Run - Integration", () => {
|
|
867
|
+
it("can test complex workflows", () => {
|
|
868
|
+
const generateFiles = traverse(["a", "b", "c"], (name) =>
|
|
869
|
+
sequence_([
|
|
870
|
+
mkdir(`/output/${name}`),
|
|
871
|
+
writeFile(`/output/${name}/index.ts`, `export const ${name} = true;`),
|
|
872
|
+
info(`Created ${name}`),
|
|
873
|
+
]),
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
const matcher = expectTask(generateFiles);
|
|
877
|
+
|
|
878
|
+
expect(matcher.effects.length).toBe(9); // 3 * (mkdir + writeFile + log)
|
|
879
|
+
matcher.toWriteFile("/output/b/index.ts");
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
it("can verify effect order", () => {
|
|
883
|
+
const task = sequence_([
|
|
884
|
+
info("Step 1"),
|
|
885
|
+
mkdir("/output"),
|
|
886
|
+
info("Step 2"),
|
|
887
|
+
writeFile("/output/file.txt", "content"),
|
|
888
|
+
info("Step 3"),
|
|
889
|
+
]);
|
|
890
|
+
|
|
891
|
+
const { effects } = dryRun(task);
|
|
892
|
+
|
|
893
|
+
expect(effects[0]._tag).toBe("Log");
|
|
894
|
+
expect((effects[0] as { message: string }).message).toBe("Step 1");
|
|
895
|
+
|
|
896
|
+
expect(effects[1]._tag).toBe("MakeDir");
|
|
897
|
+
|
|
898
|
+
expect(effects[2]._tag).toBe("Log");
|
|
899
|
+
expect((effects[2] as { message: string }).message).toBe("Step 2");
|
|
900
|
+
|
|
901
|
+
expect(effects[3]._tag).toBe("WriteFile");
|
|
902
|
+
|
|
903
|
+
expect(effects[4]._tag).toBe("Log");
|
|
904
|
+
expect((effects[4] as { message: string }).message).toBe("Step 3");
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("can use filterEffects for detailed assertions", () => {
|
|
908
|
+
const task = sequence_([
|
|
909
|
+
writeFile("/a.txt", "content a"),
|
|
910
|
+
info("log 1"),
|
|
911
|
+
writeFile("/b.txt", "content b"),
|
|
912
|
+
info("log 2"),
|
|
913
|
+
]);
|
|
914
|
+
|
|
915
|
+
const { effects } = dryRun(task);
|
|
916
|
+
const writes = filterEffects(effects, "WriteFile");
|
|
917
|
+
const logs = filterEffects(effects, "Log");
|
|
918
|
+
|
|
919
|
+
expect(writes.length).toBe(2);
|
|
920
|
+
expect(writes[0].content).toBe("content a");
|
|
921
|
+
expect(writes[1].content).toBe("content b");
|
|
922
|
+
|
|
923
|
+
expect(logs.length).toBe(2);
|
|
924
|
+
expect(logs[0].message).toBe("log 1");
|
|
925
|
+
expect(logs[1].message).toBe("log 2");
|
|
926
|
+
});
|
|
927
|
+
});
|