diff options
Diffstat (limited to 'tests/test-duet-transfer-exec.el')
| -rw-r--r-- | tests/test-duet-transfer-exec.el | 510 |
1 files changed, 510 insertions, 0 deletions
diff --git a/tests/test-duet-transfer-exec.el b/tests/test-duet-transfer-exec.el new file mode 100644 index 0000000..f93a4fa --- /dev/null +++ b/tests/test-duet-transfer-exec.el @@ -0,0 +1,510 @@ +;;; test-duet-transfer-exec.el --- Tests for the transfer execution engine -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Process-boundary tests for the Phase 5 transfer engine: the serial queue +;; and its concurrency limit, the bounded output filter, throttled log +;; rendering, terminal-result classification, coalesced pane refresh, +;; cancellation with cleanup verification, the failure explainer, and the +;; log-record schema. The process boundary is stubbed (a no-op launcher and +;; injected result plists), so no real subprocess runs here; real-rsync +;; coverage lives in test-integration-duet-transfer.el. + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) + +;; A clean engine per test: fresh counters, empty queue/history, no live +;; timers, stall detection off (mark-stalled is exercised directly), a no-op +;; launcher so `duet--start-transfer' spawns nothing. Real render/refresh +;; timers created during a test are cancelled on exit so they never fire into +;; the next one. +(defmacro duet-test--with-clean-engine (&rest body) + (declare (indent 0)) + `(let ((duet--transfer-id-counter 0) + (duet--transfers nil) + (duet--transfer-queue nil) + (duet--log-render-timer nil) + (duet--refresh-pending nil) + (duet--refresh-timer nil) + (duet-transfer-stall-timeout nil) + (duet-max-concurrent-transfers 1) + (duet--transfer-launcher (lambda (_tr) nil))) + (unwind-protect (progn ,@body) + (when (timerp duet--log-render-timer) (cancel-timer duet--log-render-timer)) + (when (timerp duet--refresh-timer) (cancel-timer duet--refresh-timer))))) + +(defun duet-test--argv-spec (&rest overrides) + "A minimal runnable local rsync transfer-spec, with plist OVERRIDES applied." + (append overrides + (list :backend 'rsync :route :local + :sources '("/src/a.txt") + :destination-directory "/dst" + :argv '("rsync" "-a" "/src/a.txt" "/dst")))) + +;;; Transfer records and ids + +(ert-deftest test-duet-exec-make-transfer-assigns-monotonic-id () + (duet-test--with-clean-engine + (let ((a (duet--make-transfer (duet-test--argv-spec))) + (b (duet--make-transfer (duet-test--argv-spec)))) + (should (= 1 (duet-transfer-id a))) + (should (= 2 (duet-transfer-id b))) + (should (eq 'queued (duet-transfer-status a))) + (should (eq 'rsync (duet-transfer-backend a))) + (should (eq :local (duet-transfer-route a))) + (should (equal '("/src/") (duet-transfer-source-directories a)))))) + +;;; Queue + concurrency limit + +(ert-deftest test-duet-exec-queue-runs-one-at-a-time () + (duet-test--with-clean-engine + (let* ((duet-max-concurrent-transfers 1) + (a (duet--run-transfer (duet-test--argv-spec))) + (b (duet--run-transfer (duet-test--argv-spec)))) + (should (eq 'running (duet-transfer-status a))) + (should (eq 'queued (duet-transfer-status b))) + (duet--transfer-handle-result a '(:exit 0)) + (should (eq 'done (duet-transfer-status a))) + (should (eq 'running (duet-transfer-status b))) + (should (not (memq a duet--transfer-queue))) + (should (memq a duet--transfers))))) + +(ert-deftest test-duet-exec-queue-honors-max-concurrent () + (duet-test--with-clean-engine + (let ((duet-max-concurrent-transfers 2)) + (let ((a (duet--run-transfer (duet-test--argv-spec))) + (b (duet--run-transfer (duet-test--argv-spec))) + (c (duet--run-transfer (duet-test--argv-spec)))) + (should (eq 'running (duet-transfer-status a))) + (should (eq 'running (duet-transfer-status b))) + (should (eq 'queued (duet-transfer-status c))))))) + +(ert-deftest test-duet-exec-run-transfer-refuses-deferred-specs () + (duet-test--with-clean-engine + (should-error + (duet--run-transfer '(:backend tramp :route :local-remote :tramp t + :destination-directory "/dst")) + :type 'user-error) + (should-error + (duet--run-transfer '(:backend rsync :route :remote-roundtrip + :exec-mode rsync-remote-to-remote + :destination-directory "/dst")) + :type 'user-error))) + +;;; Bounded output filter + +(ert-deftest test-duet-exec-filter-bounds-retained-output () + (duet-test--with-clean-engine + (let ((duet-transfer-stderr-limit 10) + (tr (duet--make-transfer (duet-test--argv-spec)))) + (duet--transfer-filter tr "abcdefgh") + (duet--transfer-filter tr "ijklmnop") + (should (= 10 (length (duet-transfer-stderr tr)))) + (should (string= "ghijklmnop" (duet-transfer-stderr tr))) + (should (= 2 (duet-transfer-output-count tr))) + (should (= 16 (duet-transfer-stderr-bytes tr)))))) + +;;; Throttled log render + +(ert-deftest test-duet-exec-render-throttled-to-one-timer () + (duet-test--with-clean-engine + (let ((timer-calls 0)) + (cl-letf (((symbol-function 'run-with-timer) + (lambda (&rest _) (cl-incf timer-calls) 'fake-timer))) + (let ((tr (duet--make-transfer (duet-test--argv-spec)))) + (duet--transfer-filter tr "x") + (duet--transfer-filter tr "y") + (duet--transfer-filter tr "z") + (should (= 1 timer-calls)) + (should (eq 'fake-timer duet--log-render-timer))))))) + +(ert-deftest test-duet-exec-render-rearms-after-flush () + (duet-test--with-clean-engine + (let ((timer-calls 0)) + (cl-letf (((symbol-function 'run-with-timer) + (lambda (&rest _) (cl-incf timer-calls) 'fake-timer))) + (duet--schedule-log-render) + (should (= 1 timer-calls)) + (duet--render-transfer-log) + (should (null duet--log-render-timer)) + (duet--schedule-log-render) + (should (= 2 timer-calls)))))) + +;;; Stall flagging + +(ert-deftest test-duet-exec-stall-marks-and-recovers () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (should (eq 'running (duet-transfer-status tr))) + (duet--mark-stalled tr) + (should (eq 'stalled (duet-transfer-status tr))) + ;; a stalled transfer still occupies a slot + (should (memq tr (duet--running-transfers))) + (duet--transfer-filter tr "more output") + (should (eq 'running (duet-transfer-status tr)))))) + +(ert-deftest test-duet-exec-mark-stalled-only-when-running () + (duet-test--with-clean-engine + (let ((tr (duet--make-transfer (duet-test--argv-spec)))) + (setf (duet-transfer-status tr) 'done) + (duet--mark-stalled tr) + (should (eq 'done (duet-transfer-status tr)))))) + +;;; Terminal-result classification + +(ert-deftest test-duet-exec-handle-result-success () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:exit 0)) + (should (eq 'done (duet-transfer-status tr))) + (should (null (duet-transfer-failure tr))) + (should (= 0 (duet-transfer-exit tr))) + (should (duet-transfer-cleanup-verified tr))))) + +(ert-deftest test-duet-exec-handle-result-nonzero-exit-normalizes () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (setf (duet-transfer-stderr tr) "rsync: mkstemp failed: Permission denied (13)") + (duet--transfer-handle-result tr '(:exit 23)) + (should (eq 'failed (duet-transfer-status tr))) + (should (eq 'permission-denied (plist-get (duet-transfer-failure tr) :class))) + (should (= 23 (duet-transfer-exit tr)))))) + +(ert-deftest test-duet-exec-handle-result-signal-fails () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:signal 15)) + (should (eq 'failed (duet-transfer-status tr))) + (should (eq 'cancelled (plist-get (duet-transfer-failure tr) :class))) + (should (= 15 (duet-transfer-signal tr)))))) + +(ert-deftest test-duet-exec-handle-result-launch-error-fails () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:launch-error "No such file or directory")) + (should (eq 'failed (duet-transfer-status tr))) + (should (eq 'launch-failure (plist-get (duet-transfer-failure tr) :class)))))) + +(ert-deftest test-duet-exec-start-transfer-launch-error-is-caught () + (duet-test--with-clean-engine + (let ((duet--transfer-launcher + (lambda (_tr) (error "boom: cannot spawn")))) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (should (eq 'failed (duet-transfer-status tr))) + (should (eq 'launch-failure (plist-get (duet-transfer-failure tr) :class))))))) + +;;; Coalesced pane refresh + +(ert-deftest test-duet-exec-refresh-coalesced-to-one-timer () + (duet-test--with-clean-engine + (let ((timer-calls 0)) + (cl-letf (((symbol-function 'run-with-timer) + (lambda (&rest _) (cl-incf timer-calls) 'fake-timer))) + (duet--schedule-pane-refresh "/a") + (duet--schedule-pane-refresh "/a") + (duet--schedule-pane-refresh "/b") + (should (= 1 timer-calls)) + (should (= 2 (length duet--refresh-pending))) + (should (member "/a/" duet--refresh-pending)) + (should (member "/b/" duet--refresh-pending)))))) + +(ert-deftest test-duet-exec-success-schedules-dest-refresh () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'run-with-timer) (lambda (&rest _) 'fake-timer))) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:exit 0)) + (should (member "/dst/" duet--refresh-pending)))))) + +(ert-deftest test-duet-exec-move-success-schedules-source-and-dest () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'run-with-timer) (lambda (&rest _) 'fake-timer))) + (let ((tr (duet--run-transfer + (duet-test--argv-spec :sources '("/src/a.txt")) + t))) + (duet--transfer-handle-result tr '(:exit 0)) + (should (member "/dst/" duet--refresh-pending)) + (should (member "/src/" duet--refresh-pending)))))) + +;;; Cancellation + cleanup verification + +(ert-deftest test-duet-exec-cancel-transitions-to-cancelling () + (duet-test--with-clean-engine + (let ((killed nil)) + (cl-letf (((symbol-function 'duet--kill-process) + (lambda (_tr) (setq killed t)))) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--cancel-transfer tr) + (should (eq 'cancelling (duet-transfer-status tr))) + (should killed)))))) + +(ert-deftest test-duet-exec-cancel-clean-temp-finishes-failed () + (duet-test--with-clean-engine + (let ((duet--temp-file-lister (lambda (_tr) nil))) + (cl-letf (((symbol-function 'duet--kill-process) #'ignore)) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--cancel-transfer tr) + (duet--transfer-handle-result tr '(:signal 15)) + (should (eq 'failed (duet-transfer-status tr))) + (should (duet-transfer-cleanup-verified tr))))))) + +(ert-deftest test-duet-exec-cancel-stray-temp-flags-cleanup-unverified () + (duet-test--with-clean-engine + (let ((duet--temp-file-lister (lambda (_tr) '("/dst/.a.txt.AbCdEf")))) + (cl-letf (((symbol-function 'duet--kill-process) #'ignore)) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--cancel-transfer tr) + (duet--transfer-handle-result tr '(:signal 15)) + (should (eq 'cleanup-unverified (duet-transfer-status tr))) + (should-not (duet-transfer-cleanup-verified tr))))))) + +(ert-deftest test-duet-exec-cancel-terminal-transfer-is-noop () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:exit 0)) + (duet--cancel-transfer tr) + (should (eq 'done (duet-transfer-status tr)))))) + +;;; Failure explanation + +(ert-deftest test-duet-exec-format-failure-explanation () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (setf (duet-transfer-stderr tr) "rsync: Permission denied (13)") + (duet--transfer-handle-result tr '(:exit 23)) + (let ((s (duet--format-failure-explanation tr))) + (should (string-match-p "permission denied" s)) + (should (string-match-p "fix-permissions" s)) + (should (string-match-p "Source unchanged" s)))))) + +(ert-deftest test-duet-exec-format-explanation-success () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:exit 0)) + (should (string-match-p "completed successfully" + (duet--format-failure-explanation tr)))))) + +;;; Log-record schema + +(ert-deftest test-duet-exec-log-record-schema () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:exit 0)) + (let ((rec (duet--transfer-log-record tr))) + (should (= (duet-transfer-id tr) (plist-get rec :id))) + (should (eq 'rsync (plist-get rec :backend))) + (should (eq :local (plist-get rec :route))) + (should (stringp (plist-get rec :argv))) + (should (eq 'done (plist-get rec :status))))))) + +(ert-deftest test-duet-exec-log-record-redacts-argv () + (duet-test--with-clean-engine + (duet-register-backend + (duet-backend-create + :name 'secret-tool + :handles (lambda (_s _d) nil) + :command (lambda (_s _d _o) '(:argv ("x"))) + :redaction '("\\(--password=\\)\\S-+") + :cleanup :none)) + (let ((tr (duet--make-transfer + '(:backend secret-tool :route :local :destination-directory "/dst" + :argv ("tool" "--password=hunter2" "/dst"))))) + (let ((argv (plist-get (duet--transfer-log-record tr) :argv))) + (should (string-match-p "--password=<redacted>" argv)) + (should-not (string-match-p "hunter2" argv)))))) + +;;; Default stray-temp lister (real tmp dir, no subprocess) + +(ert-deftest test-duet-exec-list-stray-temp-files-matches-pattern () + (duet-test--with-clean-engine + (let ((dir (make-temp-file "duet-temp-" t))) + (unwind-protect + (progn + (write-region "" nil (expand-file-name ".a.txt.AbCdEf" dir)) + (write-region "" nil (expand-file-name "a.txt" dir)) + (let* ((tr (duet--make-transfer + (duet-test--argv-spec :destination-directory dir))) + (strays (duet--list-stray-temp-files tr))) + (should (= 1 (length strays))) + (should (string-match-p "\\.a\\.txt\\.AbCdEf\\'" (car strays))))) + (delete-directory dir t))))) + +;;; Thin interactive glue + +(ert-deftest test-duet-exec-submit-builds-and-runs () + (duet-test--with-clean-engine + (let ((tr (duet--submit-transfer '("/src/a.txt") "/dst" nil))) + (should (duet-transfer-p tr)) + (should (eq 'rsync (duet-transfer-backend tr))) + (should (member "/src/a.txt" (plist-get (duet-transfer-spec tr) :sources)))))) + +(ert-deftest test-duet-exec-submit-no-sources-errors () + (duet-test--with-clean-engine + (should-error (duet--submit-transfer nil "/dst" nil) :type 'user-error))) + +(ert-deftest test-duet-copy-and-move-submit-via-engine () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'dired-get-marked-files) (lambda (&rest _) '("/src/a.txt"))) + ((symbol-function 'duet--other-pane) (lambda (&rest _) 'win)) + ((symbol-function 'duet--pane-directory) (lambda (_w) "/dst")) + ((symbol-function 'display-buffer) (lambda (&rest _) nil))) + (duet-copy) + (should (= 1 (length duet--transfers))) + (should-not (duet-transfer-move-p (car duet--transfers))) + (duet-move) + (should (= 2 (length duet--transfers))) + (should (duet-transfer-move-p (car duet--transfers)))))) + +(ert-deftest test-duet-mkdir-creates-and-refreshes () + (duet-test--with-clean-engine + (let ((made nil)) + (cl-letf (((symbol-function 'make-directory) (lambda (d &rest _) (setq made d))) + ((symbol-function 'run-with-timer) (lambda (&rest _) 'fake-timer))) + (let ((default-directory "/work/")) + (duet-mkdir "newdir") + (should (string-match-p "newdir" made)) + (should (member "/work/" duet--refresh-pending))))))) + +(ert-deftest test-duet-delete-trashes-files-and-directories () + "Marked files go through `delete-file' and directories through +`delete-directory', both to trash, then the active pane refreshes." + (duet-test--with-clean-engine + (let ((trashed-files nil) (removed-dirs nil)) + (cl-letf (((symbol-function 'dired-get-marked-files) + (lambda (&rest _) '("/work/a.txt" "/work/sub"))) + ((symbol-function 'yes-or-no-p) (lambda (&rest _) t)) + ((symbol-function 'file-directory-p) (lambda (f) (string= f "/work/sub"))) + ((symbol-function 'delete-file) (lambda (f &optional _trash) (push f trashed-files))) + ((symbol-function 'delete-directory) (lambda (d &rest _) (push d removed-dirs))) + ((symbol-function 'run-with-timer) (lambda (&rest _) 'fake-timer))) + (let ((default-directory "/work/")) + (duet-delete) + (should (member "/work/a.txt" trashed-files)) + (should (member "/work/sub" removed-dirs)) + (should (member "/work/" duet--refresh-pending))))))) + +(ert-deftest test-duet-cancel-transfer-command-no-active-errors () + (duet-test--with-clean-engine + (should-error (duet-cancel-transfer) :type 'user-error))) + +(ert-deftest test-duet-cancel-transfer-command-cancels-latest () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'duet--kill-process) #'ignore) + ((symbol-function 'message) (lambda (&rest _) nil))) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet-cancel-transfer) + (should (eq 'cancelling (duet-transfer-status tr))))))) + +(ert-deftest test-duet-explain-transfer-failure-no-transfers-errors () + (duet-test--with-clean-engine + (should-error (duet-explain-transfer-failure) :type 'user-error))) + +(ert-deftest test-duet-explain-transfer-failure-shows-buffer () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'display-buffer) (lambda (b &rest _) b))) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (setf (duet-transfer-stderr tr) "rsync: Permission denied (13)") + (duet--transfer-handle-result tr '(:exit 23)) + (duet-explain-transfer-failure) + (with-current-buffer "*DUET Transfer Failure*" + (should (string-match-p "fix-permissions" (buffer-string)))))))) + +;;; Timer-arming and cancellation branches + +(ert-deftest test-duet-exec-arm-stall-timer-when-enabled () + (duet-test--with-clean-engine + (let ((duet-transfer-stall-timeout 60)) + (cl-letf (((symbol-function 'run-with-timer) (lambda (&rest _) 'armed))) + (let ((tr (duet--make-transfer (duet-test--argv-spec)))) + (duet--arm-stall-timer tr) + (should (eq 'armed (duet-transfer-stall-timer tr)))))))) + +(ert-deftest test-duet-exec-cancel-stall-timer-cancels-real-timer () + (duet-test--with-clean-engine + (let ((tr (duet--make-transfer (duet-test--argv-spec)))) + (setf (duet-transfer-stall-timer tr) (run-with-timer 100 nil #'ignore)) + (duet--cancel-stall-timer tr) + (should (null (duet-transfer-stall-timer tr)))))) + +(ert-deftest test-duet-exec-render-cancels-real-timer () + (duet-test--with-clean-engine + (setq duet--log-render-timer (run-with-timer 100 nil #'ignore)) + (duet--render-transfer-log) + (should (null duet--log-render-timer)))) + +;;; Pane-refresh execution + +(ert-deftest test-duet-exec-do-pane-refresh-clears-pending () + (duet-test--with-clean-engine + (let ((refreshed nil)) + (cl-letf (((symbol-function 'duet--refresh-dir) (lambda (d) (push d refreshed)))) + (setq duet--refresh-pending '("/x/" "/y/") + duet--refresh-timer (run-with-timer 100 nil #'ignore)) + (duet--do-pane-refresh) + (should (null duet--refresh-pending)) + (should (null duet--refresh-timer)) + (should (= 2 (length refreshed))))))) + +(ert-deftest test-duet-exec-refresh-dir-reverts-dired-buffer () + (duet-test--with-clean-engine + (let ((dir (make-temp-file "duet-refresh-" t))) + (unwind-protect + (let ((buf (dired-noselect dir))) + (unwind-protect + (progn + (duet--refresh-dir dir) + (should (buffer-live-p buf))) + (kill-buffer buf))) + (delete-directory dir t))))) + +;;; delete-source on a directory + +(ert-deftest test-duet-exec-delete-source-removes-directory () + (duet-test--with-clean-engine + (let ((dir (make-temp-file "duet-del-" t))) + (should (file-directory-p dir)) + (duet--delete-source dir) + (should-not (file-exists-p dir))))) + +;;; Safety-text variants + +(ert-deftest test-duet-exec-safety-text-variants () + (should (string= "ok" (duet--safety-text "ok"))) + (should (string-match-p "Outcome unverified" (duet--safety-text :generic))) + (should (string-match-p "Unknown" (duet--safety-text 'something-else)))) + +;;; submit with an empty backend registry + +(ert-deftest test-duet-exec-submit-no-backend-errors () + (duet-test--with-clean-engine + (let ((duet--backend-registry nil)) + (should-error (duet--submit-transfer '("/src/a.txt") "/dst" nil) + :type 'user-error)))) + +;;; delete edge cases + +(ert-deftest test-duet-delete-no-files-errors () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'dired-get-marked-files) (lambda (&rest _) nil))) + (let ((default-directory "/work/")) + (should-error (duet-delete) :type 'user-error))))) + +(provide 'test-duet-transfer-exec) +;;; test-duet-transfer-exec.el ends here |
