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});