From 4e1401d1499be3d24df78edd97310d200e719e10 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 24 Jun 2026 06:43:45 -0400 Subject: feat(ai-term): wrap-teardown + shutdown entry points for rulesets Add the three headless functions the rulesets wrap-it-up workflow calls via emacsclient -e, since this module owns the aiv- session naming, the agent buffer, and the geometry restore. cj/ai-term-quit kills a project's tmux session and agent buffer and restores the layout, idempotent and safe when already gone. cj/ai-term-live-count returns the integer count of live aiv- sessions for the shutdown safety gate. cj/ai-term-shutdown-countdown re-checks that gate, then runs an abort-able run-at-time countdown in the echo area and, uncancelled, runs the shutdown command (a defcustom so tests stub it). Reuses the existing kill/close helpers. 13 ERT tests cover the live-count parsing, the quit kill-and-idempotency, and the gate-abort/cancel/tick logic; the tmux and shutdown side effects are manual. Claude-Session: https://claude.ai/code/session_01BqrdWUo9GcznYX2pZr76gZ --- tests/test-ai-term--live-count.el | 60 +++++++++++++++++++++++++ tests/test-ai-term--quit.el | 65 +++++++++++++++++++++++++++ tests/test-ai-term--shutdown-countdown.el | 73 +++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 tests/test-ai-term--live-count.el create mode 100644 tests/test-ai-term--quit.el create mode 100644 tests/test-ai-term--shutdown-countdown.el (limited to 'tests') diff --git a/tests/test-ai-term--live-count.el b/tests/test-ai-term--live-count.el new file mode 100644 index 000000000..1432599cc --- /dev/null +++ b/tests/test-ai-term--live-count.el @@ -0,0 +1,60 @@ +;;; test-ai-term--live-count.el --- Tests for cj/ai-term-live-count -*- lexical-binding: t; -*- + +;;; Commentary: +;; The shutdown safety gate: the integer count of live AI-term (aiv-*) tmux +;; sessions, read by the rulesets wrap-it-up workflow via emacsclient -e. No +;; server / no sessions is 0, not an error. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(defmacro test-ai-term-live-count--with-tmux (exit-code output &rest body) + "Run BODY with `process-file' mocked to a tmux list-sessions response. +EXIT-CODE is returned (or the symbol `error' to signal); OUTPUT is written to +the stdout destination buffer." + (declare (indent 2)) + `(cl-letf (((symbol-function 'process-file) + (lambda (_program _infile destination _display &rest _args) + (when (eq ,exit-code 'error) (error "tmux: command not found")) + (let ((buffer (cond ((eq destination t) (current-buffer)) + ((bufferp destination) destination) + ((consp destination) + (and (eq (car destination) t) (current-buffer)))))) + (when (bufferp buffer) + (with-current-buffer buffer (insert ,output)))) + ,exit-code))) + (let ((cj/ai-term-tmux-session-prefix "aiv-")) + ,@body))) + +(ert-deftest test-ai-term-live-count-counts-matching-sessions () + "Normal: two aiv-* sessions among others count as 2." + (test-ai-term-live-count--with-tmux 0 "aiv-foo\nrandom\naiv-bar\n" + (should (= (cj/ai-term-live-count) 2)))) + +(ert-deftest test-ai-term-live-count-single-session () + "Boundary: a sole aiv-* session counts as 1." + (test-ai-term-live-count--with-tmux 0 "aiv-only\nother\n" + (should (= (cj/ai-term-live-count) 1)))) + +(ert-deftest test-ai-term-live-count-no-matching-sessions () + "Boundary: a running server with no aiv-* sessions is 0." + (test-ai-term-live-count--with-tmux 0 "other-a\nother-b\n" + (should (= (cj/ai-term-live-count) 0)))) + +(ert-deftest test-ai-term-live-count-no-server () + "Error: tmux exits non-zero (no server) -> 0, not a signal." + (test-ai-term-live-count--with-tmux 1 "no server running\n" + (should (= (cj/ai-term-live-count) 0)))) + +(ert-deftest test-ai-term-live-count-tmux-missing () + "Error: tmux not installed -> 0." + (test-ai-term-live-count--with-tmux 'error "" + (should (= (cj/ai-term-live-count) 0)))) + +(provide 'test-ai-term--live-count) +;;; test-ai-term--live-count.el ends here diff --git a/tests/test-ai-term--quit.el b/tests/test-ai-term--quit.el new file mode 100644 index 000000000..55ace81db --- /dev/null +++ b/tests/test-ai-term--quit.el @@ -0,0 +1,65 @@ +;;; test-ai-term--quit.el --- Tests for cj/ai-term-quit -*- lexical-binding: t; -*- + +;;; Commentary: +;; Headless teardown of a project's AI-term: kill the aiv- tmux session, +;; then the agent buffer. Driven by the rulesets Stop hook via emacsclient -e, +;; keyed by project basename. Must be idempotent (a no-op when already gone). + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(defmacro test-ai-term-quit--with-tmux (calls-var &rest body) + "Run BODY with `process-file' mocked to record arg lists into CALLS-VAR (0 exit)." + (declare (indent 1)) + `(cl-letf (((symbol-function 'process-file) + (lambda (_program _infile _destination _display &rest args) + (push args ,calls-var) 0))) + ,@body)) + +(ert-deftest test-ai-term-quit-kills-session-and-buffer () + "Normal: quit kills the project's aiv- session and its agent buffer." + (let ((buf (get-buffer-create "agent [myproj]")) + (calls nil)) + (unwind-protect + (test-ai-term-quit--with-tmux calls + (cj/ai-term-quit "myproj") + (should (member '("kill-session" "-t" "aiv-myproj") calls)) + (should-not (buffer-live-p buf))) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest test-ai-term-quit-sanitizes-dotted-basename () + "Boundary: a dotted basename maps to the sanitized session tmux really uses." + (let ((buf (get-buffer-create "agent [.emacs.d]")) + (calls nil)) + (unwind-protect + (test-ai-term-quit--with-tmux calls + (cj/ai-term-quit ".emacs.d") + (should (member '("kill-session" "-t" "aiv-_emacs_d") calls)) + (should-not (buffer-live-p buf))) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest test-ai-term-quit-idempotent-when-gone () + "Error/Boundary: a second quit (session + buffer already gone) does not error." + (let ((calls nil)) + (test-ai-term-quit--with-tmux calls + ;; No buffer named "agent [ghost]" exists; session kill is a no-op in tmux. + (should (stringp (cj/ai-term-quit "ghost"))) + (should (member '("kill-session" "-t" "aiv-ghost") calls))))) + +(ert-deftest test-ai-term-quit-leaves-non-agent-buffers () + "Error: a same-named-but-non-agent buffer is never killed (prefix guard)." + (let ((buf (get-buffer-create "notes-myproj")) + (calls nil)) + (unwind-protect + (test-ai-term-quit--with-tmux calls + (cj/ai-term-quit "myproj") + (should (buffer-live-p buf))) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(provide 'test-ai-term--quit) +;;; test-ai-term--quit.el ends here diff --git a/tests/test-ai-term--shutdown-countdown.el b/tests/test-ai-term--shutdown-countdown.el new file mode 100644 index 000000000..6500e9634 --- /dev/null +++ b/tests/test-ai-term--shutdown-countdown.el @@ -0,0 +1,73 @@ +;;; test-ai-term--shutdown-countdown.el --- Tests for the shutdown countdown -*- lexical-binding: t; -*- + +;;; Commentary: +;; The "wrap it up and shutdown" countdown. The testable logic is the safety +;; gate (abort when more than one aiv-* session is live) and the cancel/timer +;; bookkeeping; the tick rendering and the actual shutdown side effect are +;; manual (see the spec). shell-command is stubbed throughout so no test can +;; power the machine off, and timers are cancelled rather than allowed to fire. + +;;; Code: + +(require 'ert) +(require 'cl-lib) + +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(require 'ai-term) + +(defmacro test-ai-term-shutdown--with (live-count shell-var &rest body) + "Run BODY with `cj/ai-term-live-count' mocked to LIVE-COUNT and `shell-command' +recording its argument into SHELL-VAR; the timer is cleared before and after." + (declare (indent 2)) + `(progn + (cj/--ai-term-shutdown-clear-timer) + (unwind-protect + (cl-letf (((symbol-function 'cj/ai-term-live-count) (lambda () ,live-count)) + ((symbol-function 'shell-command) + (lambda (cmd &rest _) (setq ,shell-var cmd) 0))) + ,@body) + (cj/--ai-term-shutdown-clear-timer)))) + +(ert-deftest test-ai-term-shutdown-aborts-when-other-sessions-live () + "Normal: more than one live session aborts -- no timer, no shutdown." + (let ((shell nil)) + (test-ai-term-shutdown--with 2 shell + (should-not (cj/ai-term-shutdown-countdown 3)) + (should-not cj/--ai-term-shutdown-timer) + (should-not shell)))) + +(ert-deftest test-ai-term-shutdown-schedules-timer-when-sole-session () + "Normal: the sole live session schedules the countdown timer (does not fire here)." + (let ((shell nil)) + (test-ai-term-shutdown--with 1 shell + (cj/ai-term-shutdown-countdown 3) + (should (timerp cj/--ai-term-shutdown-timer)) + ;; The timer has not ticked (no event loop in batch), so no shutdown yet. + (should-not shell)))) + +(ert-deftest test-ai-term-shutdown-cancel-clears-the-timer () + "Normal: cancel stops an in-progress countdown." + (let ((shell nil)) + (test-ai-term-shutdown--with 1 shell + (cj/ai-term-shutdown-countdown 5) + (should (timerp cj/--ai-term-shutdown-timer)) + (cj/ai-term-shutdown-cancel) + (should-not cj/--ai-term-shutdown-timer) + (should-not shell)))) + +(ert-deftest test-ai-term-shutdown-tick-fires-shutdown-at-zero () + "Boundary: invoking the timer function at zero remaining runs the shutdown +command and clears the timer. Drives the tick directly rather than waiting." + (let ((shell nil)) + (test-ai-term-shutdown--with 1 shell + (cj/ai-term-shutdown-countdown 1) + (let ((fn (timer--function cj/--ai-term-shutdown-timer))) + ;; remaining starts at 1: first call renders, second call hits zero. + (funcall fn) + (should-not shell) + (funcall fn) + (should (equal shell cj/ai-term-shutdown-command)) + (should-not cj/--ai-term-shutdown-timer))))) + +(provide 'test-ai-term--shutdown-countdown) +;;; test-ai-term--shutdown-countdown.el ends here -- cgit v1.2.3