@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,816 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
copyDirectoryEffect,
|
|
4
|
+
copyFileEffect,
|
|
5
|
+
deleteDirectoryEffect,
|
|
6
|
+
deleteFileEffect,
|
|
7
|
+
describeEffect,
|
|
8
|
+
execEffect,
|
|
9
|
+
existsEffect,
|
|
10
|
+
getAffectedPaths,
|
|
11
|
+
globEffect,
|
|
12
|
+
isWriteEffect,
|
|
13
|
+
logEffect,
|
|
14
|
+
makeDirEffect,
|
|
15
|
+
parallelEffect,
|
|
16
|
+
promptEffect,
|
|
17
|
+
raceEffect,
|
|
18
|
+
readContextEffect,
|
|
19
|
+
readFileEffect,
|
|
20
|
+
writeContextEffect,
|
|
21
|
+
writeFileEffect,
|
|
22
|
+
} from "../effect.js";
|
|
23
|
+
import { pure } from "../task.js";
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// File System Effect Constructors
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
describe("Effect Constructors - File System", () => {
|
|
30
|
+
describe("readFileEffect", () => {
|
|
31
|
+
it("creates a ReadFile effect with the given path", () => {
|
|
32
|
+
const effect = readFileEffect("/path/to/file.txt");
|
|
33
|
+
|
|
34
|
+
expect(effect._tag).toBe("ReadFile");
|
|
35
|
+
expect((effect as { path: string }).path).toBe("/path/to/file.txt");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("handles absolute paths", () => {
|
|
39
|
+
const effect = readFileEffect("/absolute/path/file.ts");
|
|
40
|
+
expect((effect as { path: string }).path).toBe("/absolute/path/file.ts");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("handles relative paths", () => {
|
|
44
|
+
const effect = readFileEffect("./relative/path/file.ts");
|
|
45
|
+
expect((effect as { path: string }).path).toBe("./relative/path/file.ts");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("handles paths with special characters", () => {
|
|
49
|
+
const effect = readFileEffect("/path/with spaces/file (1).txt");
|
|
50
|
+
expect((effect as { path: string }).path).toBe(
|
|
51
|
+
"/path/with spaces/file (1).txt",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("handles paths with unicode characters", () => {
|
|
56
|
+
const effect = readFileEffect("/путь/到/ファイル.txt");
|
|
57
|
+
expect((effect as { path: string }).path).toBe("/путь/到/ファイル.txt");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("writeFileEffect", () => {
|
|
62
|
+
it("creates a WriteFile effect with path and content", () => {
|
|
63
|
+
const effect = writeFileEffect("/output/file.txt", "Hello, World!");
|
|
64
|
+
|
|
65
|
+
expect(effect._tag).toBe("WriteFile");
|
|
66
|
+
expect((effect as { path: string }).path).toBe("/output/file.txt");
|
|
67
|
+
expect((effect as { content: string }).content).toBe("Hello, World!");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("handles empty content", () => {
|
|
71
|
+
const effect = writeFileEffect("/empty.txt", "");
|
|
72
|
+
expect((effect as { content: string }).content).toBe("");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("handles multiline content", () => {
|
|
76
|
+
const content = "line1\nline2\nline3";
|
|
77
|
+
const effect = writeFileEffect("/multiline.txt", content);
|
|
78
|
+
expect((effect as { content: string }).content).toBe(content);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("handles large content", () => {
|
|
82
|
+
const content = "x".repeat(100000);
|
|
83
|
+
const effect = writeFileEffect("/large.txt", content);
|
|
84
|
+
expect((effect as { content: string }).content.length).toBe(100000);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("handles content with special characters", () => {
|
|
88
|
+
const content = 'Special: @#$%^&*()[]{}|\\;"<>\n\t\r';
|
|
89
|
+
const effect = writeFileEffect("/special.txt", content);
|
|
90
|
+
expect((effect as { content: string }).content).toBe(content);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("handles JSON content", () => {
|
|
94
|
+
const jsonContent = JSON.stringify({
|
|
95
|
+
key: "value",
|
|
96
|
+
nested: { arr: [1, 2, 3] },
|
|
97
|
+
});
|
|
98
|
+
const effect = writeFileEffect("/data.json", jsonContent);
|
|
99
|
+
expect((effect as { content: string }).content).toBe(jsonContent);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("copyFileEffect", () => {
|
|
104
|
+
it("creates a CopyFile effect with source and dest", () => {
|
|
105
|
+
const effect = copyFileEffect("/source/file.txt", "/dest/file.txt");
|
|
106
|
+
|
|
107
|
+
expect(effect._tag).toBe("CopyFile");
|
|
108
|
+
expect((effect as { source: string }).source).toBe("/source/file.txt");
|
|
109
|
+
expect((effect as { dest: string }).dest).toBe("/dest/file.txt");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("handles same directory copy", () => {
|
|
113
|
+
const effect = copyFileEffect("/dir/original.txt", "/dir/copy.txt");
|
|
114
|
+
expect((effect as { source: string }).source).toBe("/dir/original.txt");
|
|
115
|
+
expect((effect as { dest: string }).dest).toBe("/dir/copy.txt");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("handles cross-directory copy", () => {
|
|
119
|
+
const effect = copyFileEffect(
|
|
120
|
+
"/source/dir/file.txt",
|
|
121
|
+
"/different/dest/file.txt",
|
|
122
|
+
);
|
|
123
|
+
expect((effect as { source: string }).source).toBe(
|
|
124
|
+
"/source/dir/file.txt",
|
|
125
|
+
);
|
|
126
|
+
expect((effect as { dest: string }).dest).toBe(
|
|
127
|
+
"/different/dest/file.txt",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("copyDirectoryEffect", () => {
|
|
133
|
+
it("creates a CopyDirectory effect with source and dest", () => {
|
|
134
|
+
const effect = copyDirectoryEffect("/source/dir", "/dest/dir");
|
|
135
|
+
|
|
136
|
+
expect(effect._tag).toBe("CopyDirectory");
|
|
137
|
+
expect((effect as { source: string }).source).toBe("/source/dir");
|
|
138
|
+
expect((effect as { dest: string }).dest).toBe("/dest/dir");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("deleteFileEffect", () => {
|
|
143
|
+
it("creates a DeleteFile effect with the given path", () => {
|
|
144
|
+
const effect = deleteFileEffect("/path/to/delete.txt");
|
|
145
|
+
|
|
146
|
+
expect(effect._tag).toBe("DeleteFile");
|
|
147
|
+
expect((effect as { path: string }).path).toBe("/path/to/delete.txt");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("deleteDirectoryEffect", () => {
|
|
152
|
+
it("creates a DeleteDirectory effect with the given path", () => {
|
|
153
|
+
const effect = deleteDirectoryEffect("/path/to/delete/dir");
|
|
154
|
+
|
|
155
|
+
expect(effect._tag).toBe("DeleteDirectory");
|
|
156
|
+
expect((effect as { path: string }).path).toBe("/path/to/delete/dir");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("makeDirEffect", () => {
|
|
161
|
+
it("creates a MakeDir effect with default recursive true", () => {
|
|
162
|
+
const effect = makeDirEffect("/new/directory");
|
|
163
|
+
|
|
164
|
+
expect(effect._tag).toBe("MakeDir");
|
|
165
|
+
expect((effect as { path: string }).path).toBe("/new/directory");
|
|
166
|
+
expect((effect as { recursive: boolean }).recursive).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("creates a MakeDir effect with explicit recursive true", () => {
|
|
170
|
+
const effect = makeDirEffect("/new/directory", true);
|
|
171
|
+
expect((effect as { recursive: boolean }).recursive).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("creates a MakeDir effect with recursive false", () => {
|
|
175
|
+
const effect = makeDirEffect("/new/directory", false);
|
|
176
|
+
expect((effect as { recursive: boolean }).recursive).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("existsEffect", () => {
|
|
181
|
+
it("creates an Exists effect with the given path", () => {
|
|
182
|
+
const effect = existsEffect("/path/to/check");
|
|
183
|
+
|
|
184
|
+
expect(effect._tag).toBe("Exists");
|
|
185
|
+
expect((effect as { path: string }).path).toBe("/path/to/check");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("globEffect", () => {
|
|
190
|
+
it("creates a Glob effect with pattern and cwd", () => {
|
|
191
|
+
const effect = globEffect("**/*.ts", "/project/src");
|
|
192
|
+
|
|
193
|
+
expect(effect._tag).toBe("Glob");
|
|
194
|
+
expect((effect as { pattern: string }).pattern).toBe("**/*.ts");
|
|
195
|
+
expect((effect as { cwd: string }).cwd).toBe("/project/src");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("handles complex glob patterns", () => {
|
|
199
|
+
const effect = globEffect("**/*.{ts,tsx,js,jsx}", "/src");
|
|
200
|
+
expect((effect as { pattern: string }).pattern).toBe(
|
|
201
|
+
"**/*.{ts,tsx,js,jsx}",
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("handles negation patterns", () => {
|
|
206
|
+
const effect = globEffect("!**/node_modules/**", "/project");
|
|
207
|
+
expect((effect as { pattern: string }).pattern).toBe(
|
|
208
|
+
"!**/node_modules/**",
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// =============================================================================
|
|
215
|
+
// Process Effect Constructors
|
|
216
|
+
// =============================================================================
|
|
217
|
+
|
|
218
|
+
describe("Effect Constructors - Process", () => {
|
|
219
|
+
describe("execEffect", () => {
|
|
220
|
+
it("creates an Exec effect with command and args", () => {
|
|
221
|
+
const effect = execEffect("npm", ["install"]);
|
|
222
|
+
|
|
223
|
+
expect(effect._tag).toBe("Exec");
|
|
224
|
+
expect((effect as { command: string }).command).toBe("npm");
|
|
225
|
+
expect((effect as { args: string[] }).args).toEqual(["install"]);
|
|
226
|
+
expect((effect as { cwd?: string }).cwd).toBeUndefined();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("creates an Exec effect with cwd", () => {
|
|
230
|
+
const effect = execEffect("npm", ["install"], "/project");
|
|
231
|
+
|
|
232
|
+
expect((effect as { command: string }).command).toBe("npm");
|
|
233
|
+
expect((effect as { args: string[] }).args).toEqual(["install"]);
|
|
234
|
+
expect((effect as { cwd?: string }).cwd).toBe("/project");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("handles empty args", () => {
|
|
238
|
+
const effect = execEffect("ls", []);
|
|
239
|
+
expect((effect as { args: string[] }).args).toEqual([]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("handles multiple args", () => {
|
|
243
|
+
const effect = execEffect("git", [
|
|
244
|
+
"commit",
|
|
245
|
+
"-m",
|
|
246
|
+
"Initial commit",
|
|
247
|
+
"--no-verify",
|
|
248
|
+
]);
|
|
249
|
+
expect((effect as { args: string[] }).args).toEqual([
|
|
250
|
+
"commit",
|
|
251
|
+
"-m",
|
|
252
|
+
"Initial commit",
|
|
253
|
+
"--no-verify",
|
|
254
|
+
]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("handles args with special characters", () => {
|
|
258
|
+
const effect = execEffect("echo", ['Hello "World"', "$PATH"]);
|
|
259
|
+
expect((effect as { args: string[] }).args).toEqual([
|
|
260
|
+
'Hello "World"',
|
|
261
|
+
"$PATH",
|
|
262
|
+
]);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// =============================================================================
|
|
268
|
+
// Prompt Effect Constructors
|
|
269
|
+
// =============================================================================
|
|
270
|
+
|
|
271
|
+
describe("Effect Constructors - Prompt", () => {
|
|
272
|
+
describe("promptEffect", () => {
|
|
273
|
+
it("creates a Prompt effect for text input", () => {
|
|
274
|
+
const question = {
|
|
275
|
+
type: "text" as const,
|
|
276
|
+
name: "username",
|
|
277
|
+
message: "Enter your name:",
|
|
278
|
+
default: "anonymous",
|
|
279
|
+
};
|
|
280
|
+
const effect = promptEffect(question);
|
|
281
|
+
|
|
282
|
+
expect(effect._tag).toBe("Prompt");
|
|
283
|
+
expect((effect as { question: typeof question }).question).toEqual(
|
|
284
|
+
question,
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("creates a Prompt effect for confirm", () => {
|
|
289
|
+
const question = {
|
|
290
|
+
type: "confirm" as const,
|
|
291
|
+
name: "proceed",
|
|
292
|
+
message: "Continue?",
|
|
293
|
+
default: true,
|
|
294
|
+
};
|
|
295
|
+
const effect = promptEffect(question);
|
|
296
|
+
|
|
297
|
+
expect(effect._tag).toBe("Prompt");
|
|
298
|
+
expect((effect as { question: typeof question }).question.type).toBe(
|
|
299
|
+
"confirm",
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("creates a Prompt effect for select", () => {
|
|
304
|
+
const question = {
|
|
305
|
+
type: "select" as const,
|
|
306
|
+
name: "framework",
|
|
307
|
+
message: "Choose a framework:",
|
|
308
|
+
choices: [
|
|
309
|
+
{ label: "React", value: "react" },
|
|
310
|
+
{ label: "Vue", value: "vue" },
|
|
311
|
+
{ label: "Angular", value: "angular" },
|
|
312
|
+
],
|
|
313
|
+
default: "react",
|
|
314
|
+
};
|
|
315
|
+
const effect = promptEffect(question);
|
|
316
|
+
|
|
317
|
+
expect(effect._tag).toBe("Prompt");
|
|
318
|
+
expect(
|
|
319
|
+
(effect as { question: typeof question }).question.choices,
|
|
320
|
+
).toHaveLength(3);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("creates a Prompt effect for multiselect", () => {
|
|
324
|
+
const question = {
|
|
325
|
+
type: "multiselect" as const,
|
|
326
|
+
name: "features",
|
|
327
|
+
message: "Select features:",
|
|
328
|
+
choices: [
|
|
329
|
+
{ label: "TypeScript", value: "ts" },
|
|
330
|
+
{ label: "ESLint", value: "eslint" },
|
|
331
|
+
{ label: "Prettier", value: "prettier" },
|
|
332
|
+
],
|
|
333
|
+
default: ["ts", "prettier"],
|
|
334
|
+
};
|
|
335
|
+
const effect = promptEffect(question);
|
|
336
|
+
|
|
337
|
+
expect(effect._tag).toBe("Prompt");
|
|
338
|
+
expect((effect as { question: typeof question }).question.type).toBe(
|
|
339
|
+
"multiselect",
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// =============================================================================
|
|
346
|
+
// Logging Effect Constructors
|
|
347
|
+
// =============================================================================
|
|
348
|
+
|
|
349
|
+
describe("Effect Constructors - Logging", () => {
|
|
350
|
+
describe("logEffect", () => {
|
|
351
|
+
it("creates a Log effect with debug level", () => {
|
|
352
|
+
const effect = logEffect("debug", "Debug message");
|
|
353
|
+
|
|
354
|
+
expect(effect._tag).toBe("Log");
|
|
355
|
+
expect((effect as { level: string }).level).toBe("debug");
|
|
356
|
+
expect((effect as { message: string }).message).toBe("Debug message");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("creates a Log effect with info level", () => {
|
|
360
|
+
const effect = logEffect("info", "Info message");
|
|
361
|
+
expect((effect as { level: string }).level).toBe("info");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("creates a Log effect with warn level", () => {
|
|
365
|
+
const effect = logEffect("warn", "Warning message");
|
|
366
|
+
expect((effect as { level: string }).level).toBe("warn");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("creates a Log effect with error level", () => {
|
|
370
|
+
const effect = logEffect("error", "Error message");
|
|
371
|
+
expect((effect as { level: string }).level).toBe("error");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("handles empty message", () => {
|
|
375
|
+
const effect = logEffect("info", "");
|
|
376
|
+
expect((effect as { message: string }).message).toBe("");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("handles multiline message", () => {
|
|
380
|
+
const message = "Line 1\nLine 2\nLine 3";
|
|
381
|
+
const effect = logEffect("info", message);
|
|
382
|
+
expect((effect as { message: string }).message).toBe(message);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// =============================================================================
|
|
388
|
+
// Context Effect Constructors
|
|
389
|
+
// =============================================================================
|
|
390
|
+
|
|
391
|
+
describe("Effect Constructors - Context", () => {
|
|
392
|
+
describe("readContextEffect", () => {
|
|
393
|
+
it("creates a ReadContext effect with the given key", () => {
|
|
394
|
+
const effect = readContextEffect("myKey");
|
|
395
|
+
|
|
396
|
+
expect(effect._tag).toBe("ReadContext");
|
|
397
|
+
expect((effect as { key: string }).key).toBe("myKey");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("handles dot notation keys", () => {
|
|
401
|
+
const effect = readContextEffect("user.settings.theme");
|
|
402
|
+
expect((effect as { key: string }).key).toBe("user.settings.theme");
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("writeContextEffect", () => {
|
|
407
|
+
it("creates a WriteContext effect with key and value", () => {
|
|
408
|
+
const effect = writeContextEffect("myKey", { data: 123 });
|
|
409
|
+
|
|
410
|
+
expect(effect._tag).toBe("WriteContext");
|
|
411
|
+
expect((effect as { key: string }).key).toBe("myKey");
|
|
412
|
+
expect((effect as { value: unknown }).value).toEqual({ data: 123 });
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("handles string value", () => {
|
|
416
|
+
const effect = writeContextEffect("name", "John");
|
|
417
|
+
expect((effect as { value: unknown }).value).toBe("John");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("handles number value", () => {
|
|
421
|
+
const effect = writeContextEffect("count", 42);
|
|
422
|
+
expect((effect as { value: unknown }).value).toBe(42);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it("handles boolean value", () => {
|
|
426
|
+
const effect = writeContextEffect("enabled", true);
|
|
427
|
+
expect((effect as { value: unknown }).value).toBe(true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it("handles null value", () => {
|
|
431
|
+
const effect = writeContextEffect("nullable", null);
|
|
432
|
+
expect((effect as { value: unknown }).value).toBeNull();
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("handles undefined value", () => {
|
|
436
|
+
const effect = writeContextEffect("optional", undefined);
|
|
437
|
+
expect((effect as { value: unknown }).value).toBeUndefined();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("handles array value", () => {
|
|
441
|
+
const effect = writeContextEffect("items", [1, 2, 3]);
|
|
442
|
+
expect((effect as { value: unknown }).value).toEqual([1, 2, 3]);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("handles complex nested value", () => {
|
|
446
|
+
const value = {
|
|
447
|
+
user: {
|
|
448
|
+
id: 1,
|
|
449
|
+
settings: {
|
|
450
|
+
theme: "dark",
|
|
451
|
+
notifications: { email: true, push: false },
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
};
|
|
455
|
+
const effect = writeContextEffect("state", value);
|
|
456
|
+
expect((effect as { value: unknown }).value).toEqual(value);
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// =============================================================================
|
|
462
|
+
// Concurrency Effect Constructors
|
|
463
|
+
// =============================================================================
|
|
464
|
+
|
|
465
|
+
describe("Effect Constructors - Concurrency", () => {
|
|
466
|
+
describe("parallelEffect", () => {
|
|
467
|
+
it("creates a Parallel effect with tasks", () => {
|
|
468
|
+
const tasks = [pure(1), pure(2), pure(3)];
|
|
469
|
+
const effect = parallelEffect(tasks);
|
|
470
|
+
|
|
471
|
+
expect(effect._tag).toBe("Parallel");
|
|
472
|
+
expect((effect as { tasks: unknown[] }).tasks).toHaveLength(3);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("handles empty tasks array", () => {
|
|
476
|
+
const effect = parallelEffect([]);
|
|
477
|
+
expect((effect as { tasks: unknown[] }).tasks).toHaveLength(0);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("handles single task", () => {
|
|
481
|
+
const effect = parallelEffect([pure(42)]);
|
|
482
|
+
expect((effect as { tasks: unknown[] }).tasks).toHaveLength(1);
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
describe("raceEffect", () => {
|
|
487
|
+
it("creates a Race effect with tasks", () => {
|
|
488
|
+
const tasks = [pure(1), pure(2)];
|
|
489
|
+
const effect = raceEffect(tasks);
|
|
490
|
+
|
|
491
|
+
expect(effect._tag).toBe("Race");
|
|
492
|
+
expect((effect as { tasks: unknown[] }).tasks).toHaveLength(2);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("handles empty tasks array", () => {
|
|
496
|
+
const effect = raceEffect([]);
|
|
497
|
+
expect((effect as { tasks: unknown[] }).tasks).toHaveLength(0);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
// =============================================================================
|
|
503
|
+
// Effect Utilities
|
|
504
|
+
// =============================================================================
|
|
505
|
+
|
|
506
|
+
describe("Effect Utilities - describeEffect", () => {
|
|
507
|
+
it("describes ReadFile effect", () => {
|
|
508
|
+
const effect = readFileEffect("/path/to/file.txt");
|
|
509
|
+
expect(describeEffect(effect)).toBe("Read file: /path/to/file.txt");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("describes WriteFile effect with byte count", () => {
|
|
513
|
+
const effect = writeFileEffect("/output.txt", "Hello, World!");
|
|
514
|
+
expect(describeEffect(effect)).toBe("Write file: /output.txt (13 bytes)");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it("describes CopyFile effect", () => {
|
|
518
|
+
const effect = copyFileEffect("/source.txt", "/dest.txt");
|
|
519
|
+
expect(describeEffect(effect)).toBe("Copy file: /source.txt → /dest.txt");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("describes CopyDirectory effect", () => {
|
|
523
|
+
const effect = copyDirectoryEffect("/source/dir", "/dest/dir");
|
|
524
|
+
expect(describeEffect(effect)).toBe(
|
|
525
|
+
"Copy directory: /source/dir → /dest/dir",
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("describes DeleteFile effect", () => {
|
|
530
|
+
const effect = deleteFileEffect("/path/to/delete.txt");
|
|
531
|
+
expect(describeEffect(effect)).toBe("Delete file: /path/to/delete.txt");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it("describes DeleteDirectory effect", () => {
|
|
535
|
+
const effect = deleteDirectoryEffect("/path/to/delete/dir");
|
|
536
|
+
expect(describeEffect(effect)).toBe(
|
|
537
|
+
"Delete directory: /path/to/delete/dir",
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("describes MakeDir effect with recursive", () => {
|
|
542
|
+
const effect = makeDirEffect("/new/directory", true);
|
|
543
|
+
expect(describeEffect(effect)).toBe("Created /new/directory/");
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("describes MakeDir effect without recursive", () => {
|
|
547
|
+
const effect = makeDirEffect("/new/directory", false);
|
|
548
|
+
expect(describeEffect(effect)).toBe("Created /new/directory/");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it("describes Exists effect", () => {
|
|
552
|
+
const effect = existsEffect("/path/to/check");
|
|
553
|
+
expect(describeEffect(effect)).toBe("Check exists: /path/to/check");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it("describes Glob effect", () => {
|
|
557
|
+
const effect = globEffect("**/*.ts", "/src");
|
|
558
|
+
expect(describeEffect(effect)).toBe("Glob: **/*.ts in /src");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("describes Exec effect", () => {
|
|
562
|
+
const effect = execEffect("npm", ["install", "--save-dev"]);
|
|
563
|
+
expect(describeEffect(effect)).toBe("Execute: npm install --save-dev");
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("describes Prompt effect", () => {
|
|
567
|
+
const effect = promptEffect({
|
|
568
|
+
type: "text",
|
|
569
|
+
name: "name",
|
|
570
|
+
message: "Enter your name:",
|
|
571
|
+
});
|
|
572
|
+
expect(describeEffect(effect)).toBe("Prompt: Enter your name:");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("describes Log effect", () => {
|
|
576
|
+
const effect = logEffect("info", "Hello, World!");
|
|
577
|
+
expect(describeEffect(effect)).toBe("Log [info]: Hello, World!");
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it("describes ReadContext effect", () => {
|
|
581
|
+
const effect = readContextEffect("myKey");
|
|
582
|
+
expect(describeEffect(effect)).toBe("Read context: myKey");
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("describes WriteContext effect", () => {
|
|
586
|
+
const effect = writeContextEffect("myKey", { data: 123 });
|
|
587
|
+
expect(describeEffect(effect)).toBe("Write context: myKey");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("describes Parallel effect", () => {
|
|
591
|
+
const effect = parallelEffect([pure(1), pure(2), pure(3)]);
|
|
592
|
+
expect(describeEffect(effect)).toBe("Parallel: 3 tasks");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("describes Race effect", () => {
|
|
596
|
+
const effect = raceEffect([pure(1), pure(2)]);
|
|
597
|
+
expect(describeEffect(effect)).toBe("Race: 2 tasks");
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
describe("Effect Utilities - isWriteEffect", () => {
|
|
602
|
+
it("returns true for WriteFile", () => {
|
|
603
|
+
expect(isWriteEffect(writeFileEffect("/path", "content"))).toBe(true);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("returns true for CopyFile", () => {
|
|
607
|
+
expect(isWriteEffect(copyFileEffect("/source", "/dest"))).toBe(true);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("returns true for CopyDirectory", () => {
|
|
611
|
+
expect(isWriteEffect(copyDirectoryEffect("/source", "/dest"))).toBe(true);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("returns true for DeleteFile", () => {
|
|
615
|
+
expect(isWriteEffect(deleteFileEffect("/path"))).toBe(true);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("returns true for DeleteDirectory", () => {
|
|
619
|
+
expect(isWriteEffect(deleteDirectoryEffect("/path"))).toBe(true);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("returns true for MakeDir", () => {
|
|
623
|
+
expect(isWriteEffect(makeDirEffect("/path"))).toBe(true);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("returns false for ReadFile", () => {
|
|
627
|
+
expect(isWriteEffect(readFileEffect("/path"))).toBe(false);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("returns false for Exists", () => {
|
|
631
|
+
expect(isWriteEffect(existsEffect("/path"))).toBe(false);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
it("returns false for Glob", () => {
|
|
635
|
+
expect(isWriteEffect(globEffect("**/*", "/"))).toBe(false);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it("returns false for Exec", () => {
|
|
639
|
+
expect(isWriteEffect(execEffect("echo", ["hello"]))).toBe(false);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("returns false for Prompt", () => {
|
|
643
|
+
expect(
|
|
644
|
+
isWriteEffect(promptEffect({ type: "text", name: "x", message: "?" })),
|
|
645
|
+
).toBe(false);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("returns false for Log", () => {
|
|
649
|
+
expect(isWriteEffect(logEffect("info", "message"))).toBe(false);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("returns false for ReadContext", () => {
|
|
653
|
+
expect(isWriteEffect(readContextEffect("key"))).toBe(false);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("returns false for WriteContext", () => {
|
|
657
|
+
expect(isWriteEffect(writeContextEffect("key", "value"))).toBe(false);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("returns false for Parallel", () => {
|
|
661
|
+
expect(isWriteEffect(parallelEffect([]))).toBe(false);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("returns false for Race", () => {
|
|
665
|
+
expect(isWriteEffect(raceEffect([]))).toBe(false);
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
describe("Effect Utilities - getAffectedPaths", () => {
|
|
670
|
+
it("returns path for ReadFile", () => {
|
|
671
|
+
expect(getAffectedPaths(readFileEffect("/path/file.txt"))).toEqual([
|
|
672
|
+
"/path/file.txt",
|
|
673
|
+
]);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it("returns path for WriteFile", () => {
|
|
677
|
+
expect(
|
|
678
|
+
getAffectedPaths(writeFileEffect("/path/file.txt", "content")),
|
|
679
|
+
).toEqual(["/path/file.txt"]);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("returns path for DeleteFile", () => {
|
|
683
|
+
expect(getAffectedPaths(deleteFileEffect("/path/file.txt"))).toEqual([
|
|
684
|
+
"/path/file.txt",
|
|
685
|
+
]);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it("returns path for MakeDir", () => {
|
|
689
|
+
expect(getAffectedPaths(makeDirEffect("/new/dir"))).toEqual(["/new/dir"]);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("returns path for Exists", () => {
|
|
693
|
+
expect(getAffectedPaths(existsEffect("/path/to/check"))).toEqual([
|
|
694
|
+
"/path/to/check",
|
|
695
|
+
]);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("returns source and dest for CopyFile", () => {
|
|
699
|
+
expect(
|
|
700
|
+
getAffectedPaths(copyFileEffect("/source.txt", "/dest.txt")),
|
|
701
|
+
).toEqual(["/source.txt", "/dest.txt"]);
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
it("returns source and dest for CopyDirectory", () => {
|
|
705
|
+
expect(
|
|
706
|
+
getAffectedPaths(copyDirectoryEffect("/source/dir", "/dest/dir")),
|
|
707
|
+
).toEqual(["/source/dir", "/dest/dir"]);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("returns path for DeleteDirectory", () => {
|
|
711
|
+
expect(getAffectedPaths(deleteDirectoryEffect("/path/dir"))).toEqual([
|
|
712
|
+
"/path/dir",
|
|
713
|
+
]);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
it("returns cwd for Glob", () => {
|
|
717
|
+
expect(getAffectedPaths(globEffect("**/*.ts", "/src"))).toEqual(["/src"]);
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("returns empty array for Exec", () => {
|
|
721
|
+
expect(getAffectedPaths(execEffect("npm", ["install"]))).toEqual([]);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("returns empty array for Prompt", () => {
|
|
725
|
+
expect(
|
|
726
|
+
getAffectedPaths(promptEffect({ type: "text", name: "x", message: "?" })),
|
|
727
|
+
).toEqual([]);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("returns empty array for Log", () => {
|
|
731
|
+
expect(getAffectedPaths(logEffect("info", "message"))).toEqual([]);
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
it("returns empty array for ReadContext", () => {
|
|
735
|
+
expect(getAffectedPaths(readContextEffect("key"))).toEqual([]);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("returns empty array for WriteContext", () => {
|
|
739
|
+
expect(getAffectedPaths(writeContextEffect("key", "value"))).toEqual([]);
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
it("returns empty array for Parallel", () => {
|
|
743
|
+
expect(getAffectedPaths(parallelEffect([]))).toEqual([]);
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
it("returns empty array for Race", () => {
|
|
747
|
+
expect(getAffectedPaths(raceEffect([]))).toEqual([]);
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// =============================================================================
|
|
752
|
+
// Edge Cases
|
|
753
|
+
// =============================================================================
|
|
754
|
+
|
|
755
|
+
describe("Effect Constructors - Edge Cases", () => {
|
|
756
|
+
describe("Path handling", () => {
|
|
757
|
+
it("handles Windows-style paths", () => {
|
|
758
|
+
const effect = readFileEffect("C:\\Users\\name\\file.txt");
|
|
759
|
+
expect((effect as { path: string }).path).toBe(
|
|
760
|
+
"C:\\Users\\name\\file.txt",
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("handles trailing slashes", () => {
|
|
765
|
+
const effect = makeDirEffect("/path/to/dir/");
|
|
766
|
+
expect((effect as { path: string }).path).toBe("/path/to/dir/");
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("handles double slashes", () => {
|
|
770
|
+
const effect = readFileEffect("/path//to//file.txt");
|
|
771
|
+
expect((effect as { path: string }).path).toBe("/path//to//file.txt");
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("handles dot paths", () => {
|
|
775
|
+
const effect = readFileEffect("./file.txt");
|
|
776
|
+
expect((effect as { path: string }).path).toBe("./file.txt");
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it("handles parent directory references", () => {
|
|
780
|
+
const effect = readFileEffect("../parent/file.txt");
|
|
781
|
+
expect((effect as { path: string }).path).toBe("../parent/file.txt");
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
describe("Content handling", () => {
|
|
786
|
+
it("handles binary-like content", () => {
|
|
787
|
+
const binaryLike = "\x00\x01\x02\x03";
|
|
788
|
+
const effect = writeFileEffect("/binary.dat", binaryLike);
|
|
789
|
+
expect((effect as { content: string }).content).toBe(binaryLike);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("handles very long content", () => {
|
|
793
|
+
const longContent = "a".repeat(1_000_000);
|
|
794
|
+
const effect = writeFileEffect("/large.txt", longContent);
|
|
795
|
+
expect((effect as { content: string }).content.length).toBe(1_000_000);
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
describe("Exec command handling", () => {
|
|
800
|
+
it("handles command with path", () => {
|
|
801
|
+
const effect = execEffect("/usr/bin/node", ["script.js"]);
|
|
802
|
+
expect((effect as { command: string }).command).toBe("/usr/bin/node");
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it("handles args with equals sign", () => {
|
|
806
|
+
const effect = execEffect("npm", [
|
|
807
|
+
"config",
|
|
808
|
+
"set",
|
|
809
|
+
"registry=https://npm.example.com",
|
|
810
|
+
]);
|
|
811
|
+
expect((effect as { args: string[] }).args).toContain(
|
|
812
|
+
"registry=https://npm.example.com",
|
|
813
|
+
);
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
});
|