@nijaru/tk 0.0.4 → 0.1.1
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/bin/tk.js +30 -0
- package/package.json +22 -45
- package/LICENSE +0 -21
- package/README.md +0 -242
- package/src/cli.test.ts +0 -976
- package/src/cli.ts +0 -695
- package/src/db/storage.ts +0 -1014
- package/src/lib/completions.ts +0 -425
- package/src/lib/format.test.ts +0 -361
- package/src/lib/format.ts +0 -188
- package/src/lib/help.ts +0 -249
- package/src/lib/priority.test.ts +0 -105
- package/src/lib/priority.ts +0 -40
- package/src/lib/root.ts +0 -79
- package/src/lib/time.ts +0 -115
- package/src/types.ts +0 -130
package/src/cli.test.ts
DELETED
|
@@ -1,976 +0,0 @@
|
|
|
1
|
-
import { test, expect, beforeEach, afterEach, describe } from "bun:test";
|
|
2
|
-
import { mkdtempSync, rmSync, existsSync } from "fs";
|
|
3
|
-
import { join } from "path";
|
|
4
|
-
import { tmpdir } from "os";
|
|
5
|
-
|
|
6
|
-
const PROJECT_ROOT = import.meta.dir.replace("/src", "");
|
|
7
|
-
const CLI_PATH = join(PROJECT_ROOT, "src/cli.ts");
|
|
8
|
-
|
|
9
|
-
async function run(
|
|
10
|
-
args: string[],
|
|
11
|
-
cwd: string,
|
|
12
|
-
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
13
|
-
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], {
|
|
14
|
-
cwd,
|
|
15
|
-
stdout: "pipe",
|
|
16
|
-
stderr: "pipe",
|
|
17
|
-
env: { ...process.env, NO_COLOR: "1" },
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
const stdout = await new Response(proc.stdout).text();
|
|
21
|
-
const stderr = await new Response(proc.stderr).text();
|
|
22
|
-
const exitCode = await proc.exited;
|
|
23
|
-
|
|
24
|
-
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe("tk CLI", () => {
|
|
28
|
-
let testDir: string;
|
|
29
|
-
|
|
30
|
-
beforeEach(() => {
|
|
31
|
-
testDir = mkdtempSync(join(tmpdir(), "tk-test-"));
|
|
32
|
-
Bun.spawnSync(["git", "init"], { cwd: testDir });
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
afterEach(() => {
|
|
36
|
-
rmSync(testDir, { recursive: true, force: true });
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test("--help shows usage", async () => {
|
|
40
|
-
const { stdout, exitCode } = await run(["--help"], testDir);
|
|
41
|
-
expect(exitCode).toBe(0);
|
|
42
|
-
expect(stdout).toContain("tk v");
|
|
43
|
-
expect(stdout).toContain("COMMANDS:");
|
|
44
|
-
expect(stdout).toContain("add");
|
|
45
|
-
expect(stdout).toContain("Run 'tk <command> --help'");
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("command --help shows command-specific help", async () => {
|
|
49
|
-
const { stdout: addHelp, exitCode: addCode } = await run(["add", "--help"], testDir);
|
|
50
|
-
expect(addCode).toBe(0);
|
|
51
|
-
expect(addHelp).toContain("tk add");
|
|
52
|
-
expect(addHelp).toContain("--priority");
|
|
53
|
-
|
|
54
|
-
const { stdout: lsHelp, exitCode: lsCode } = await run(["ls", "--help"], testDir);
|
|
55
|
-
expect(lsCode).toBe(0);
|
|
56
|
-
expect(lsHelp).toContain("tk ls");
|
|
57
|
-
expect(lsHelp).toContain("--status");
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("help <command> shows command-specific help", async () => {
|
|
61
|
-
const { stdout, exitCode } = await run(["help", "add"], testDir);
|
|
62
|
-
expect(exitCode).toBe(0);
|
|
63
|
-
expect(stdout).toContain("tk add");
|
|
64
|
-
expect(stdout).toContain("--priority");
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("--version shows version", async () => {
|
|
68
|
-
const { stdout, exitCode } = await run(["--version"], testDir);
|
|
69
|
-
expect(exitCode).toBe(0);
|
|
70
|
-
expect(stdout).toMatch(/^tk v\d+\.\d+\.\d+$/);
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
test("unknown command errors", async () => {
|
|
74
|
-
const { stderr, exitCode } = await run(["unknown"], testDir);
|
|
75
|
-
expect(exitCode).toBe(1);
|
|
76
|
-
expect(stderr).toContain("Unknown command: unknown");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe("add", () => {
|
|
80
|
-
test("creates task and returns ID", async () => {
|
|
81
|
-
const { stdout, exitCode } = await run(["add", "Test task"], testDir);
|
|
82
|
-
expect(exitCode).toBe(0);
|
|
83
|
-
expect(stdout).toMatch(/^tk-[a-z0-9]{4}$/);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test("creates task with priority", async () => {
|
|
87
|
-
const { stdout } = await run(["add", "Urgent task", "-p", "1"], testDir);
|
|
88
|
-
const id = stdout.trim();
|
|
89
|
-
|
|
90
|
-
const { stdout: showOut } = await run(["show", id, "--json"], testDir);
|
|
91
|
-
const task = JSON.parse(showOut);
|
|
92
|
-
expect(task.priority).toBe(1);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test("creates task with description", async () => {
|
|
96
|
-
const { stdout } = await run(["add", "Task", "-d", "Description here"], testDir);
|
|
97
|
-
const id = stdout.trim();
|
|
98
|
-
|
|
99
|
-
const { stdout: showOut } = await run(["show", id, "--json"], testDir);
|
|
100
|
-
const task = JSON.parse(showOut);
|
|
101
|
-
expect(task.description).toBe("Description here");
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("accepts p0-p4 priority format", async () => {
|
|
105
|
-
const { stdout } = await run(["add", "Task", "-p", "p1"], testDir);
|
|
106
|
-
const id = stdout.trim();
|
|
107
|
-
|
|
108
|
-
const { stdout: showOut } = await run(["show", id, "--json"], testDir);
|
|
109
|
-
const task = JSON.parse(showOut);
|
|
110
|
-
expect(task.priority).toBe(1);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test("accepts named priority format", async () => {
|
|
114
|
-
const { stdout } = await run(["add", "Task", "-p", "urgent"], testDir);
|
|
115
|
-
const id = stdout.trim();
|
|
116
|
-
|
|
117
|
-
const { stdout: showOut } = await run(["show", id, "--json"], testDir);
|
|
118
|
-
const task = JSON.parse(showOut);
|
|
119
|
-
expect(task.priority).toBe(1);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("creates task with labels", async () => {
|
|
123
|
-
const { stdout } = await run(["add", "Task", "-l", "bug,urgent"], testDir);
|
|
124
|
-
const id = stdout.trim();
|
|
125
|
-
|
|
126
|
-
const { stdout: showOut } = await run(["show", id, "--json"], testDir);
|
|
127
|
-
const task = JSON.parse(showOut);
|
|
128
|
-
expect(task.labels).toContain("bug");
|
|
129
|
-
expect(task.labels).toContain("urgent");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("creates task with due date", async () => {
|
|
133
|
-
const { stdout } = await run(["add", "Task", "--due", "2026-01-15"], testDir);
|
|
134
|
-
const id = stdout.trim();
|
|
135
|
-
|
|
136
|
-
const { stdout: showOut } = await run(["show", id, "--json"], testDir);
|
|
137
|
-
const task = JSON.parse(showOut);
|
|
138
|
-
expect(task.due_date).toBe("2026-01-15");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
test("creates task with relative due date", async () => {
|
|
142
|
-
const { stdout } = await run(["add", "Task", "--due", "+7d"], testDir);
|
|
143
|
-
const id = stdout.trim();
|
|
144
|
-
|
|
145
|
-
const { stdout: showOut } = await run(["show", id, "--json"], testDir);
|
|
146
|
-
const task = JSON.parse(showOut);
|
|
147
|
-
expect(task.due_date).not.toBeNull();
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test("creates task with custom project", async () => {
|
|
151
|
-
const { stdout } = await run(["add", "Task", "-P", "api"], testDir);
|
|
152
|
-
expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("errors without title", async () => {
|
|
156
|
-
const { stderr, exitCode } = await run(["add"], testDir);
|
|
157
|
-
expect(exitCode).toBe(1);
|
|
158
|
-
expect(stderr).toContain("Title required");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
test("errors with whitespace-only title", async () => {
|
|
162
|
-
const { stderr, exitCode } = await run(["add", " "], testDir);
|
|
163
|
-
expect(exitCode).toBe(1);
|
|
164
|
-
expect(stderr).toContain("Title required");
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("errors with non-existent parent", async () => {
|
|
168
|
-
const { stderr, exitCode } = await run(["add", "Task", "--parent", "tk-999"], testDir);
|
|
169
|
-
expect(exitCode).toBe(1);
|
|
170
|
-
expect(stderr).toContain("Parent task not found");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("resolves parent by ref only", async () => {
|
|
174
|
-
const { stdout: parentId } = await run(["add", "Parent task"], testDir);
|
|
175
|
-
const ref = parentId.trim().split("-")[1] ?? "";
|
|
176
|
-
|
|
177
|
-
const { stdout: childId, exitCode } = await run(
|
|
178
|
-
["add", "Child task", "--parent", ref],
|
|
179
|
-
testDir,
|
|
180
|
-
);
|
|
181
|
-
expect(exitCode).toBe(0);
|
|
182
|
-
|
|
183
|
-
const { stdout } = await run(["show", childId.trim()], testDir);
|
|
184
|
-
expect(stdout).toContain("Parent:");
|
|
185
|
-
expect(stdout).toContain(parentId.trim());
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test("errors with invalid project name", async () => {
|
|
189
|
-
const { stderr, exitCode } = await run(["add", "Task", "-P", "Invalid-Name!"], testDir);
|
|
190
|
-
expect(exitCode).toBe(1);
|
|
191
|
-
expect(stderr).toContain("Invalid project name");
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
describe("ls", () => {
|
|
196
|
-
test("lists tasks", async () => {
|
|
197
|
-
await run(["add", "Task 1"], testDir);
|
|
198
|
-
await run(["add", "Task 2"], testDir);
|
|
199
|
-
|
|
200
|
-
const { stdout } = await run(["ls"], testDir);
|
|
201
|
-
expect(stdout).toContain("Task 1");
|
|
202
|
-
expect(stdout).toContain("Task 2");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("filters by status", async () => {
|
|
206
|
-
await run(["add", "Open task"], testDir);
|
|
207
|
-
const { stdout: id2 } = await run(["add", "Done task"], testDir);
|
|
208
|
-
await run(["done", id2.trim()], testDir);
|
|
209
|
-
|
|
210
|
-
const { stdout } = await run(["ls", "-s", "open"], testDir);
|
|
211
|
-
expect(stdout).toContain("Open task");
|
|
212
|
-
expect(stdout).not.toContain("Done task");
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test("filters by project", async () => {
|
|
216
|
-
await run(["add", "API task", "-P", "api"], testDir);
|
|
217
|
-
await run(["add", "Web task", "-P", "web"], testDir);
|
|
218
|
-
|
|
219
|
-
const { stdout } = await run(["ls", "-P", "api"], testDir);
|
|
220
|
-
expect(stdout).toContain("API task");
|
|
221
|
-
expect(stdout).not.toContain("Web task");
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
test("filters by label", async () => {
|
|
225
|
-
await run(["add", "Bug task", "-l", "bug"], testDir);
|
|
226
|
-
await run(["add", "Feature task", "-l", "feature"], testDir);
|
|
227
|
-
|
|
228
|
-
const { stdout } = await run(["ls", "-l", "bug"], testDir);
|
|
229
|
-
expect(stdout).toContain("Bug task");
|
|
230
|
-
expect(stdout).not.toContain("Feature task");
|
|
231
|
-
});
|
|
232
|
-
|
|
233
|
-
test("--json outputs JSON array", async () => {
|
|
234
|
-
await run(["add", "Task"], testDir);
|
|
235
|
-
|
|
236
|
-
const { stdout } = await run(["ls", "--json"], testDir);
|
|
237
|
-
const tasks = JSON.parse(stdout);
|
|
238
|
-
expect(Array.isArray(tasks)).toBe(true);
|
|
239
|
-
expect(tasks.length).toBe(1);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
test("empty list shows message", async () => {
|
|
243
|
-
const { stdout } = await run(["ls"], testDir);
|
|
244
|
-
expect(stdout).toContain("No tasks found");
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("sorts by status (active > open > done)", async () => {
|
|
248
|
-
const { stdout: idDone } = await run(["add", "Done task"], testDir);
|
|
249
|
-
await run(["done", idDone.trim()], testDir);
|
|
250
|
-
const { stdout: idActive } = await run(["add", "Active task"], testDir);
|
|
251
|
-
await run(["start", idActive.trim()], testDir);
|
|
252
|
-
await run(["add", "Open task"], testDir);
|
|
253
|
-
|
|
254
|
-
const { stdout } = await run(["ls"], testDir);
|
|
255
|
-
const lines = stdout
|
|
256
|
-
.trim()
|
|
257
|
-
.split("\n")
|
|
258
|
-
.filter((l) => l.includes("task"));
|
|
259
|
-
expect(lines[0]).toContain("Active task");
|
|
260
|
-
expect(lines[1]).toContain("Open task");
|
|
261
|
-
expect(lines[2]).toContain("Done task");
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test("sorts by priority (p1-4, then p0/none)", async () => {
|
|
265
|
-
await run(["add", "Medium", "-p", "3"], testDir);
|
|
266
|
-
await run(["add", "Urgent", "-p", "1"], testDir);
|
|
267
|
-
await run(["add", "None", "-p", "0"], testDir);
|
|
268
|
-
await run(["add", "Low", "-p", "4"], testDir);
|
|
269
|
-
|
|
270
|
-
const { stdout } = await run(["ls"], testDir);
|
|
271
|
-
const lines = stdout
|
|
272
|
-
.trim()
|
|
273
|
-
.split("\n")
|
|
274
|
-
.filter((l) => /Medium|Urgent|None|Low/.test(l));
|
|
275
|
-
expect(lines[0]).toContain("Urgent");
|
|
276
|
-
expect(lines[1]).toContain("Medium");
|
|
277
|
-
expect(lines[2]).toContain("Low");
|
|
278
|
-
expect(lines[3]).toContain("None");
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
test("hoists overdue tasks to the top of their status group", async () => {
|
|
282
|
-
await run(["add", "Urgent Future", "-p", "1", "--due", "2099-01-01"], testDir);
|
|
283
|
-
await run(["add", "Low Overdue", "-p", "4", "--due", "2020-01-01"], testDir);
|
|
284
|
-
|
|
285
|
-
const { stdout } = await run(["ls"], testDir);
|
|
286
|
-
const lines = stdout
|
|
287
|
-
.trim()
|
|
288
|
-
.split("\n")
|
|
289
|
-
.filter((l) => /Future|Overdue/.test(l));
|
|
290
|
-
expect(lines[0]).toContain("Low Overdue");
|
|
291
|
-
expect(lines[1]).toContain("Urgent Future");
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
test("sorts by due date when priority is equal", async () => {
|
|
295
|
-
await run(["add", "Later", "-p", "3", "--due", "2099-01-02"], testDir);
|
|
296
|
-
await run(["add", "Earlier", "-p", "3", "--due", "2099-01-01"], testDir);
|
|
297
|
-
|
|
298
|
-
const { stdout } = await run(["ls"], testDir);
|
|
299
|
-
const lines = stdout
|
|
300
|
-
.trim()
|
|
301
|
-
.split("\n")
|
|
302
|
-
.filter((l) => /Earlier|Later/.test(l));
|
|
303
|
-
expect(lines[0]).toContain("Earlier");
|
|
304
|
-
expect(lines[1]).toContain("Later");
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
test("sorts done tasks by completion time (newest first)", async () => {
|
|
308
|
-
const { stdout: id1 } = await run(["add", "Done First"], testDir);
|
|
309
|
-
const { stdout: id2 } = await run(["add", "Done Second"], testDir);
|
|
310
|
-
await run(["done", id1.trim()], testDir);
|
|
311
|
-
// Ensure time difference
|
|
312
|
-
await new Promise((r) => setTimeout(r, 10));
|
|
313
|
-
await run(["done", id2.trim()], testDir);
|
|
314
|
-
|
|
315
|
-
const { stdout } = await run(["ls", "-s", "done"], testDir);
|
|
316
|
-
const lines = stdout
|
|
317
|
-
.trim()
|
|
318
|
-
.split("\n")
|
|
319
|
-
.filter((l) => l.includes("Done"));
|
|
320
|
-
expect(lines[0]).toContain("Done Second");
|
|
321
|
-
expect(lines[1]).toContain("Done First");
|
|
322
|
-
});
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
describe("ready", () => {
|
|
326
|
-
test("shows active and unblocked open tasks", async () => {
|
|
327
|
-
const { stdout: id1 } = await run(["add", "Active task"], testDir);
|
|
328
|
-
await run(["start", id1.trim()], testDir);
|
|
329
|
-
await run(["add", "Open task"], testDir);
|
|
330
|
-
const { stdout: id2 } = await run(["add", "Blocked task"], testDir);
|
|
331
|
-
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
332
|
-
|
|
333
|
-
const { stdout } = await run(["ready"], testDir);
|
|
334
|
-
expect(stdout).toContain("Active task");
|
|
335
|
-
expect(stdout).toContain("Open task");
|
|
336
|
-
expect(stdout).not.toContain("Blocked task");
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
test("shows blocked task after blocker is done", async () => {
|
|
340
|
-
const { stdout: id1 } = await run(["add", "Blocker"], testDir);
|
|
341
|
-
const { stdout: id2 } = await run(["add", "Blocked"], testDir);
|
|
342
|
-
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
343
|
-
await run(["done", id1.trim()], testDir);
|
|
344
|
-
|
|
345
|
-
const { stdout } = await run(["ready"], testDir);
|
|
346
|
-
expect(stdout).toContain("Blocked");
|
|
347
|
-
});
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
describe("status transitions", () => {
|
|
351
|
-
test("start: open -> active", async () => {
|
|
352
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
353
|
-
await run(["start", id.trim()], testDir);
|
|
354
|
-
|
|
355
|
-
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
356
|
-
const task = JSON.parse(stdout);
|
|
357
|
-
expect(task.status).toBe("active");
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
test("done: any -> done", async () => {
|
|
361
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
362
|
-
await run(["done", id.trim()], testDir);
|
|
363
|
-
|
|
364
|
-
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
365
|
-
const task = JSON.parse(stdout);
|
|
366
|
-
expect(task.status).toBe("done");
|
|
367
|
-
expect(task.completed_at).not.toBeNull();
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test("reopen: done -> open", async () => {
|
|
371
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
372
|
-
await run(["done", id.trim()], testDir);
|
|
373
|
-
await run(["reopen", id.trim()], testDir);
|
|
374
|
-
|
|
375
|
-
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
376
|
-
const task = JSON.parse(stdout);
|
|
377
|
-
expect(task.status).toBe("open");
|
|
378
|
-
expect(task.completed_at).toBeNull();
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
test("start errors if already active", async () => {
|
|
382
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
383
|
-
await run(["start", id.trim()], testDir);
|
|
384
|
-
|
|
385
|
-
const { stderr, exitCode } = await run(["start", id.trim()], testDir);
|
|
386
|
-
expect(exitCode).toBe(1);
|
|
387
|
-
expect(stderr).toContain("already active");
|
|
388
|
-
expect(stderr).toContain("tk done"); // suggests next action
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
test("start errors if already done", async () => {
|
|
392
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
393
|
-
await run(["done", id.trim()], testDir);
|
|
394
|
-
|
|
395
|
-
const { stderr, exitCode } = await run(["start", id.trim()], testDir);
|
|
396
|
-
expect(exitCode).toBe(1);
|
|
397
|
-
expect(stderr).toContain("already done");
|
|
398
|
-
expect(stderr).toContain("tk reopen"); // suggests next action
|
|
399
|
-
});
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
describe("edit", () => {
|
|
403
|
-
test("updates title", async () => {
|
|
404
|
-
const { stdout: id } = await run(["add", "Original"], testDir);
|
|
405
|
-
await run(["edit", id.trim(), "-t", "Updated"], testDir);
|
|
406
|
-
|
|
407
|
-
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
408
|
-
const task = JSON.parse(stdout);
|
|
409
|
-
expect(task.title).toBe("Updated");
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
test("updates priority", async () => {
|
|
413
|
-
const { stdout: id } = await run(["add", "Task", "-p", "3"], testDir);
|
|
414
|
-
await run(["edit", id.trim(), "-p", "1"], testDir);
|
|
415
|
-
|
|
416
|
-
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
417
|
-
const task = JSON.parse(stdout);
|
|
418
|
-
expect(task.priority).toBe(1);
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
test("adds label with +", async () => {
|
|
422
|
-
const { stdout: id } = await run(["add", "Task", "-l", "bug"], testDir);
|
|
423
|
-
await run(["edit", id.trim(), "-l", "+urgent"], testDir);
|
|
424
|
-
|
|
425
|
-
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
426
|
-
const task = JSON.parse(stdout);
|
|
427
|
-
expect(task.labels).toContain("bug");
|
|
428
|
-
expect(task.labels).toContain("urgent");
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
test("removes label with -", async () => {
|
|
432
|
-
const { stdout: id } = await run(["add", "Task", "-l", "bug,urgent"], testDir);
|
|
433
|
-
// Use --labels= format to avoid -bug being parsed as flag
|
|
434
|
-
await run(["edit", id.trim(), "--labels=-bug"], testDir);
|
|
435
|
-
|
|
436
|
-
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
437
|
-
const task = JSON.parse(stdout);
|
|
438
|
-
expect(task.labels).not.toContain("bug");
|
|
439
|
-
expect(task.labels).toContain("urgent");
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
test("prevents duplicate labels with +", async () => {
|
|
443
|
-
const { stdout: id } = await run(["add", "Task", "-l", "bug"], testDir);
|
|
444
|
-
await run(["edit", id.trim(), "-l", "+bug"], testDir);
|
|
445
|
-
|
|
446
|
-
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
447
|
-
const task = JSON.parse(stdout);
|
|
448
|
-
expect(task.labels).toEqual(["bug"]);
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
test("errors on self-referential parent", async () => {
|
|
452
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
453
|
-
const { stderr, exitCode } = await run(["edit", id.trim(), "--parent", id.trim()], testDir);
|
|
454
|
-
expect(exitCode).toBe(1);
|
|
455
|
-
expect(stderr).toContain("cannot be its own parent");
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
test("errors on parent cycle", async () => {
|
|
459
|
-
const { stdout: id1 } = await run(["add", "Parent"], testDir);
|
|
460
|
-
const { stdout: id2 } = await run(["add", "Child", "--parent", id1.trim()], testDir);
|
|
461
|
-
const { stderr, exitCode } = await run(["edit", id1.trim(), "--parent", id2.trim()], testDir);
|
|
462
|
-
expect(exitCode).toBe(1);
|
|
463
|
-
expect(stderr).toContain("circular parent");
|
|
464
|
-
});
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
describe("log", () => {
|
|
468
|
-
test("adds log entry", async () => {
|
|
469
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
470
|
-
await run(["log", id.trim(), "First entry"], testDir);
|
|
471
|
-
await run(["log", id.trim(), "Second entry"], testDir);
|
|
472
|
-
|
|
473
|
-
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
474
|
-
const task = JSON.parse(stdout);
|
|
475
|
-
expect(task.logs.length).toBe(2);
|
|
476
|
-
expect(task.logs[0].msg).toBe("First entry");
|
|
477
|
-
expect(task.logs[1].msg).toBe("Second entry");
|
|
478
|
-
});
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
describe("block/unblock", () => {
|
|
482
|
-
test("block prevents task from being ready", async () => {
|
|
483
|
-
const { stdout: id1 } = await run(["add", "Blocker"], testDir);
|
|
484
|
-
const { stdout: id2 } = await run(["add", "Blocked"], testDir);
|
|
485
|
-
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
486
|
-
|
|
487
|
-
const { stdout } = await run(["show", id2.trim(), "--json"], testDir);
|
|
488
|
-
const task = JSON.parse(stdout);
|
|
489
|
-
expect(task.blocked_by).toContain(id1.trim());
|
|
490
|
-
expect(task.blocked_by_incomplete).toBe(true);
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
test("unblock removes dependency", async () => {
|
|
494
|
-
const { stdout: id1 } = await run(["add", "Blocker"], testDir);
|
|
495
|
-
const { stdout: id2 } = await run(["add", "Blocked"], testDir);
|
|
496
|
-
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
497
|
-
await run(["unblock", id2.trim(), id1.trim()], testDir);
|
|
498
|
-
|
|
499
|
-
const { stdout } = await run(["show", id2.trim(), "--json"], testDir);
|
|
500
|
-
const task = JSON.parse(stdout);
|
|
501
|
-
expect(task.blocked_by).not.toContain(id1.trim());
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
test("cannot block self", async () => {
|
|
505
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
506
|
-
const { stderr, exitCode } = await run(["block", id.trim(), id.trim()], testDir);
|
|
507
|
-
expect(exitCode).toBe(1);
|
|
508
|
-
expect(stderr).toContain("cannot block itself");
|
|
509
|
-
});
|
|
510
|
-
|
|
511
|
-
test("validates blocker exists", async () => {
|
|
512
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
513
|
-
const { stderr, exitCode } = await run(["block", id.trim(), "tk-999"], testDir);
|
|
514
|
-
expect(exitCode).toBe(1);
|
|
515
|
-
expect(stderr).toContain("not found");
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
test("detects circular dependencies", async () => {
|
|
519
|
-
const { stdout: id1 } = await run(["add", "Task A"], testDir);
|
|
520
|
-
const { stdout: id2 } = await run(["add", "Task B"], testDir);
|
|
521
|
-
const { stdout: id3 } = await run(["add", "Task C"], testDir);
|
|
522
|
-
|
|
523
|
-
await run(["block", id1.trim(), id2.trim()], testDir);
|
|
524
|
-
await run(["block", id2.trim(), id3.trim()], testDir);
|
|
525
|
-
|
|
526
|
-
const { stderr, exitCode } = await run(["block", id3.trim(), id1.trim()], testDir);
|
|
527
|
-
expect(exitCode).toBe(1);
|
|
528
|
-
expect(stderr).toContain("circular");
|
|
529
|
-
});
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
describe("rm", () => {
|
|
533
|
-
test("deletes task", async () => {
|
|
534
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
535
|
-
await run(["rm", id.trim()], testDir);
|
|
536
|
-
|
|
537
|
-
const { stderr, exitCode } = await run(["show", id.trim()], testDir);
|
|
538
|
-
expect(exitCode).toBe(1);
|
|
539
|
-
expect(stderr).toContain("not found");
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
test("removes associated blocks", async () => {
|
|
543
|
-
const { stdout: id1 } = await run(["add", "Blocker"], testDir);
|
|
544
|
-
const { stdout: id2 } = await run(["add", "Blocked"], testDir);
|
|
545
|
-
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
546
|
-
await run(["rm", id1.trim()], testDir);
|
|
547
|
-
|
|
548
|
-
const { stdout } = await run(["show", id2.trim(), "--json"], testDir);
|
|
549
|
-
const task = JSON.parse(stdout);
|
|
550
|
-
expect(task.blocked_by).not.toContain(id1.trim());
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
test("clears parent references on child tasks", async () => {
|
|
554
|
-
const { stdout: parentId } = await run(["add", "Parent"], testDir);
|
|
555
|
-
const { stdout: childId } = await run(["add", "Child", "--parent", parentId.trim()], testDir);
|
|
556
|
-
|
|
557
|
-
// Verify child has parent
|
|
558
|
-
const { stdout: before } = await run(["show", childId.trim(), "--json"], testDir);
|
|
559
|
-
expect(JSON.parse(before).parent).toBe(parentId.trim());
|
|
560
|
-
|
|
561
|
-
// Delete parent
|
|
562
|
-
await run(["rm", parentId.trim()], testDir);
|
|
563
|
-
|
|
564
|
-
// Child's parent should be cleared
|
|
565
|
-
const { stdout: after } = await run(["show", childId.trim(), "--json"], testDir);
|
|
566
|
-
expect(JSON.parse(after).parent).toBeNull();
|
|
567
|
-
});
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
describe("clean", () => {
|
|
571
|
-
test("removes completed tasks", async () => {
|
|
572
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
573
|
-
await run(["done", id.trim()], testDir);
|
|
574
|
-
await run(["clean", "--force"], testDir);
|
|
575
|
-
|
|
576
|
-
const { exitCode } = await run(["show", id.trim()], testDir);
|
|
577
|
-
expect(exitCode).toBe(1);
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
test("keeps open tasks", async () => {
|
|
581
|
-
const { stdout: id } = await run(["add", "Open task"], testDir);
|
|
582
|
-
await run(["clean", "--force"], testDir);
|
|
583
|
-
|
|
584
|
-
const { exitCode } = await run(["show", id.trim()], testDir);
|
|
585
|
-
expect(exitCode).toBe(0);
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
test("--older-than respects age threshold", async () => {
|
|
589
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
590
|
-
await run(["done", id.trim()], testDir);
|
|
591
|
-
|
|
592
|
-
// Task was just completed, so --older-than 1 should keep it
|
|
593
|
-
await run(["clean", "--older-than", "1"], testDir);
|
|
594
|
-
const { exitCode: stillExists } = await run(["show", id.trim()], testDir);
|
|
595
|
-
expect(stillExists).toBe(0);
|
|
596
|
-
|
|
597
|
-
// --older-than 0 should remove it (0 days = remove all)
|
|
598
|
-
await run(["clean", "--older-than", "0"], testDir);
|
|
599
|
-
const { exitCode: gone } = await run(["show", id.trim()], testDir);
|
|
600
|
-
expect(gone).toBe(1);
|
|
601
|
-
});
|
|
602
|
-
});
|
|
603
|
-
|
|
604
|
-
describe("init", () => {
|
|
605
|
-
test("creates .tasks directory", async () => {
|
|
606
|
-
const newDir = mkdtempSync(join(tmpdir(), "tk-init-"));
|
|
607
|
-
Bun.spawnSync(["git", "init"], { cwd: newDir });
|
|
608
|
-
|
|
609
|
-
const { stdout, exitCode } = await run(["init"], newDir);
|
|
610
|
-
expect(exitCode).toBe(0);
|
|
611
|
-
expect(stdout).toContain("Initialized");
|
|
612
|
-
expect(existsSync(join(newDir, ".tasks"))).toBe(true);
|
|
613
|
-
|
|
614
|
-
rmSync(newDir, { recursive: true, force: true });
|
|
615
|
-
});
|
|
616
|
-
|
|
617
|
-
test("creates .tasks with custom project", async () => {
|
|
618
|
-
const newDir = mkdtempSync(join(tmpdir(), "tk-init-"));
|
|
619
|
-
Bun.spawnSync(["git", "init"], { cwd: newDir });
|
|
620
|
-
|
|
621
|
-
await run(["init", "-P", "api"], newDir);
|
|
622
|
-
const { stdout } = await run(["add", "Task"], newDir);
|
|
623
|
-
expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
|
|
624
|
-
|
|
625
|
-
rmSync(newDir, { recursive: true, force: true });
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
test("reports already initialized", async () => {
|
|
629
|
-
await run(["init"], testDir);
|
|
630
|
-
const { stdout } = await run(["init"], testDir);
|
|
631
|
-
expect(stdout).toContain("Already initialized");
|
|
632
|
-
});
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
describe("config", () => {
|
|
636
|
-
test("shows config", async () => {
|
|
637
|
-
await run(["init"], testDir);
|
|
638
|
-
const { stdout } = await run(["config"], testDir);
|
|
639
|
-
expect(stdout).toContain("Version:");
|
|
640
|
-
expect(stdout).toContain("Project:");
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
test("shows default project derived from directory", async () => {
|
|
644
|
-
await run(["init"], testDir);
|
|
645
|
-
const { stdout } = await run(["config", "project"], testDir);
|
|
646
|
-
// Project is derived from directory name (tktest... from temp dir)
|
|
647
|
-
expect(stdout).toMatch(/^tktest[a-z0-9]+$/);
|
|
648
|
-
});
|
|
649
|
-
|
|
650
|
-
test("respects explicit project name", async () => {
|
|
651
|
-
await run(["init", "--project", "myapp"], testDir);
|
|
652
|
-
const { stdout } = await run(["config", "project"], testDir);
|
|
653
|
-
expect(stdout).toBe("myapp");
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
test("sets default project", async () => {
|
|
657
|
-
await run(["init"], testDir);
|
|
658
|
-
await run(["config", "project", "api"], testDir);
|
|
659
|
-
|
|
660
|
-
const { stdout } = await run(["add", "Task"], testDir);
|
|
661
|
-
expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
test("manages aliases", async () => {
|
|
665
|
-
await run(["init"], testDir);
|
|
666
|
-
await run(["config", "alias", "api", "packages/api"], testDir);
|
|
667
|
-
|
|
668
|
-
const { stdout } = await run(["config", "alias"], testDir);
|
|
669
|
-
expect(stdout).toContain("api");
|
|
670
|
-
expect(stdout).toContain("packages/api");
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
test("renames project and updates references", async () => {
|
|
674
|
-
await run(["init", "-P", "old"], testDir);
|
|
675
|
-
|
|
676
|
-
// Create tasks with references
|
|
677
|
-
const { stdout: id1 } = await run(["add", "Task 1"], testDir);
|
|
678
|
-
const { stdout: id2 } = await run(["add", "Task 2"], testDir);
|
|
679
|
-
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
680
|
-
|
|
681
|
-
// Rename project
|
|
682
|
-
const { stdout, exitCode } = await run(
|
|
683
|
-
["config", "project", "new", "--rename", "old"],
|
|
684
|
-
testDir,
|
|
685
|
-
);
|
|
686
|
-
expect(exitCode).toBe(0);
|
|
687
|
-
expect(stdout).toContain("Renamed 2 tasks");
|
|
688
|
-
expect(stdout).toContain("old-*");
|
|
689
|
-
expect(stdout).toContain("new-*");
|
|
690
|
-
|
|
691
|
-
// Verify new IDs work
|
|
692
|
-
const newId1 = id1.trim().replace("old-", "new-");
|
|
693
|
-
const { exitCode: showCode } = await run(["show", newId1], testDir);
|
|
694
|
-
expect(showCode).toBe(0);
|
|
695
|
-
|
|
696
|
-
// Verify references updated
|
|
697
|
-
const { stdout: showOut } = await run(
|
|
698
|
-
["show", id2.trim().replace("old-", "new-"), "--json"],
|
|
699
|
-
testDir,
|
|
700
|
-
);
|
|
701
|
-
const task = JSON.parse(showOut);
|
|
702
|
-
expect(task.blocked_by[0]).toBe(newId1);
|
|
703
|
-
});
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
describe("error handling", () => {
|
|
707
|
-
describe("validation errors", () => {
|
|
708
|
-
test("invalid status shows valid options", async () => {
|
|
709
|
-
const { stderr, exitCode } = await run(["ls", "-s", "invalid"], testDir);
|
|
710
|
-
expect(exitCode).toBe(1);
|
|
711
|
-
expect(stderr).toContain("Invalid status");
|
|
712
|
-
expect(stderr).toContain("open");
|
|
713
|
-
expect(stderr).toContain("active");
|
|
714
|
-
expect(stderr).toContain("done");
|
|
715
|
-
});
|
|
716
|
-
|
|
717
|
-
test("invalid priority shows valid formats", async () => {
|
|
718
|
-
const { stderr, exitCode } = await run(["add", "Task", "-p", "p9"], testDir);
|
|
719
|
-
expect(exitCode).toBe(1);
|
|
720
|
-
expect(stderr).toContain("Invalid priority");
|
|
721
|
-
});
|
|
722
|
-
});
|
|
723
|
-
|
|
724
|
-
describe("missing arguments", () => {
|
|
725
|
-
test("show without ID", async () => {
|
|
726
|
-
const { stderr, exitCode } = await run(["show"], testDir);
|
|
727
|
-
expect(exitCode).toBe(1);
|
|
728
|
-
expect(stderr).toContain("ID required");
|
|
729
|
-
});
|
|
730
|
-
|
|
731
|
-
test("log without message", async () => {
|
|
732
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
733
|
-
const { stderr, exitCode } = await run(["log", id.trim()], testDir);
|
|
734
|
-
expect(exitCode).toBe(1);
|
|
735
|
-
expect(stderr).toContain("Message required");
|
|
736
|
-
});
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
describe("invalid ID format", () => {
|
|
740
|
-
test("rejects invalid ID format", async () => {
|
|
741
|
-
const { stderr, exitCode } = await run(["show", "invalid"], testDir);
|
|
742
|
-
expect(exitCode).toBe(1);
|
|
743
|
-
expect(stderr).toContain("not found");
|
|
744
|
-
});
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
describe("not found errors", () => {
|
|
748
|
-
test("show non-existent task", async () => {
|
|
749
|
-
const { stderr, exitCode } = await run(["show", "tk-999"], testDir);
|
|
750
|
-
expect(exitCode).toBe(1);
|
|
751
|
-
expect(stderr).toContain("Task not found");
|
|
752
|
-
});
|
|
753
|
-
});
|
|
754
|
-
});
|
|
755
|
-
|
|
756
|
-
describe("flag position flexibility", () => {
|
|
757
|
-
test("--json works after command", async () => {
|
|
758
|
-
await run(["add", "Task"], testDir);
|
|
759
|
-
const { stdout, exitCode } = await run(["ls", "--json"], testDir);
|
|
760
|
-
expect(exitCode).toBe(0);
|
|
761
|
-
const tasks = JSON.parse(stdout);
|
|
762
|
-
expect(Array.isArray(tasks)).toBe(true);
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
test("--json works before command", async () => {
|
|
766
|
-
await run(["add", "Task"], testDir);
|
|
767
|
-
const { stdout, exitCode } = await run(["--json", "ls"], testDir);
|
|
768
|
-
expect(exitCode).toBe(0);
|
|
769
|
-
const tasks = JSON.parse(stdout);
|
|
770
|
-
expect(Array.isArray(tasks)).toBe(true);
|
|
771
|
-
});
|
|
772
|
-
|
|
773
|
-
test("log requires quoted message", async () => {
|
|
774
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
775
|
-
const taskId = id.trim();
|
|
776
|
-
|
|
777
|
-
// Unquoted multiple words should error
|
|
778
|
-
const { stderr, exitCode } = await run(["log", taskId, "word1", "word2"], testDir);
|
|
779
|
-
expect(exitCode).toBe(1);
|
|
780
|
-
expect(stderr).toContain("must be quoted");
|
|
781
|
-
|
|
782
|
-
// Quoted message works (shell passes as single arg)
|
|
783
|
-
await run(["log", taskId, "Quoted message works"], testDir);
|
|
784
|
-
const { stdout } = await run(["show", "--json", taskId], testDir);
|
|
785
|
-
const task = JSON.parse(stdout);
|
|
786
|
-
expect(task.logs[0].msg).toBe("Quoted message works");
|
|
787
|
-
});
|
|
788
|
-
});
|
|
789
|
-
|
|
790
|
-
describe("ID resolution", () => {
|
|
791
|
-
test("can use just ref if unambiguous", async () => {
|
|
792
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
793
|
-
const ref = id.trim().split("-")[1] ?? "";
|
|
794
|
-
|
|
795
|
-
const { exitCode } = await run(["show", ref], testDir);
|
|
796
|
-
expect(exitCode).toBe(0);
|
|
797
|
-
});
|
|
798
|
-
|
|
799
|
-
test("full ID always works", async () => {
|
|
800
|
-
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
801
|
-
|
|
802
|
-
const { exitCode } = await run(["show", id.trim()], testDir);
|
|
803
|
-
expect(exitCode).toBe(0);
|
|
804
|
-
});
|
|
805
|
-
|
|
806
|
-
test("ambiguous ID shows matching tasks", async () => {
|
|
807
|
-
// Create tasks with same ref prefix in different projects
|
|
808
|
-
const { stdout: id1 } = await run(["add", "Task 1", "-P", "api"], testDir);
|
|
809
|
-
const { stdout: id2 } = await run(["add", "Task 2", "-P", "web"], testDir);
|
|
810
|
-
const ref1 = id1.trim().split("-")[1] ?? "";
|
|
811
|
-
const ref2 = id2.trim().split("-")[1] ?? "";
|
|
812
|
-
|
|
813
|
-
// Use first char which might match both
|
|
814
|
-
const prefix = ref1[0] ?? "";
|
|
815
|
-
|
|
816
|
-
// Only test if both refs start with same char (otherwise not ambiguous)
|
|
817
|
-
if (ref2.startsWith(prefix)) {
|
|
818
|
-
const { stderr, exitCode } = await run(["show", prefix], testDir);
|
|
819
|
-
expect(exitCode).toBe(1);
|
|
820
|
-
expect(stderr).toContain("Ambiguous");
|
|
821
|
-
expect(stderr).toContain("matches");
|
|
822
|
-
}
|
|
823
|
-
});
|
|
824
|
-
|
|
825
|
-
test("not found shows clear error", async () => {
|
|
826
|
-
const { stderr, exitCode } = await run(["show", "zzzz"], testDir);
|
|
827
|
-
expect(exitCode).toBe(1);
|
|
828
|
-
expect(stderr).toContain("not found");
|
|
829
|
-
});
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
describe("tk check", () => {
|
|
833
|
-
test("reports all OK when no issues", async () => {
|
|
834
|
-
await run(["add", "Task 1"], testDir);
|
|
835
|
-
await run(["add", "Task 2"], testDir);
|
|
836
|
-
|
|
837
|
-
const { stdout, exitCode } = await run(["check"], testDir);
|
|
838
|
-
expect(exitCode).toBe(0);
|
|
839
|
-
expect(stdout).toContain("2 tasks OK");
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
test("auto-fixes orphaned blocker reference", async () => {
|
|
843
|
-
// Create two tasks and block one by the other
|
|
844
|
-
const { stdout: id1 } = await run(["add", "Blocker"], testDir);
|
|
845
|
-
const { stdout: id2 } = await run(["add", "Blocked"], testDir);
|
|
846
|
-
const blockerId = id1.trim();
|
|
847
|
-
const blockedId = id2.trim();
|
|
848
|
-
|
|
849
|
-
await run(["block", blockedId, blockerId], testDir);
|
|
850
|
-
|
|
851
|
-
// Manually delete blocker file to simulate merge
|
|
852
|
-
const fs = await import("fs");
|
|
853
|
-
const path = await import("path");
|
|
854
|
-
const blockerFile = path.join(testDir, ".tasks", `${blockerId}.json`);
|
|
855
|
-
fs.unlinkSync(blockerFile);
|
|
856
|
-
|
|
857
|
-
// Check should fix it
|
|
858
|
-
const { stdout } = await run(["check"], testDir);
|
|
859
|
-
expect(stdout).toContain("cleaned");
|
|
860
|
-
expect(stdout).toContain("orphaned");
|
|
861
|
-
|
|
862
|
-
// Verify the blocked_by is now empty
|
|
863
|
-
const { stdout: showOut } = await run(["show", "--json", blockedId], testDir);
|
|
864
|
-
const task = JSON.parse(showOut);
|
|
865
|
-
expect(task.blocked_by).toEqual([]);
|
|
866
|
-
});
|
|
867
|
-
|
|
868
|
-
test("auto-fixes orphaned parent reference", async () => {
|
|
869
|
-
// Create parent and child
|
|
870
|
-
const { stdout: parentId } = await run(["add", "Parent"], testDir);
|
|
871
|
-
const parent = parentId.trim();
|
|
872
|
-
const { stdout: childId } = await run(["add", "Child", "--parent", parent], testDir);
|
|
873
|
-
const child = childId.trim();
|
|
874
|
-
|
|
875
|
-
// Manually delete parent file
|
|
876
|
-
const fs = await import("fs");
|
|
877
|
-
const path = await import("path");
|
|
878
|
-
const parentFile = path.join(testDir, ".tasks", `${parent}.json`);
|
|
879
|
-
fs.unlinkSync(parentFile);
|
|
880
|
-
|
|
881
|
-
// Check should fix it
|
|
882
|
-
const { stdout } = await run(["check"], testDir);
|
|
883
|
-
expect(stdout).toContain("cleaned");
|
|
884
|
-
expect(stdout).toContain("orphaned parent");
|
|
885
|
-
|
|
886
|
-
// Verify parent is now null
|
|
887
|
-
const { stdout: showOut } = await run(["show", "--json", child], testDir);
|
|
888
|
-
const task = JSON.parse(showOut);
|
|
889
|
-
expect(task.parent).toBeNull();
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
test("auto-fix happens on show command", async () => {
|
|
893
|
-
// Create two tasks and block one
|
|
894
|
-
const { stdout: id1 } = await run(["add", "Blocker"], testDir);
|
|
895
|
-
const { stdout: id2 } = await run(["add", "Blocked"], testDir);
|
|
896
|
-
const blockerId = id1.trim();
|
|
897
|
-
const blockedId = id2.trim();
|
|
898
|
-
|
|
899
|
-
await run(["block", blockedId, blockerId], testDir);
|
|
900
|
-
|
|
901
|
-
// Manually delete blocker file
|
|
902
|
-
const fs = await import("fs");
|
|
903
|
-
const path = await import("path");
|
|
904
|
-
const blockerFile = path.join(testDir, ".tasks", `${blockerId}.json`);
|
|
905
|
-
fs.unlinkSync(blockerFile);
|
|
906
|
-
|
|
907
|
-
// Show should auto-fix and output cleanup message
|
|
908
|
-
const { stderr } = await run(["show", blockedId], testDir);
|
|
909
|
-
expect(stderr).toContain("cleaned");
|
|
910
|
-
expect(stderr).toContain("orphaned");
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
test("reports unfixable corrupted JSON", async () => {
|
|
914
|
-
await run(["add", "Good task"], testDir);
|
|
915
|
-
|
|
916
|
-
// Create a corrupted JSON file
|
|
917
|
-
const fs = await import("fs");
|
|
918
|
-
const path = await import("path");
|
|
919
|
-
const corruptFile = path.join(testDir, ".tasks", "test-bad1.json");
|
|
920
|
-
fs.writeFileSync(corruptFile, "{ invalid json");
|
|
921
|
-
|
|
922
|
-
const { stdout } = await run(["check"], testDir);
|
|
923
|
-
expect(stdout).toContain("Unfixable");
|
|
924
|
-
expect(stdout).toContain("test-bad1.json");
|
|
925
|
-
});
|
|
926
|
-
|
|
927
|
-
test("reports unfixable invalid task structure", async () => {
|
|
928
|
-
await run(["add", "Good task"], testDir);
|
|
929
|
-
|
|
930
|
-
// Create valid JSON but invalid task structure
|
|
931
|
-
const fs = await import("fs");
|
|
932
|
-
const path = await import("path");
|
|
933
|
-
const badFile = path.join(testDir, ".tasks", "test-bad2.json");
|
|
934
|
-
fs.writeFileSync(badFile, '{"foo": "bar"}');
|
|
935
|
-
|
|
936
|
-
const { stdout } = await run(["check"], testDir);
|
|
937
|
-
expect(stdout).toContain("Unfixable");
|
|
938
|
-
expect(stdout).toContain("test-bad2.json");
|
|
939
|
-
expect(stdout).toContain("Invalid task structure");
|
|
940
|
-
});
|
|
941
|
-
|
|
942
|
-
test("auto-fixes ID mismatch (filename vs content)", async () => {
|
|
943
|
-
// Create a task
|
|
944
|
-
const { stdout: id } = await run(["add", "Test task", "-P", "api"], testDir);
|
|
945
|
-
const taskId = id.trim();
|
|
946
|
-
|
|
947
|
-
// Manually rename the file to create a mismatch
|
|
948
|
-
const fs = await import("fs");
|
|
949
|
-
const path = await import("path");
|
|
950
|
-
const oldPath = path.join(testDir, ".tasks", `${taskId}.json`);
|
|
951
|
-
const newPath = path.join(testDir, ".tasks", "web-x1y2.json");
|
|
952
|
-
fs.renameSync(oldPath, newPath);
|
|
953
|
-
|
|
954
|
-
// Check should fix it
|
|
955
|
-
const { stdout } = await run(["check"], testDir);
|
|
956
|
-
expect(stdout).toContain("cleaned");
|
|
957
|
-
expect(stdout).toContain("ID mismatch");
|
|
958
|
-
|
|
959
|
-
// Verify the content was updated to match filename
|
|
960
|
-
const { stdout: showOut } = await run(["show", "--json", "web-x1y2"], testDir);
|
|
961
|
-
const task = JSON.parse(showOut);
|
|
962
|
-
expect(task.project).toBe("web");
|
|
963
|
-
expect(task.ref).toBe("x1y2");
|
|
964
|
-
});
|
|
965
|
-
|
|
966
|
-
test("check --json returns structured output", async () => {
|
|
967
|
-
await run(["add", "Task"], testDir);
|
|
968
|
-
|
|
969
|
-
const { stdout } = await run(["check", "--json"], testDir);
|
|
970
|
-
const result = JSON.parse(stdout);
|
|
971
|
-
expect(result).toHaveProperty("totalTasks");
|
|
972
|
-
expect(result).toHaveProperty("cleaned");
|
|
973
|
-
expect(result).toHaveProperty("unfixable");
|
|
974
|
-
});
|
|
975
|
-
});
|
|
976
|
-
});
|