@nijaru/tk 0.0.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/LICENSE +21 -0
- package/README.md +229 -0
- package/package.json +47 -0
- package/src/cli.test.ts +636 -0
- package/src/cli.ts +871 -0
- package/src/db/storage.ts +777 -0
- package/src/lib/completions.ts +418 -0
- package/src/lib/format.test.ts +347 -0
- package/src/lib/format.ts +162 -0
- package/src/lib/priority.test.ts +105 -0
- package/src/lib/priority.ts +40 -0
- package/src/lib/root.ts +79 -0
- package/src/types.ts +130 -0
package/src/cli.test.ts
ADDED
|
@@ -0,0 +1,636 @@
|
|
|
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
|
+
|
|
248
|
+
describe("ready", () => {
|
|
249
|
+
test("shows only unblocked open tasks", async () => {
|
|
250
|
+
const { stdout: id1 } = await run(["add", "Ready task"], testDir);
|
|
251
|
+
const { stdout: id2 } = await run(["add", "Blocked task"], testDir);
|
|
252
|
+
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
253
|
+
|
|
254
|
+
const { stdout } = await run(["ready"], testDir);
|
|
255
|
+
expect(stdout).toContain("Ready task");
|
|
256
|
+
expect(stdout).not.toContain("Blocked task");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("shows blocked task after blocker is done", async () => {
|
|
260
|
+
const { stdout: id1 } = await run(["add", "Blocker"], testDir);
|
|
261
|
+
const { stdout: id2 } = await run(["add", "Blocked"], testDir);
|
|
262
|
+
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
263
|
+
await run(["done", id1.trim()], testDir);
|
|
264
|
+
|
|
265
|
+
const { stdout } = await run(["ready"], testDir);
|
|
266
|
+
expect(stdout).toContain("Blocked");
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("status transitions", () => {
|
|
271
|
+
test("start: open -> active", async () => {
|
|
272
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
273
|
+
await run(["start", id.trim()], testDir);
|
|
274
|
+
|
|
275
|
+
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
276
|
+
const task = JSON.parse(stdout);
|
|
277
|
+
expect(task.status).toBe("active");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("done: any -> done", async () => {
|
|
281
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
282
|
+
await run(["done", id.trim()], testDir);
|
|
283
|
+
|
|
284
|
+
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
285
|
+
const task = JSON.parse(stdout);
|
|
286
|
+
expect(task.status).toBe("done");
|
|
287
|
+
expect(task.completed_at).not.toBeNull();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("reopen: done -> open", async () => {
|
|
291
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
292
|
+
await run(["done", id.trim()], testDir);
|
|
293
|
+
await run(["reopen", id.trim()], testDir);
|
|
294
|
+
|
|
295
|
+
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
296
|
+
const task = JSON.parse(stdout);
|
|
297
|
+
expect(task.status).toBe("open");
|
|
298
|
+
expect(task.completed_at).toBeNull();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("start errors if not open", async () => {
|
|
302
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
303
|
+
await run(["start", id.trim()], testDir);
|
|
304
|
+
|
|
305
|
+
const { stderr, exitCode } = await run(["start", id.trim()], testDir);
|
|
306
|
+
expect(exitCode).toBe(1);
|
|
307
|
+
expect(stderr).toContain("not open");
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("edit", () => {
|
|
312
|
+
test("updates title", async () => {
|
|
313
|
+
const { stdout: id } = await run(["add", "Original"], testDir);
|
|
314
|
+
await run(["edit", id.trim(), "-t", "Updated"], testDir);
|
|
315
|
+
|
|
316
|
+
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
317
|
+
const task = JSON.parse(stdout);
|
|
318
|
+
expect(task.title).toBe("Updated");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("updates priority", async () => {
|
|
322
|
+
const { stdout: id } = await run(["add", "Task", "-p", "3"], testDir);
|
|
323
|
+
await run(["edit", id.trim(), "-p", "1"], testDir);
|
|
324
|
+
|
|
325
|
+
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
326
|
+
const task = JSON.parse(stdout);
|
|
327
|
+
expect(task.priority).toBe(1);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("adds label with +", async () => {
|
|
331
|
+
const { stdout: id } = await run(["add", "Task", "-l", "bug"], testDir);
|
|
332
|
+
await run(["edit", id.trim(), "-l", "+urgent"], testDir);
|
|
333
|
+
|
|
334
|
+
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
335
|
+
const task = JSON.parse(stdout);
|
|
336
|
+
expect(task.labels).toContain("bug");
|
|
337
|
+
expect(task.labels).toContain("urgent");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("removes label with -", async () => {
|
|
341
|
+
const { stdout: id } = await run(["add", "Task", "-l", "bug,urgent"], testDir);
|
|
342
|
+
// Use --labels= format to avoid -bug being parsed as flag
|
|
343
|
+
await run(["edit", id.trim(), "--labels=-bug"], testDir);
|
|
344
|
+
|
|
345
|
+
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
346
|
+
const task = JSON.parse(stdout);
|
|
347
|
+
expect(task.labels).not.toContain("bug");
|
|
348
|
+
expect(task.labels).toContain("urgent");
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("prevents duplicate labels with +", async () => {
|
|
352
|
+
const { stdout: id } = await run(["add", "Task", "-l", "bug"], testDir);
|
|
353
|
+
await run(["edit", id.trim(), "-l", "+bug"], testDir);
|
|
354
|
+
|
|
355
|
+
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
356
|
+
const task = JSON.parse(stdout);
|
|
357
|
+
expect(task.labels).toEqual(["bug"]);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("errors on self-referential parent", async () => {
|
|
361
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
362
|
+
const { stderr, exitCode } = await run(["edit", id.trim(), "--parent", id.trim()], testDir);
|
|
363
|
+
expect(exitCode).toBe(1);
|
|
364
|
+
expect(stderr).toContain("cannot be its own parent");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("errors on parent cycle", async () => {
|
|
368
|
+
const { stdout: id1 } = await run(["add", "Parent"], testDir);
|
|
369
|
+
const { stdout: id2 } = await run(["add", "Child", "--parent", id1.trim()], testDir);
|
|
370
|
+
const { stderr, exitCode } = await run(["edit", id1.trim(), "--parent", id2.trim()], testDir);
|
|
371
|
+
expect(exitCode).toBe(1);
|
|
372
|
+
expect(stderr).toContain("circular parent");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe("log", () => {
|
|
377
|
+
test("adds log entry", async () => {
|
|
378
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
379
|
+
await run(["log", id.trim(), "First entry"], testDir);
|
|
380
|
+
await run(["log", id.trim(), "Second entry"], testDir);
|
|
381
|
+
|
|
382
|
+
const { stdout } = await run(["show", id.trim(), "--json"], testDir);
|
|
383
|
+
const task = JSON.parse(stdout);
|
|
384
|
+
expect(task.logs.length).toBe(2);
|
|
385
|
+
expect(task.logs[0].msg).toBe("First entry");
|
|
386
|
+
expect(task.logs[1].msg).toBe("Second entry");
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
describe("block/unblock", () => {
|
|
391
|
+
test("block prevents task from being ready", async () => {
|
|
392
|
+
const { stdout: id1 } = await run(["add", "Blocker"], testDir);
|
|
393
|
+
const { stdout: id2 } = await run(["add", "Blocked"], testDir);
|
|
394
|
+
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
395
|
+
|
|
396
|
+
const { stdout } = await run(["show", id2.trim(), "--json"], testDir);
|
|
397
|
+
const task = JSON.parse(stdout);
|
|
398
|
+
expect(task.blocked_by).toContain(id1.trim());
|
|
399
|
+
expect(task.blocked_by_incomplete).toBe(true);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("unblock removes dependency", async () => {
|
|
403
|
+
const { stdout: id1 } = await run(["add", "Blocker"], testDir);
|
|
404
|
+
const { stdout: id2 } = await run(["add", "Blocked"], testDir);
|
|
405
|
+
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
406
|
+
await run(["unblock", id2.trim(), id1.trim()], testDir);
|
|
407
|
+
|
|
408
|
+
const { stdout } = await run(["show", id2.trim(), "--json"], testDir);
|
|
409
|
+
const task = JSON.parse(stdout);
|
|
410
|
+
expect(task.blocked_by).not.toContain(id1.trim());
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("cannot block self", async () => {
|
|
414
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
415
|
+
const { stderr, exitCode } = await run(["block", id.trim(), id.trim()], testDir);
|
|
416
|
+
expect(exitCode).toBe(1);
|
|
417
|
+
expect(stderr).toContain("cannot block itself");
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("validates blocker exists", async () => {
|
|
421
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
422
|
+
const { stderr, exitCode } = await run(["block", id.trim(), "tk-999"], testDir);
|
|
423
|
+
expect(exitCode).toBe(1);
|
|
424
|
+
expect(stderr).toContain("not found");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("detects circular dependencies", async () => {
|
|
428
|
+
const { stdout: id1 } = await run(["add", "Task A"], testDir);
|
|
429
|
+
const { stdout: id2 } = await run(["add", "Task B"], testDir);
|
|
430
|
+
const { stdout: id3 } = await run(["add", "Task C"], testDir);
|
|
431
|
+
|
|
432
|
+
await run(["block", id1.trim(), id2.trim()], testDir);
|
|
433
|
+
await run(["block", id2.trim(), id3.trim()], testDir);
|
|
434
|
+
|
|
435
|
+
const { stderr, exitCode } = await run(["block", id3.trim(), id1.trim()], testDir);
|
|
436
|
+
expect(exitCode).toBe(1);
|
|
437
|
+
expect(stderr).toContain("circular");
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
describe("rm", () => {
|
|
442
|
+
test("deletes task", async () => {
|
|
443
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
444
|
+
await run(["rm", id.trim()], testDir);
|
|
445
|
+
|
|
446
|
+
const { stderr, exitCode } = await run(["show", id.trim()], testDir);
|
|
447
|
+
expect(exitCode).toBe(1);
|
|
448
|
+
expect(stderr).toContain("not found");
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("removes associated blocks", async () => {
|
|
452
|
+
const { stdout: id1 } = await run(["add", "Blocker"], testDir);
|
|
453
|
+
const { stdout: id2 } = await run(["add", "Blocked"], testDir);
|
|
454
|
+
await run(["block", id2.trim(), id1.trim()], testDir);
|
|
455
|
+
await run(["rm", id1.trim()], testDir);
|
|
456
|
+
|
|
457
|
+
const { stdout } = await run(["show", id2.trim(), "--json"], testDir);
|
|
458
|
+
const task = JSON.parse(stdout);
|
|
459
|
+
expect(task.blocked_by).not.toContain(id1.trim());
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
describe("clean", () => {
|
|
464
|
+
test("removes completed tasks", async () => {
|
|
465
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
466
|
+
await run(["done", id.trim()], testDir);
|
|
467
|
+
await run(["clean", "--all"], testDir);
|
|
468
|
+
|
|
469
|
+
const { exitCode } = await run(["show", id.trim()], testDir);
|
|
470
|
+
expect(exitCode).toBe(1);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("keeps open tasks", async () => {
|
|
474
|
+
const { stdout: id } = await run(["add", "Open task"], testDir);
|
|
475
|
+
await run(["clean", "--all"], testDir);
|
|
476
|
+
|
|
477
|
+
const { exitCode } = await run(["show", id.trim()], testDir);
|
|
478
|
+
expect(exitCode).toBe(0);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe("init", () => {
|
|
483
|
+
test("creates .tasks directory", async () => {
|
|
484
|
+
const newDir = mkdtempSync(join(tmpdir(), "tk-init-"));
|
|
485
|
+
Bun.spawnSync(["git", "init"], { cwd: newDir });
|
|
486
|
+
|
|
487
|
+
const { stdout, exitCode } = await run(["init"], newDir);
|
|
488
|
+
expect(exitCode).toBe(0);
|
|
489
|
+
expect(stdout).toContain("Initialized");
|
|
490
|
+
expect(existsSync(join(newDir, ".tasks"))).toBe(true);
|
|
491
|
+
|
|
492
|
+
rmSync(newDir, { recursive: true, force: true });
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("creates .tasks with custom project", async () => {
|
|
496
|
+
const newDir = mkdtempSync(join(tmpdir(), "tk-init-"));
|
|
497
|
+
Bun.spawnSync(["git", "init"], { cwd: newDir });
|
|
498
|
+
|
|
499
|
+
await run(["init", "-P", "api"], newDir);
|
|
500
|
+
const { stdout } = await run(["add", "Task"], newDir);
|
|
501
|
+
expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
|
|
502
|
+
|
|
503
|
+
rmSync(newDir, { recursive: true, force: true });
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("reports already initialized", async () => {
|
|
507
|
+
await run(["init"], testDir);
|
|
508
|
+
const { stdout } = await run(["init"], testDir);
|
|
509
|
+
expect(stdout).toContain("Already initialized");
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe("config", () => {
|
|
514
|
+
test("shows config", async () => {
|
|
515
|
+
await run(["init"], testDir);
|
|
516
|
+
const { stdout } = await run(["config"], testDir);
|
|
517
|
+
expect(stdout).toContain("Version:");
|
|
518
|
+
expect(stdout).toContain("Project:");
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("shows default project derived from directory", async () => {
|
|
522
|
+
await run(["init"], testDir);
|
|
523
|
+
const { stdout } = await run(["config", "project"], testDir);
|
|
524
|
+
// Project is derived from directory name (tktest... from temp dir)
|
|
525
|
+
expect(stdout).toMatch(/^tktest[a-z0-9]+$/);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test("respects explicit project name", async () => {
|
|
529
|
+
await run(["init", "--project", "myapp"], testDir);
|
|
530
|
+
const { stdout } = await run(["config", "project"], testDir);
|
|
531
|
+
expect(stdout).toBe("myapp");
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("sets default project", async () => {
|
|
535
|
+
await run(["init"], testDir);
|
|
536
|
+
await run(["config", "project", "api"], testDir);
|
|
537
|
+
|
|
538
|
+
const { stdout } = await run(["add", "Task"], testDir);
|
|
539
|
+
expect(stdout).toMatch(/^api-[a-z0-9]{4}$/);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
test("manages aliases", async () => {
|
|
543
|
+
await run(["init"], testDir);
|
|
544
|
+
await run(["config", "alias", "api", "packages/api"], testDir);
|
|
545
|
+
|
|
546
|
+
const { stdout } = await run(["config", "alias"], testDir);
|
|
547
|
+
expect(stdout).toContain("api");
|
|
548
|
+
expect(stdout).toContain("packages/api");
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
describe("error handling", () => {
|
|
553
|
+
describe("validation errors", () => {
|
|
554
|
+
test("invalid status shows valid options", async () => {
|
|
555
|
+
const { stderr, exitCode } = await run(["ls", "-s", "invalid"], testDir);
|
|
556
|
+
expect(exitCode).toBe(1);
|
|
557
|
+
expect(stderr).toContain("Invalid status");
|
|
558
|
+
expect(stderr).toContain("open");
|
|
559
|
+
expect(stderr).toContain("active");
|
|
560
|
+
expect(stderr).toContain("done");
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("invalid priority shows valid formats", async () => {
|
|
564
|
+
const { stderr, exitCode } = await run(["add", "Task", "-p", "p9"], testDir);
|
|
565
|
+
expect(exitCode).toBe(1);
|
|
566
|
+
expect(stderr).toContain("Invalid priority");
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
describe("missing arguments", () => {
|
|
571
|
+
test("show without ID", async () => {
|
|
572
|
+
const { stderr, exitCode } = await run(["show"], testDir);
|
|
573
|
+
expect(exitCode).toBe(1);
|
|
574
|
+
expect(stderr).toContain("ID required");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("log without message", async () => {
|
|
578
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
579
|
+
const { stderr, exitCode } = await run(["log", id.trim()], testDir);
|
|
580
|
+
expect(exitCode).toBe(1);
|
|
581
|
+
expect(stderr).toContain("Message required");
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
describe("invalid ID format", () => {
|
|
586
|
+
test("rejects invalid ID format", async () => {
|
|
587
|
+
const { stderr, exitCode } = await run(["show", "invalid"], testDir);
|
|
588
|
+
expect(exitCode).toBe(1);
|
|
589
|
+
expect(stderr).toContain("Invalid task ID");
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
describe("not found errors", () => {
|
|
594
|
+
test("show non-existent task", async () => {
|
|
595
|
+
const { stderr, exitCode } = await run(["show", "tk-999"], testDir);
|
|
596
|
+
expect(exitCode).toBe(1);
|
|
597
|
+
expect(stderr).toContain("Task not found");
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
describe("flag position flexibility", () => {
|
|
603
|
+
test("--json works after command", async () => {
|
|
604
|
+
await run(["add", "Task"], testDir);
|
|
605
|
+
const { stdout, exitCode } = await run(["ls", "--json"], testDir);
|
|
606
|
+
expect(exitCode).toBe(0);
|
|
607
|
+
const tasks = JSON.parse(stdout);
|
|
608
|
+
expect(Array.isArray(tasks)).toBe(true);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("--json works before command", async () => {
|
|
612
|
+
await run(["add", "Task"], testDir);
|
|
613
|
+
const { stdout, exitCode } = await run(["--json", "ls"], testDir);
|
|
614
|
+
expect(exitCode).toBe(0);
|
|
615
|
+
const tasks = JSON.parse(stdout);
|
|
616
|
+
expect(Array.isArray(tasks)).toBe(true);
|
|
617
|
+
});
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
describe("ID resolution", () => {
|
|
621
|
+
test("can use just ref if unambiguous", async () => {
|
|
622
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
623
|
+
const ref = id.trim().split("-")[1] ?? "";
|
|
624
|
+
|
|
625
|
+
const { exitCode } = await run(["show", ref], testDir);
|
|
626
|
+
expect(exitCode).toBe(0);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
test("full ID always works", async () => {
|
|
630
|
+
const { stdout: id } = await run(["add", "Task"], testDir);
|
|
631
|
+
|
|
632
|
+
const { exitCode } = await run(["show", id.trim()], testDir);
|
|
633
|
+
expect(exitCode).toBe(0);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
});
|