main
1/**
2 * Tests for org-todos Pi extension
3 *
4 * Run with: bun test dots/pi/agent/extensions/org-todos/index.test.ts
5 *
6 * Note: Integration tests require Emacs daemon running with pi-org-todos.el loaded.
7 * Unit tests can run without Emacs.
8 */
9
10import { describe, test, expect, beforeAll, afterAll } from "bun:test";
11import { execSync } from "node:child_process";
12import { writeFileSync, unlinkSync, readFileSync, existsSync } from "node:fs";
13import { join } from "node:path";
14import { tmpdir } from "node:os";
15import * as chrono from "chrono-node";
16
17// Test org file content
18const TEST_ORG_CONTENT = `#+title: Test TODOs
19
20* Work
21
22** TODO [#2] Review PR for pipeline
23SCHEDULED: <2026-02-06 Fri>
24:PROPERTIES:
25:CREATED: [2026-02-01 Mon]
26:END:
27
28This is a test TODO with some content.
29
30** NEXT [#1] Fix CI/CD issue :urgent:
31DEADLINE: <2026-02-05 Thu>
32
33** STRT Write documentation
34:PROPERTIES:
35:CREATED: [2026-02-03 Wed]
36:END:
37
38** WAIT Waiting for approval :blocked:
39:PROPERTIES:
40:BLOCKER: Review PR for pipeline
41:END:
42
43** DONE Completed task
44CLOSED: [2026-02-04 Thu 10:00]
45
46* Projects
47
48** TODO [#3] Homelab migration
49SCHEDULED: <2026-02-10 Wed>
50
51** TODO NixOS refactoring :nixos:homelab:
52
53* Personal
54
55** TODO Buy groceries
56SCHEDULED: <2026-02-06 Fri>
57`;
58
59// Test file path
60const TEST_FILE = join(tmpdir(), "pi-org-todos-test.org");
61
62// Helper to check if Emacs daemon is running
63function isEmacsDaemonRunning(): boolean {
64 try {
65 execSync("emacsclient --eval '(+ 1 1)'", { stdio: "pipe" });
66 return true;
67 } catch {
68 return false;
69 }
70}
71
72// Helper to execute elisp and parse result
73function execEmacs(elisp: string): any {
74 const escaped = elisp.replace(/'/g, "'\\''");
75 const result = execSync(`emacsclient --eval '${escaped}'`, {
76 encoding: "utf-8",
77 timeout: 10000,
78 });
79
80 let jsonStr = result.trim();
81 if (jsonStr.startsWith('"') && jsonStr.endsWith('"')) {
82 jsonStr = jsonStr.slice(1, -1);
83 }
84 jsonStr = jsonStr.replace(/\\"/g, '"').replace(/\\\\/g, "\\");
85
86 return JSON.parse(jsonStr);
87}
88
89// Check if we can run integration tests
90const canRunIntegrationTests = isEmacsDaemonRunning();
91
92// ============================
93// UNIT TESTS (no Emacs needed)
94// ============================
95
96describe("org-todos extension", () => {
97 describe("unit tests", () => {
98 // --- stripOrgLinks ---
99 describe("stripOrgLinks", () => {
100 // Import logic inline since the function is not exported
101 function stripOrgLinks(text: string): string {
102 text = text.replace(/\[\[([^\]]*)\]\[([^\]]*)\]\]/g, "$2");
103 text = text.replace(/\[\[([^\]]*)\]\]/g, "$1");
104 return text;
105 }
106
107 test("strips [[url][title]] to title", () => {
108 expect(stripOrgLinks("See [[https://example.com][Example]]")).toBe("See Example");
109 });
110
111 test("strips [[url]] to url", () => {
112 expect(stripOrgLinks("See [[https://example.com]]")).toBe("See https://example.com");
113 });
114
115 test("handles multiple links", () => {
116 expect(stripOrgLinks("[[a][A]] and [[b][B]]")).toBe("A and B");
117 });
118
119 test("handles text with no links", () => {
120 expect(stripOrgLinks("No links here")).toBe("No links here");
121 });
122
123 test("handles empty string", () => {
124 expect(stripOrgLinks("")).toBe("");
125 });
126
127 test("handles nested brackets", () => {
128 expect(stripOrgLinks("[[file:todos.org::*Heading][Heading]]")).toBe("Heading");
129 });
130 });
131
132 // --- formatTodo ---
133 describe("formatTodo", () => {
134 function formatTodo(todo: any): string {
135 const parts: string[] = [];
136 const state = todo.todo || "TODO";
137 parts.push(`[${state}]`);
138 if (todo.priority) parts.push(`[#${todo.priority}]`);
139 parts.push(todo.heading);
140 if (todo.tags && todo.tags.length > 0) parts.push(`:${todo.tags.join(":")}:`);
141 const dates: string[] = [];
142 if (todo.scheduled) dates.push(`SCHEDULED: ${todo.scheduled}`);
143 if (todo.deadline) dates.push(`DEADLINE: ${todo.deadline}`);
144 if (dates.length > 0) parts.push(`(${dates.join(", ")})`);
145 return parts.join(" ");
146 }
147
148 test("formats basic TODO", () => {
149 const result = formatTodo({ todo: "TODO", heading: "Test", priority: 2, tags: ["work"], scheduled: "<2026-02-06>" });
150 expect(result).toContain("[TODO]");
151 expect(result).toContain("[#2]");
152 expect(result).toContain("Test");
153 expect(result).toContain(":work:");
154 expect(result).toContain("SCHEDULED:");
155 });
156
157 test("formats minimal TODO", () => {
158 expect(formatTodo({ todo: "NEXT", heading: "Simple" })).toBe("[NEXT] Simple");
159 });
160
161 test("handles null state", () => {
162 expect(formatTodo({ heading: "No state" })).toBe("[TODO] No state");
163 });
164
165 test("formats with deadline only", () => {
166 const result = formatTodo({ todo: "TODO", heading: "Task", deadline: "<2026-03-01>" });
167 expect(result).toContain("DEADLINE:");
168 expect(result).not.toContain("SCHEDULED:");
169 });
170
171 test("formats with both scheduled and deadline", () => {
172 const result = formatTodo({ todo: "TODO", heading: "Task", scheduled: "<2026-02-06>", deadline: "<2026-03-01>" });
173 expect(result).toContain("SCHEDULED:");
174 expect(result).toContain("DEADLINE:");
175 });
176
177 test("formats with multiple tags", () => {
178 const result = formatTodo({ todo: "TODO", heading: "Task", tags: ["work", "urgent", "review"] });
179 expect(result).toContain(":work:urgent:review:");
180 });
181
182 test("formats with empty tags array", () => {
183 const result = formatTodo({ todo: "TODO", heading: "Task", tags: [] });
184 expect(result).not.toContain(":");
185 });
186 });
187
188 // --- formatTodoMarkdown ---
189 describe("formatTodoMarkdown", () => {
190 function stripOrgLinks(text: string): string {
191 text = text.replace(/\[\[([^\]]*)\]\[([^\]]*)\]\]/g, "$2");
192 text = text.replace(/\[\[([^\]]*)\]\]/g, "$1");
193 return text;
194 }
195
196 function formatTodoMarkdown(todo: any): string {
197 const parts: string[] = [];
198 const state = todo.todo || "TODO";
199 parts.push(`**[${state}]**`);
200 if (todo.priority) parts.push(`\`#${todo.priority}\``);
201 parts.push(stripOrgLinks(todo.heading));
202 if (todo.tags && todo.tags.length > 0) {
203 const tagStr = todo.tags.map((t: string) => `\`${t}\``).join(" ");
204 parts.push(tagStr);
205 }
206 const dates: string[] = [];
207 if (todo.scheduled) dates.push(`📅 ${todo.scheduled}`);
208 if (todo.deadline) dates.push(`⏰ ${todo.deadline}`);
209 let result = parts.join(" ");
210 if (dates.length > 0) result += ` *(${dates.join(", ")})*`;
211 return result;
212 }
213
214 test("wraps state in bold", () => {
215 const result = formatTodoMarkdown({ todo: "NEXT", heading: "Task" });
216 expect(result).toContain("**[NEXT]**");
217 });
218
219 test("formats priority as inline code", () => {
220 const result = formatTodoMarkdown({ todo: "TODO", heading: "Task", priority: 1 });
221 expect(result).toContain("`#1`");
222 });
223
224 test("formats tags as inline code", () => {
225 const result = formatTodoMarkdown({ todo: "TODO", heading: "Task", tags: ["urgent"] });
226 expect(result).toContain("`urgent`");
227 });
228
229 test("strips org links in heading", () => {
230 const result = formatTodoMarkdown({ todo: "TODO", heading: "See [[https://example.com][docs]]" });
231 expect(result).toContain("See docs");
232 expect(result).not.toContain("[[");
233 });
234
235 test("formats dates with emoji", () => {
236 const result = formatTodoMarkdown({ todo: "TODO", heading: "Task", scheduled: "<2026-02-06>", deadline: "<2026-03-01>" });
237 expect(result).toContain("📅");
238 expect(result).toContain("⏰");
239 });
240 });
241
242 // --- parseNaturalDate ---
243 describe("parseNaturalDate", () => {
244 function parseNaturalDate(text: string): string | null {
245 const result = chrono.parseDate(text);
246 if (!result) return null;
247 const year = result.getFullYear();
248 const month = String(result.getMonth() + 1).padStart(2, "0");
249 const day = String(result.getDate()).padStart(2, "0");
250 return `${year}-${month}-${day}`;
251 }
252
253 test("parses 'tomorrow'", () => {
254 const result = parseNaturalDate("tomorrow");
255 expect(result).toBeDefined();
256 const tomorrow = new Date();
257 tomorrow.setDate(tomorrow.getDate() + 1);
258 const expected = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, "0")}-${String(tomorrow.getDate()).padStart(2, "0")}`;
259 expect(result).toBe(expected);
260 });
261
262 test("parses 'next friday'", () => {
263 const result = parseNaturalDate("next friday");
264 expect(result).toBeDefined();
265 expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
266 });
267
268 test("parses ISO date", () => {
269 const result = parseNaturalDate("2026-03-15");
270 expect(result).toBe("2026-03-15");
271 });
272
273 test("parses 'in 3 days'", () => {
274 const result = parseNaturalDate("in 3 days");
275 expect(result).toBeDefined();
276 });
277
278 test("returns null for invalid date", () => {
279 const result = parseNaturalDate("not a date zzzzz");
280 expect(result).toBeNull();
281 });
282 });
283
284 // --- parseCommandArgs ---
285 describe("parseCommandArgs", () => {
286 function parseNaturalDate(text: string): string | null {
287 const result = chrono.parseDate(text);
288 if (!result) return null;
289 const year = result.getFullYear();
290 const month = String(result.getMonth() + 1).padStart(2, "0");
291 const day = String(result.getDate()).padStart(2, "0");
292 return `${year}-${month}-${day}`;
293 }
294
295 function parseCommandArgs(args: string) {
296 let remaining = args;
297 let section: string | undefined;
298 let scheduled: string | undefined;
299 let deadline: string | undefined;
300 let priority: number | undefined;
301 let state: string | undefined;
302
303 const sectionMatch = remaining.match(/@(\w+)/);
304 if (sectionMatch) {
305 section = sectionMatch[1];
306 remaining = remaining.replace(/@\w+/, "").trim();
307 }
308
309 const scheduledMatch = remaining.match(/scheduled:([^\s]+(?:\s+[^\s@:]+)*?)(?=\s+(?:deadline:|priority:|state:|@|$)|$)/i);
310 if (scheduledMatch) {
311 const dateStr = scheduledMatch[1].trim();
312 scheduled = parseNaturalDate(dateStr) || dateStr;
313 remaining = remaining.replace(scheduledMatch[0], "").trim();
314 }
315
316 const deadlineMatch = remaining.match(/deadline:([^\s]+(?:\s+[^\s@:]+)*?)(?=\s+(?:scheduled:|priority:|state:|@|$)|$)/i);
317 if (deadlineMatch) {
318 const dateStr = deadlineMatch[1].trim();
319 deadline = parseNaturalDate(dateStr) || dateStr;
320 remaining = remaining.replace(deadlineMatch[0], "").trim();
321 }
322
323 const priorityMatch = remaining.match(/priority:(\d)/i);
324 if (priorityMatch) {
325 priority = parseInt(priorityMatch[1], 10);
326 remaining = remaining.replace(priorityMatch[0], "").trim();
327 }
328
329 const stateMatch = remaining.match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
330 if (stateMatch) {
331 state = stateMatch[1].toUpperCase();
332 remaining = remaining.replace(stateMatch[0], "").trim();
333 }
334
335 return { title: remaining.trim(), section, scheduled, deadline, priority, state };
336 }
337
338 test("parses title only", () => {
339 const result = parseCommandArgs("Buy groceries");
340 expect(result.title).toBe("Buy groceries");
341 expect(result.section).toBeUndefined();
342 expect(result.scheduled).toBeUndefined();
343 });
344
345 test("parses @Section", () => {
346 const result = parseCommandArgs("Buy groceries @Personal");
347 expect(result.title).toBe("Buy groceries");
348 expect(result.section).toBe("Personal");
349 });
350
351 test("parses scheduled: with ISO date", () => {
352 const result = parseCommandArgs("Task scheduled:2026-03-15");
353 expect(result.title).toBe("Task");
354 expect(result.scheduled).toBe("2026-03-15");
355 });
356
357 test("parses deadline: with ISO date", () => {
358 const result = parseCommandArgs("Task deadline:2026-04-01");
359 expect(result.title).toBe("Task");
360 expect(result.deadline).toBe("2026-04-01");
361 });
362
363 test("parses priority:", () => {
364 const result = parseCommandArgs("Task priority:2");
365 expect(result.title).toBe("Task");
366 expect(result.priority).toBe(2);
367 });
368
369 test("parses state:", () => {
370 const result = parseCommandArgs("Task state:NEXT");
371 expect(result.title).toBe("Task");
372 expect(result.state).toBe("NEXT");
373 });
374
375 test("parses all options combined", () => {
376 const result = parseCommandArgs("Complex task @Work scheduled:2026-03-15 deadline:2026-04-01 priority:1 state:NEXT");
377 expect(result.title).toBe("Complex task");
378 expect(result.section).toBe("Work");
379 expect(result.scheduled).toBe("2026-03-15");
380 expect(result.deadline).toBe("2026-04-01");
381 expect(result.priority).toBe(1);
382 expect(result.state).toBe("NEXT");
383 });
384
385 test("parses state case-insensitive", () => {
386 const result = parseCommandArgs("Task state:next");
387 expect(result.state).toBe("NEXT");
388 });
389
390 test("handles scheduled:tomorrow", () => {
391 const result = parseCommandArgs("Task scheduled:tomorrow");
392 expect(result.scheduled).toBeDefined();
393 expect(result.scheduled).toMatch(/^\d{4}-\d{2}-\d{2}$/);
394 });
395 });
396
397 // --- action validation ---
398 test("all tool actions are enumerated", () => {
399 const validActions = [
400 "list", "scheduled", "upcoming", "overdue", "search", "get",
401 "done", "state", "schedule", "deadline", "priority", "add", "append",
402 "sections", "statistics", "archive",
403 "inbox-list", "inbox-count", "inbox-add",
404 "refile-targets", "refile"
405 ];
406 expect(validActions.length).toBe(21);
407 });
408
409 // --- elisp escaping ---
410 test("elisp escaping handles double quotes", () => {
411 const heading = 'Task with "quotes"';
412 const escaped = heading.replace(/"/g, '\\"');
413 expect(escaped).toBe('Task with \\"quotes\\"');
414 });
415
416 test("elisp escaping handles single quotes", () => {
417 const heading = "Task with 'apostrophes'";
418 // Single quotes don't need escaping in double-quoted elisp strings
419 expect(heading).toContain("'");
420 });
421
422 test("elisp escaping handles backslashes", () => {
423 const heading = "Path\\to\\file";
424 const escaped = heading.replace(/\\/g, '\\\\');
425 expect(escaped).toBe("Path\\\\to\\\\file");
426 });
427
428 test("elisp escaping handles newlines", () => {
429 const content = "Line 1\nLine 2";
430 const escaped = content.replace(/\n/g, '\\n');
431 expect(escaped).toBe("Line 1\\nLine 2");
432 });
433 });
434
435 // ==================================
436 // INTEGRATION TESTS (Emacs required)
437 // ==================================
438
439 describe("integration tests", () => {
440 beforeAll(() => {
441 if (!canRunIntegrationTests) {
442 console.log("Skipping integration tests: Emacs daemon not running");
443 return;
444 }
445
446 // Create test file
447 writeFileSync(TEST_FILE, TEST_ORG_CONTENT);
448
449 // Ensure pi-org-todos.el is loaded
450 try {
451 execSync(
452 `emacsclient --eval '(progn (add-to-list (quote load-path) "${process.cwd()}/dots/config/emacs/site-lisp") (require (quote pi-org-todos)))'`,
453 { stdio: "pipe" }
454 );
455 } catch (e) {
456 console.log("Warning: Could not load pi-org-todos.el");
457 }
458 });
459
460 afterAll(() => {
461 if (existsSync(TEST_FILE)) {
462 unlinkSync(TEST_FILE);
463 }
464 });
465
466 // --- List Operations ---
467 test("list TODOs", () => {
468 if (!canRunIntegrationTests) return;
469 const result = execEmacs(`(pi/org-todo-list "${TEST_FILE}")`);
470 expect(result.success).toBe(true);
471 expect(Array.isArray(result.data)).toBe(true);
472 expect(result.data.length).toBeGreaterThan(0);
473 const states = result.data.map((t: any) => t.todo);
474 expect(states).toContain("TODO");
475 expect(states).toContain("NEXT");
476 });
477
478 test("list TODOs with state filter", () => {
479 if (!canRunIntegrationTests) return;
480 const result = execEmacs(`(pi/org-todo-list "${TEST_FILE}" "NEXT")`);
481 expect(result.success).toBe(true);
482 expect(result.data.length).toBeGreaterThan(0);
483 for (const todo of result.data) {
484 expect(todo.todo).toBe("NEXT");
485 }
486 });
487
488 test("list TODOs with comma-separated states", () => {
489 if (!canRunIntegrationTests) return;
490 const result = execEmacs(`(pi/org-todo-list "${TEST_FILE}" "NEXT,STRT")`);
491 expect(result.success).toBe(true);
492 for (const todo of result.data) {
493 expect(["NEXT", "STRT"]).toContain(todo.todo);
494 }
495 });
496
497 test("list all TODOs including DONE", () => {
498 if (!canRunIntegrationTests) return;
499 const result = execEmacs(`(pi/org-todo-list-all "${TEST_FILE}")`);
500 expect(result.success).toBe(true);
501 const states = result.data.map((t: any) => t.todo);
502 expect(states).toContain("DONE");
503 });
504
505 // --- Search ---
506 test("search TODOs by heading", () => {
507 if (!canRunIntegrationTests) return;
508 const result = execEmacs(`(pi/org-todo-search "pipeline" "${TEST_FILE}")`);
509 expect(result.success).toBe(true);
510 expect(result.data.length).toBeGreaterThan(0);
511 expect(result.data[0].heading).toContain("pipeline");
512 });
513
514 test("search TODOs returns no results for nonexistent term", () => {
515 if (!canRunIntegrationTests) return;
516 const result = execEmacs(`(pi/org-todo-search "zzzznonexistent" "${TEST_FILE}")`);
517 expect(result.success).toBe(true);
518 // data may be null or empty array when no results
519 expect(result.data === null || result.data.length === 0).toBe(true);
520 });
521
522 // --- Get ---
523 test("get specific TODO", () => {
524 if (!canRunIntegrationTests) return;
525 const result = execEmacs(`(pi/org-todo-get "Review PR for pipeline" "${TEST_FILE}")`);
526 expect(result.success).toBe(true);
527 expect(result.data.heading).toBe("Review PR for pipeline");
528 expect(result.data.todo).toBe("TODO");
529 expect(result.data.priority).toBe(2);
530 });
531
532 test("get TODO includes content", () => {
533 if (!canRunIntegrationTests) return;
534 const result = execEmacs(`(pi/org-todo-get "Review PR for pipeline" "${TEST_FILE}")`);
535 expect(result.success).toBe(true);
536 expect(result.data.content).toContain("test TODO with some content");
537 });
538
539 test("get TODO includes properties", () => {
540 if (!canRunIntegrationTests) return;
541 const result = execEmacs(`(pi/org-todo-get "Review PR for pipeline" "${TEST_FILE}")`);
542 expect(result.success).toBe(true);
543 expect(result.data.properties).toBeDefined();
544 expect(result.data.properties.CREATED).toContain("2026-02-01");
545 });
546
547 // --- Sections ---
548 test("get sections", () => {
549 if (!canRunIntegrationTests) return;
550 const result = execEmacs(`(pi/org-todo-sections "${TEST_FILE}")`);
551 expect(result.success).toBe(true);
552 const sections = Array.isArray(result.data) ? result.data : Object.values(result.data);
553 expect(sections).toContain("Work");
554 expect(sections).toContain("Projects");
555 expect(sections).toContain("Personal");
556 });
557
558 test("get TODOs by section", () => {
559 if (!canRunIntegrationTests) return;
560 const result = execEmacs(`(pi/org-todo-by-section "Projects" "${TEST_FILE}")`);
561 expect(result.success).toBe(true);
562 expect(result.data.length).toBe(2);
563 });
564
565 // --- Statistics ---
566 test("get statistics", () => {
567 if (!canRunIntegrationTests) return;
568 const result = execEmacs(`(pi/org-todo-statistics "${TEST_FILE}")`);
569 expect(result.success).toBe(true);
570 expect(result.data.total).toBeGreaterThan(0);
571 expect(result.data.by_state).toBeDefined();
572 });
573
574 // --- Write Operations (use fresh temp files) ---
575 test("mark TODO as done", () => {
576 if (!canRunIntegrationTests) return;
577 const doneTestFile = join(tmpdir(), `pi-org-done-test-${Date.now()}.org`);
578 writeFileSync(doneTestFile, `* Work\n** TODO Task to complete\n`);
579 try {
580 const result = execEmacs(`(pi/org-todo-done "Task to complete" "${doneTestFile}")`);
581 expect(result.success).toBe(true);
582 expect(result.data.state).toBe("DONE");
583 const content = readFileSync(doneTestFile, "utf-8");
584 expect(content).toContain("DONE Task to complete");
585 } finally {
586 if (existsSync(doneTestFile)) unlinkSync(doneTestFile);
587 }
588 });
589
590 test("change TODO state to NEXT", () => {
591 if (!canRunIntegrationTests) return;
592 const stateTestFile = join(tmpdir(), `pi-org-state-test-${Date.now()}.org`);
593 writeFileSync(stateTestFile, `* Work\n** TODO Task to change\n`);
594 try {
595 const result = execEmacs(`(pi/org-todo-state "Task to change" "NEXT" "${stateTestFile}")`);
596 expect(result.success).toBe(true);
597 expect(result.data.state).toBe("NEXT");
598 const content = readFileSync(stateTestFile, "utf-8");
599 expect(content).toContain("NEXT Task to change");
600 } finally {
601 if (existsSync(stateTestFile)) unlinkSync(stateTestFile);
602 }
603 });
604
605 test("change TODO state to STRT", () => {
606 if (!canRunIntegrationTests) return;
607 const f = join(tmpdir(), `pi-org-strt-${Date.now()}.org`);
608 writeFileSync(f, `* Work\n** TODO Task\n`);
609 try {
610 const result = execEmacs(`(pi/org-todo-state "Task" "STRT" "${f}")`);
611 expect(result.success).toBe(true);
612 expect(result.data.state).toBe("STRT");
613 } finally {
614 if (existsSync(f)) unlinkSync(f);
615 }
616 });
617
618 test("change TODO state to WAIT", () => {
619 if (!canRunIntegrationTests) return;
620 const f = join(tmpdir(), `pi-org-wait-${Date.now()}.org`);
621 writeFileSync(f, `* Work\n** TODO Task\n`);
622 try {
623 const result = execEmacs(`(pi/org-todo-state "Task" "WAIT" "${f}")`);
624 expect(result.success).toBe(true);
625 expect(result.data.state).toBe("WAIT");
626 } finally {
627 if (existsSync(f)) unlinkSync(f);
628 }
629 });
630
631 test("change TODO state to CANX", () => {
632 if (!canRunIntegrationTests) return;
633 const f = join(tmpdir(), `pi-org-canx-${Date.now()}.org`);
634 writeFileSync(f, `* Work\n** TODO Task\n`);
635 try {
636 const result = execEmacs(`(pi/org-todo-state "Task" "CANX" "${f}")`);
637 expect(result.success).toBe(true);
638 expect(result.data.state).toBe("CANX");
639 } finally {
640 if (existsSync(f)) unlinkSync(f);
641 }
642 });
643
644 test("add new TODO", () => {
645 if (!canRunIntegrationTests) return;
646 const addTestFile = join(tmpdir(), `pi-org-add-test-${Date.now()}.org`);
647 writeFileSync(addTestFile, `* Work\n** TODO Existing task\n`);
648 try {
649 const result = execEmacs(
650 `(pi/org-todo-add "New task from test" "Work" "${addTestFile}" "2026-03-01" 2 '("test"))`
651 );
652 expect(result.success).toBe(true);
653 expect(result.data.heading).toBe("New task from test");
654 const content = readFileSync(addTestFile, "utf-8");
655 expect(content).toContain("New task from test");
656 } finally {
657 if (existsSync(addTestFile)) unlinkSync(addTestFile);
658 }
659 });
660
661 test("add TODO with tags", () => {
662 if (!canRunIntegrationTests) return;
663 const f = join(tmpdir(), `pi-org-add-tags-${Date.now()}.org`);
664 writeFileSync(f, `* Work\n`);
665 try {
666 const result = execEmacs(`(pi/org-todo-add "Tagged task" "Work" "${f}" nil nil '("review" "code"))`);
667 expect(result.success).toBe(true);
668 const content = readFileSync(f, "utf-8");
669 expect(content).toContain(":review:code:");
670 } finally {
671 if (existsSync(f)) unlinkSync(f);
672 }
673 });
674
675 test("add TODO to nonexistent section fails", () => {
676 if (!canRunIntegrationTests) return;
677 const f = join(tmpdir(), `pi-org-add-fail-${Date.now()}.org`);
678 writeFileSync(f, `* Work\n`);
679 try {
680 const result = execEmacs(`(pi/org-todo-add "Task" "Nonexistent" "${f}")`);
681 expect(result.success).toBe(false);
682 } finally {
683 if (existsSync(f)) unlinkSync(f);
684 }
685 });
686
687 // --- Schedule & Deadline ---
688 test("schedule a TODO", () => {
689 if (!canRunIntegrationTests) return;
690 const f = join(tmpdir(), `pi-org-sched-${Date.now()}.org`);
691 writeFileSync(f, `* Work\n** TODO Schedule me\n`);
692 try {
693 const result = execEmacs(`(pi/org-todo-schedule "Schedule me" "2026-06-15" "${f}")`);
694 expect(result.success).toBe(true);
695 const content = readFileSync(f, "utf-8");
696 expect(content).toContain("SCHEDULED:");
697 expect(content).toContain("2026-06-15");
698 } finally {
699 if (existsSync(f)) unlinkSync(f);
700 }
701 });
702
703 test("set deadline on a TODO", () => {
704 if (!canRunIntegrationTests) return;
705 const f = join(tmpdir(), `pi-org-deadline-${Date.now()}.org`);
706 writeFileSync(f, `* Work\n** TODO Deadline me\n`);
707 try {
708 const result = execEmacs(`(pi/org-todo-deadline "Deadline me" "2026-07-01" "${f}")`);
709 expect(result.success).toBe(true);
710 const content = readFileSync(f, "utf-8");
711 expect(content).toContain("DEADLINE:");
712 expect(content).toContain("2026-07-01");
713 } finally {
714 if (existsSync(f)) unlinkSync(f);
715 }
716 });
717
718 // --- Priority ---
719 test("set priority on a TODO", () => {
720 if (!canRunIntegrationTests) return;
721 const f = join(tmpdir(), `pi-org-prio-${Date.now()}.org`);
722 writeFileSync(f, `* Work\n** TODO Priority me\n`);
723 try {
724 const result = execEmacs(`(pi/org-todo-priority "Priority me" 1 "${f}")`);
725 expect(result.success).toBe(true);
726 const content = readFileSync(f, "utf-8");
727 expect(content).toContain("[#1]");
728 } finally {
729 if (existsSync(f)) unlinkSync(f);
730 }
731 });
732
733 // --- Append ---
734 test("append content to a TODO", () => {
735 if (!canRunIntegrationTests) return;
736 const f = join(tmpdir(), `pi-org-append-${Date.now()}.org`);
737 writeFileSync(f, `* Work\n** TODO Append to me\n`);
738 try {
739 const result = execEmacs(`(pi/org-todo-append "Append to me" "Added content here" "${f}")`);
740 expect(result.success).toBe(true);
741 const content = readFileSync(f, "utf-8");
742 expect(content).toContain("Added content here");
743 } finally {
744 if (existsSync(f)) unlinkSync(f);
745 }
746 });
747
748 // --- Tags ---
749 test("add tags to a TODO", () => {
750 if (!canRunIntegrationTests) return;
751 const f = join(tmpdir(), `pi-org-tags-${Date.now()}.org`);
752 writeFileSync(f, `* Work\n** TODO Tag me\n`);
753 try {
754 const result = execEmacs(`(pi/org-todo-add-tags "Tag me" '("new" "tags") "${f}")`);
755 expect(result.success).toBe(true);
756 const content = readFileSync(f, "utf-8");
757 expect(content).toContain(":new:");
758 expect(content).toContain(":tags:");
759 } finally {
760 if (existsSync(f)) unlinkSync(f);
761 }
762 });
763
764 test("remove tags from a TODO", () => {
765 if (!canRunIntegrationTests) return;
766 const f = join(tmpdir(), `pi-org-rmtag-${Date.now()}.org`);
767 writeFileSync(f, `* Work\n** TODO Remove tag :urgent:review:\n`);
768 try {
769 const result = execEmacs(`(pi/org-todo-remove-tags "Remove tag" '("urgent") "${f}")`);
770 expect(result.success).toBe(true);
771 const content = readFileSync(f, "utf-8");
772 expect(content).not.toContain(":urgent:");
773 expect(content).toContain(":review:");
774 } finally {
775 if (existsSync(f)) unlinkSync(f);
776 }
777 });
778
779 // --- Properties ---
780 test("set and get property", () => {
781 if (!canRunIntegrationTests) return;
782 const f = join(tmpdir(), `pi-org-prop-${Date.now()}.org`);
783 writeFileSync(f, `* Work\n** TODO Property me\n`);
784 try {
785 const setResult = execEmacs(`(pi/org-todo-set-property "Property me" "EFFORT" "2h" "${f}")`);
786 expect(setResult.success).toBe(true);
787 const getResult = execEmacs(`(pi/org-todo-get-property "Property me" "EFFORT" "${f}")`);
788 expect(getResult.success).toBe(true);
789 expect(getResult.data.value).toBe("2h");
790 } finally {
791 if (existsSync(f)) unlinkSync(f);
792 }
793 });
794
795 // --- Inbox ---
796 test("inbox-all on empty inbox", () => {
797 if (!canRunIntegrationTests) return;
798 const f = join(tmpdir(), `pi-org-inbox-empty-${Date.now()}.org`);
799 writeFileSync(f, `#+title: Inbox\n`);
800 try {
801 const result = execEmacs(`(pi/org-todo-inbox-all "${f}")`);
802 expect(result.success).toBe(true);
803 // data may be null or empty array for empty inbox
804 expect(result.data === null || result.data.length === 0).toBe(true);
805 } finally {
806 if (existsSync(f)) unlinkSync(f);
807 }
808 });
809
810 test("inbox-all with mixed items", () => {
811 if (!canRunIntegrationTests) return;
812 const f = join(tmpdir(), `pi-org-inbox-${Date.now()}.org`);
813 writeFileSync(f, `#+title: Inbox\n* TODO Task one\n* Link item\n* TODO Task two\n`);
814 try {
815 const result = execEmacs(`(pi/org-todo-inbox-all "${f}")`);
816 expect(result.success).toBe(true);
817 expect(result.data.length).toBe(3);
818 const todos = result.data.filter((i: any) => i.todo);
819 expect(todos.length).toBe(2);
820 } finally {
821 if (existsSync(f)) unlinkSync(f);
822 }
823 });
824
825 // --- Refile ---
826 test("get refile targets", () => {
827 if (!canRunIntegrationTests) return;
828 const result = execEmacs(`(pi/org-todo-get-refile-targets "${TEST_FILE}")`);
829 expect(result.success).toBe(true);
830 expect(result.data.length).toBeGreaterThan(0);
831 for (const target of result.data) {
832 expect(target.section).toBeDefined();
833 expect(target.position).toBeDefined();
834 }
835 });
836
837 test("refile entry from inbox to target", () => {
838 if (!canRunIntegrationTests) return;
839 const inbox = join(tmpdir(), `pi-org-refile-inbox-${Date.now()}.org`);
840 const target = join(tmpdir(), `pi-org-refile-target-${Date.now()}.org`);
841 writeFileSync(inbox, `* TODO Refile me\n`);
842 writeFileSync(target, `* Work\n** TODO Existing\n* Projects\n`);
843 try {
844 const result = execEmacs(`(pi/org-todo-refile "Refile me" "Work" "${inbox}" "${target}")`);
845 expect(result.success).toBe(true);
846 // Verify it's gone from inbox
847 const inboxContent = readFileSync(inbox, "utf-8");
848 expect(inboxContent).not.toContain("Refile me");
849 // Verify it's in target
850 const targetContent = readFileSync(target, "utf-8");
851 expect(targetContent).toContain("Refile me");
852 } finally {
853 if (existsSync(inbox)) unlinkSync(inbox);
854 if (existsSync(target)) unlinkSync(target);
855 }
856 });
857
858 // --- Error Handling ---
859 test("error handling for non-existent heading", () => {
860 if (!canRunIntegrationTests) return;
861 const result = execEmacs(`(pi/org-todo-done "This heading does not exist" "${TEST_FILE}")`);
862 expect(result.success).toBe(false);
863 expect(result.error).toContain("not found");
864 });
865
866 test("error handling for non-existent file", () => {
867 if (!canRunIntegrationTests) return;
868 const result = execEmacs(`(pi/org-todo-list "/nonexistent/file.org")`);
869 expect(result.success).toBe(false);
870 });
871
872 test("error handling for schedule on non-existent heading", () => {
873 if (!canRunIntegrationTests) return;
874 const result = execEmacs(`(pi/org-todo-schedule "Nonexistent" "2026-03-01" "${TEST_FILE}")`);
875 expect(result.success).toBe(false);
876 });
877
878 test("error handling for deadline on non-existent heading", () => {
879 if (!canRunIntegrationTests) return;
880 const result = execEmacs(`(pi/org-todo-deadline "Nonexistent" "2026-03-01" "${TEST_FILE}")`);
881 expect(result.success).toBe(false);
882 });
883
884 test("error handling for priority on non-existent heading", () => {
885 if (!canRunIntegrationTests) return;
886 const result = execEmacs(`(pi/org-todo-priority "Nonexistent" 1 "${TEST_FILE}")`);
887 expect(result.success).toBe(false);
888 });
889
890 test("error handling for append on non-existent heading", () => {
891 if (!canRunIntegrationTests) return;
892 const result = execEmacs(`(pi/org-todo-append "Nonexistent" "content" "${TEST_FILE}")`);
893 expect(result.success).toBe(false);
894 });
895
896 // --- Sequential Operations ---
897 test("add then mark done (buffer sync)", () => {
898 if (!canRunIntegrationTests) return;
899 const f = join(tmpdir(), `pi-org-seq-${Date.now()}.org`);
900 writeFileSync(f, `* Work\n`);
901 try {
902 // Add
903 const addResult = execEmacs(`(pi/org-todo-add "Sequential task" "Work" "${f}")`);
904 expect(addResult.success).toBe(true);
905 // Done
906 const doneResult = execEmacs(`(pi/org-todo-done "Sequential task" "${f}")`);
907 expect(doneResult.success).toBe(true);
908 // Verify on disk
909 const content = readFileSync(f, "utf-8");
910 expect(content).toContain("DONE Sequential task");
911 } finally {
912 if (existsSync(f)) unlinkSync(f);
913 }
914 });
915
916 test("add, schedule, set priority, then verify", () => {
917 if (!canRunIntegrationTests) return;
918 const f = join(tmpdir(), `pi-org-multi-${Date.now()}.org`);
919 writeFileSync(f, `* Work\n`);
920 try {
921 execEmacs(`(pi/org-todo-add "Multi task" "Work" "${f}")`);
922 execEmacs(`(pi/org-todo-schedule "Multi task" "2026-08-15" "${f}")`);
923 execEmacs(`(pi/org-todo-priority "Multi task" 1 "${f}")`);
924 const getResult = execEmacs(`(pi/org-todo-get "Multi task" "${f}")`);
925 expect(getResult.success).toBe(true);
926 expect(getResult.data.priority).toBe(1);
927 expect(getResult.data.scheduled).toContain("2026-08-15");
928 } finally {
929 if (existsSync(f)) unlinkSync(f);
930 }
931 });
932 });
933});
934
935// --- Command parsing regression tests ---
936describe("command parsing", () => {
937 test("chrono-node parses tomorrow", () => {
938 const tomorrow = chrono.parseDate("tomorrow");
939 expect(tomorrow).not.toBeNull();
940 expect(tomorrow!.getDate()).toBe(new Date().getDate() + 1);
941 });
942
943 test("chrono-node parses next friday", () => {
944 const nextFriday = chrono.parseDate("next friday");
945 expect(nextFriday).not.toBeNull();
946 expect(nextFriday!.getDay()).toBe(5);
947 });
948
949 test("chrono-node parses ISO date", () => {
950 const specific = chrono.parseDate("2026-03-15");
951 expect(specific).not.toBeNull();
952 expect(specific!.getMonth()).toBe(2); // March (0-indexed)
953 });
954
955 test("@Section pattern", () => {
956 const match = "Buy milk @Personal".match(/@(\w+)/);
957 expect(match).toBeDefined();
958 expect(match![1]).toBe("Personal");
959 });
960
961 test("scheduled: pattern", () => {
962 const match = "Task scheduled:tomorrow".match(/scheduled:([^\s]+)/i);
963 expect(match).toBeDefined();
964 expect(match![1]).toBe("tomorrow");
965 });
966
967 test("priority: pattern", () => {
968 const match = "Task priority:2".match(/priority:(\d)/i);
969 expect(match).toBeDefined();
970 expect(match![1]).toBe("2");
971 });
972
973 test("state: pattern", () => {
974 const match = "Task state:NEXT".match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
975 expect(match).toBeDefined();
976 expect(match![1]).toBe("NEXT");
977 });
978
979 test("state: pattern case insensitive", () => {
980 const match = "Task state:done".match(/state:(TODO|NEXT|STRT|WAIT|DONE|CANX)/i);
981 expect(match).toBeDefined();
982 expect(match![1]).toBe("done");
983 });
984});