diff options
Diffstat (limited to 'tests/test-term-tmux-history.el')
| -rw-r--r-- | tests/test-term-tmux-history.el | 312 |
1 files changed, 312 insertions, 0 deletions
diff --git a/tests/test-term-tmux-history.el b/tests/test-term-tmux-history.el new file mode 100644 index 00000000..2c9c38f8 --- /dev/null +++ b/tests/test-term-tmux-history.el @@ -0,0 +1,312 @@ +;;; test-term-tmux-history.el --- Tests for term-config tmux history + menu UX -*- lexical-binding: t; -*- + +;;; Commentary: +;; Exercises the term-config (ghostel) terminal UX: the Emacs-owned tmux +;; history buffer, the copy-mode-dwim engine pick, the tmux pane-id / +;; attached-client predicates, and the C-; x menu bindings. +;; +;; ghostel is required (which defines `ghostel-mode-map' / +;; `ghostel-keymap-exceptions' and lets term-config's `with-eval-after-load' +;; fire) before term-config. `(require 'ghostel)' does not load the native +;; module; tmux is mocked via `process-file', so nothing spawns. + +;;; Code: + +(require 'ert) +(require 'cl-lib) +(require 'package) + +(setq package-user-dir (expand-file-name "elpa" user-emacs-directory)) +(package-initialize) +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) +(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory)) +(setq load-prefer-newer t) +(require 'ghostel) +(require 'term-config) +(require 'testutil-ghostel-buffers) + +(defmacro test-term-tmux-history--with-tmux-mock (responses &rest body) + "Run BODY with `process-file' mocked for tmux RESPONSES. + +RESPONSES is an alist of (ARGS EXIT-CODE OUTPUT)." + (declare (indent 1)) + `(let ((calls nil)) + (cl-letf (((symbol-function 'process-file) + (lambda (program _infile destination _display &rest args) + (push (cons program args) calls) + (let* ((entry (seq-find + (lambda (candidate) + (equal (car candidate) args)) + ,responses)) + (exit-code (or (cadr entry) 1)) + (output (or (caddr entry) ""))) + (when destination + (let ((buffer (cond + ((eq destination t) (current-buffer)) + ((bufferp destination) destination) + ((consp destination) (car destination))))) + (when (bufferp buffer) + (with-current-buffer buffer + (insert output))))) + exit-code)))) + ,@body))) + +(ert-deftest test-term-tmux-history--pane-id-for-tty-matches-client () + "Normal: current terminal pty maps to the active pane for that tmux client." + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/1\t%1\n/dev/pts/8\t%8\n")) + (should (equal (cj/term--tmux-pane-id-for-tty "/dev/pts/8") "%8")))) + +(ert-deftest test-term-tmux-history--capture-pane-uses-full-history () + "Normal: capture asks tmux for joined full pane history." + (test-term-tmux-history--with-tmux-mock + '((("capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" "%8") 0 + "first line\nsecond line\n")) + (should (equal (cj/term--tmux-capture-pane "%8") + "first line\nsecond line\n")))) + +(ert-deftest test-term-tmux-history-open-renders-read-only-history-buffer () + "Normal: command renders tmux history in a normal Emacs buffer." + (let ((origin (cj/test--make-fake-ghostel-buffer "*test-term-history-origin*"))) + (unwind-protect + (save-window-excursion + (switch-to-buffer origin) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n") + (("capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" "%8") 0 + "history http://example.com\n")) + (cj/term-tmux-history) + (should (eq major-mode 'cj/term-tmux-history-mode)) + (should buffer-read-only) + (should (string-match-p "history http://example.com" + (buffer-string)))))) + (cj/test--kill-buffers-matching-prefix "*terminal tmux history") + (when (buffer-live-p origin) + (kill-buffer origin))))) + +(ert-deftest test-term-tmux-history-replaces-origin-buffer-in-same-window () + "Normal: the history view replaces the origin in the selected window. + +`cj/term-tmux-history' uses `switch-to-buffer' so reading scrollback keeps +the terminal's frame slot rather than splitting or popping a new window." + (let ((origin (cj/test--make-fake-ghostel-buffer "*test-term-history-inplace*"))) + (unwind-protect + (save-window-excursion + (delete-other-windows) + (switch-to-buffer origin) + (let ((win (selected-window))) + (should (eq (window-buffer win) origin)) + (should (one-window-p)) + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n") + (("capture-pane" "-p" "-J" "-S" "-" "-E" "-" "-t" "%8") 0 + "scrollback line\n")) + (cj/term-tmux-history))) + (should (one-window-p)) + (should (eq (selected-window) win)) + (should (string-prefix-p + "*terminal tmux history:" + (buffer-name (window-buffer win)))))) + (cj/test--kill-buffers-matching-prefix "*terminal tmux history") + (when (buffer-live-p origin) + (kill-buffer origin))))) + +(ert-deftest test-term-tmux-history-quit-returns-to-origin () + "Normal: q / <escape> / C-g (cj/term-tmux-history-quit) kills the history +buffer and restores the origin buffer, window, and point." + (let ((origin (get-buffer-create "*test-term-history-return*"))) + (unwind-protect + (let ((history (get-buffer-create "*terminal tmux history: test*"))) + (with-current-buffer origin + (erase-buffer) + (insert "origin") + (goto-char (point-min))) + (switch-to-buffer origin) + (let ((origin-window (selected-window))) + (with-current-buffer history + (cj/term-tmux-history-mode) + (let ((inhibit-read-only t)) + (insert "alpha\nbeta\ngamma\n")) + (setq-local cj/term-tmux-history--origin-buffer origin) + (setq-local cj/term-tmux-history--origin-window origin-window) + (setq-local cj/term-tmux-history--origin-point (point-min)) + (cj/term-tmux-history-quit)) + (should-not (buffer-live-p history)) + (should (eq (current-buffer) origin)) + (should (= (point) (point-min))))) + (when (buffer-live-p origin) + (kill-buffer origin))))) + +(ert-deftest test-term-tmux-history-mode-keymap () + "Normal: in the history buffer M-w copies without quitting; q, <escape>, +and C-g quit back to the terminal; RET is left unbound (no special exit)." + (should (eq (keymap-lookup cj/term-tmux-history-mode-map "M-w") + #'kill-ring-save)) + (should (eq (keymap-lookup cj/term-tmux-history-mode-map "q") + #'cj/term-tmux-history-quit)) + (should (eq (keymap-lookup cj/term-tmux-history-mode-map "<escape>") + #'cj/term-tmux-history-quit)) + (should (eq (keymap-lookup cj/term-tmux-history-mode-map "C-g") + #'cj/term-tmux-history-quit)) + (should-not (keymap-lookup cj/term-tmux-history-mode-map "RET"))) + +(ert-deftest test-term-keymap-includes-history-and-copy-bindings () + "Normal: the personal terminal map owns the high-level UX commands, and C-; +reaches Emacs inside ghostel buffers so the prefix works there." + (should (member "C-;" ghostel-keymap-exceptions)) + (should (eq (keymap-lookup cj/custom-keymap "x h") #'cj/term-tmux-history)) + (should (eq (keymap-lookup cj/custom-keymap "x c") #'cj/term-copy-mode-dwim)) + (should (equal (keymap-lookup ghostel-mode-map "C-;") cj/custom-keymap)) + (should (eq (keymap-lookup ghostel-mode-map "C-; x h") #'cj/term-tmux-history)) + (should (eq (keymap-lookup ghostel-mode-map "C-; x c") #'cj/term-copy-mode-dwim))) + +(ert-deftest test-term-keymap-prompt-navigation () + "Normal: n/p navigate prompts, capital N creates a new terminal buffer." + (should (eq (keymap-lookup cj/custom-keymap "x n") #'ghostel-next-prompt)) + (should (eq (keymap-lookup cj/custom-keymap "x p") #'ghostel-previous-prompt)) + (should (eq (keymap-lookup cj/custom-keymap "x N") #'ghostel))) + +(ert-deftest test-term-current-tmux-pane-id-rejects-non-ghostel-buffer () + "Error: pane-id lookup refuses a buffer that is not in `ghostel-mode'." + (with-temp-buffer + (should-error (cj/term--current-tmux-pane-id) :type 'user-error))) + +(ert-deftest test-term-current-tmux-pane-id-accepts-agent-named-buffer () + "Normal: an agent-named ghostel buffer resolves by process TTY. + +The pane lookup keys off the live process TTY, never the buffer name, so a +buffer named `agent [repo]' (ai-term.el's naming) resolves like any other +ghostel-mode terminal." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/1\t%1\n/dev/pts/8\t%8\n")) + (should (equal (cj/term--current-tmux-pane-id) "%8"))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-in-tmux-p-true-when-client-attached () + "Normal: predicate returns t when tmux reports a client for our tty." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n")) + (should (cj/term--in-tmux-p))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-in-tmux-p-nil-when-no-matching-client () + "Boundary: predicate returns nil when tmux runs but our tty has no client." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/1\t%1\n")) + (should-not (cj/term--in-tmux-p))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-in-tmux-p-nil-when-tmux-fails () + "Error: predicate swallows tmux failures and returns nil." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]"))) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8"))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1 + "no server running")) + (should-not (cj/term--in-tmux-p))))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-in-tmux-p-nil-when-not-ghostel-mode () + "Boundary: predicate refuses non-ghostel buffers without calling tmux." + (with-temp-buffer + (let ((tmux-called nil)) + (cl-letf (((symbol-function 'process-file) + (lambda (&rest _) (setq tmux-called t) 0))) + (should-not (cj/term--in-tmux-p)) + (should-not tmux-called))))) + +(ert-deftest test-term-copy-mode-dwim-sends-tmux-prefix-when-attached () + "Normal: with tmux attached, dwim writes C-b [ into the pty so tmux enters +its own copy-mode against the full pane history." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]")) + (sent nil) + (copy-mode-called nil)) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8")) + ((symbol-function 'ghostel-send-string) + (lambda (s) (push s sent))) + ((symbol-function 'ghostel-copy-mode) + (lambda () (setq copy-mode-called t)))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 0 + "/dev/pts/8\t%8\n")) + (cj/term-copy-mode-dwim) + (should (equal sent '("\C-b["))) + (should-not copy-mode-called)))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(ert-deftest test-term-copy-mode-dwim-falls-back-without-tmux () + "Boundary: without tmux, dwim calls `ghostel-copy-mode' and sends nothing." + (let ((agent (cj/test--make-fake-ghostel-buffer "agent [emacs.d]")) + (sent nil) + (copy-mode-called nil)) + (unwind-protect + (with-current-buffer agent + (cl-letf (((symbol-function 'get-buffer-process) + (lambda (_buffer) 'fake-process)) + ((symbol-function 'process-tty-name) + (lambda (_process) "/dev/pts/8")) + ((symbol-function 'ghostel-send-string) + (lambda (s) (push s sent))) + ((symbol-function 'ghostel-copy-mode) + (lambda () (setq copy-mode-called t)))) + (test-term-tmux-history--with-tmux-mock + '((("list-clients" "-F" "#{client_tty}\t#{pane_id}") 1 + "no server running")) + (cj/term-copy-mode-dwim) + (should-not sent) + (should copy-mode-called)))) + (when (buffer-live-p agent) + (kill-buffer agent))))) + +(provide 'test-term-tmux-history) +;;; test-term-tmux-history.el ends here |
