aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test-agenda-query--render.el11
-rw-r--r--tests/test-bootstrap-packages.bats142
-rw-r--r--tests/test-calendar-sync--batch-failures.el47
-rw-r--r--tests/test-calendar-sync--batch-report.el89
-rw-r--r--tests/test-calendar-sync--batch-results.el59
-rw-r--r--tests/test-calendar-sync--batch-wait.el68
-rw-r--r--tests/test-calendar-sync--sync-dispatch.el39
-rw-r--r--tests/test-calendar-sync-run.bats116
-rw-r--r--tests/test-calibredb-epub-config.el5
-rw-r--r--tests/test-config-utilities--compile-this-elisp-buffer.el138
-rw-r--r--tests/test-dirvish-config--side-key.el40
-rw-r--r--tests/test-google-keep-config--local-config.el52
-rw-r--r--tests/test-init-defer-games.el31
-rw-r--r--tests/test-integration-recurring-events.el17
-rw-r--r--tests/test-keybindings--fullscreen.el45
-rw-r--r--tests/test-music-config--append-track-to-m3u-file.el231
-rw-r--r--tests/test-org-capture-config-fkey-guard.el15
-rw-r--r--tests/test-package-resilience.el504
-rw-r--r--tests/test-prog-general-yas-activation.el6
-rw-r--r--tests/test-setup-telega.bats4
-rw-r--r--tests/test-system-defaults--warning-display-dead-buffer.el103
-rw-r--r--tests/test-system-defaults-functions.el6
-rw-r--r--tests/test-system-lib-auth-source-secret-value.el39
-rw-r--r--tests/test-telega-config--docker-pin.el141
-rw-r--r--tests/test-telega-config.el4
-rw-r--r--tests/test-term-tmux-detach.el62
-rw-r--r--tests/test-transcription-config--ratio-backend.el102
-rw-r--r--tests/test-undead-buffers--native-comp-log-undead.el92
-rw-r--r--tests/test-undead-buffers--warnings-undead.el63
-rw-r--r--tests/test-video-audio-recording--keybindings.el103
30 files changed, 2299 insertions, 75 deletions
diff --git a/tests/test-agenda-query--render.el b/tests/test-agenda-query--render.el
index 48d28773..da5c3f60 100644
--- a/tests/test-agenda-query--render.el
+++ b/tests/test-agenda-query--render.el
@@ -162,10 +162,17 @@ still parses as JSON, which is the worst kind."
Pinning the failure mode, not endorsing it. This is what the surface showed
before the batch writer learned the vocabulary, and it is why the test above
exists."
+ ;; No priority cookie in this fixture. org 9.8 (Emacs 31.1) parses the
+ ;; cookie with `org-priority-regexp' under `looking-at', and that regexp's
+ ;; lazy `.*?' prefix swallows everything between the stars and the cookie,
+ ;; unknown keyword included. A cookie here would test org's bug rather than
+ ;; the vocabulary gap this test pins.
(let ((org-todo-keywords '((sequence "TODO" "|" "DONE"))))
(test-aq-render--with-agenda-file
- "* DOING [#A] Justin Johns advisor projects\nSCHEDULED: <2026-07-31 Fri 09:00>\n"
- (should (string-prefix-p "DOING" (alist-get 't (car rows)))))))
+ "* DOING Justin Johns advisor projects\nSCHEDULED: <2026-07-31 Fri 09:00>\n"
+ (let ((row (car rows)))
+ (should (string-prefix-p "DOING" (alist-get 't row)))
+ (should-not (equal "DOING" (alist-get 'keyword row)))))))
;;; ---------- the cache writer ----------
diff --git a/tests/test-bootstrap-packages.bats b/tests/test-bootstrap-packages.bats
new file mode 100644
index 00000000..7e511152
--- /dev/null
+++ b/tests/test-bootstrap-packages.bats
@@ -0,0 +1,142 @@
+#!/usr/bin/env bats
+# Tests for scripts/bootstrap-packages.sh — the headless package installer.
+#
+# The elisp tests cover what happens inside one Emacs. What only a shell test
+# can cover is the pass loop: whether a run that reports packages still missing
+# gets another pass, whether a run that converges stops early, and whether a
+# broken init breaks out instead of burning every pass on the same failure.
+#
+# Every test drives a fake emacs whose exit statuses are scripted, so no test
+# touches the network, the real elpa directory, or a real Emacs. The script
+# honours $EMACS, which is the seam these hang on.
+
+setup() {
+ SCRIPT="${BATS_TEST_DIRNAME}/../scripts/bootstrap-packages.sh"
+ BIN="${BATS_TEST_TMPDIR}/bin"
+ COUNTER="${BATS_TEST_TMPDIR}/attempts"
+ mkdir -p "$BIN"
+ echo 0 >"$COUNTER"
+ export BOOTSTRAP_PASSES=3
+ export BOOTSTRAP_TIMEOUT=30
+ # Point the script at a scratch config dir rather than the real checkout, so
+ # the byte-compiled-modules check reads fixture state instead of whatever
+ # this working tree happens to have compiled.
+ export BOOTSTRAP_DIR="${BATS_TEST_TMPDIR}/emacsd"
+ mkdir -p "$BOOTSTRAP_DIR/modules"
+}
+
+# Write a fake emacs that exits with the given statuses in order, repeating the
+# last one once the list runs out. Status 1 also prints the "still missing"
+# line the real cj/package-bootstrap-batch prints, so the script's grep is
+# exercised rather than assumed.
+fake_emacs() {
+ {
+ echo '#!/usr/bin/env bash'
+ echo "n=\$(cat '$COUNTER')"
+ echo "n=\$((n + 1))"
+ echo "echo \$n >'$COUNTER'"
+ echo "statuses=($*)"
+ echo 'idx=$((n - 1))'
+ echo 'last=$((${#statuses[@]} - 1))'
+ echo '[ $idx -gt $last ] && idx=$last'
+ echo 'status=${statuses[$idx]}'
+ echo '[ "$status" -eq 1 ] && echo "package-bootstrap: 2 missing: foo bar"'
+ echo 'exit $status'
+ } >"$BIN/emacs"
+ chmod +x "$BIN/emacs"
+ export EMACS="$BIN/emacs"
+}
+
+attempts() { cat "$COUNTER"; }
+
+@test "normal: a clean first pass succeeds and stops there" {
+ fake_emacs 0
+ run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(attempts)" -eq 1 ]
+ [[ "$output" == *"every package is installed"* ]]
+}
+
+@test "normal: a pass reporting missing packages is retried until it converges" {
+ fake_emacs 1 0
+ run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(attempts)" -eq 2 ]
+ [[ "$output" == *"2 missing: foo bar"* ]]
+}
+
+@test "error: packages that never install exhaust the passes and fail" {
+ fake_emacs 1
+ run bash "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [ "$(attempts)" -eq 3 ]
+ [[ "$output" == *"FAILED"* ]]
+}
+
+@test "error: exit 1 without a missing-packages line is not blamed on packages" {
+ # The fake exits 1 silently, which is any other failure, not a short install.
+ {
+ echo '#!/usr/bin/env bash'
+ echo "n=\$(cat '$COUNTER'); echo \$((n + 1)) >'$COUNTER'"
+ echo 'exit 1'
+ } >"$BIN/emacs"
+ chmod +x "$BIN/emacs"
+ export EMACS="$BIN/emacs"
+ run bash "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [ "$(attempts)" -eq 1 ]
+ [[ "$output" == *"without reporting missing packages"* ]]
+}
+
+@test "error: a broken init breaks out instead of burning every pass" {
+ fake_emacs 255
+ run bash "$SCRIPT"
+ [ "$status" -eq 255 ]
+ [ "$(attempts)" -eq 1 ]
+ [[ "$output" == *"failed to load init"* ]]
+}
+
+@test "boundary: a timed-out pass is reported and still retried" {
+ fake_emacs 124 0
+ run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(attempts)" -eq 2 ]
+ [[ "$output" == *"timeout"* ]]
+}
+
+@test "boundary: the pass ceiling is honoured" {
+ export BOOTSTRAP_PASSES=1
+ fake_emacs 1
+ run bash "$SCRIPT"
+ [ "$status" -eq 1 ]
+ [ "$(attempts)" -eq 1 ]
+}
+
+@test "error: a byte-compiled tree is refused rather than passed vacuously" {
+ touch "$BOOTSTRAP_DIR/modules/foo.elc"
+ fake_emacs 0
+ run bash "$SCRIPT"
+ [ "$status" -eq 2 ]
+ [ "$(attempts)" -eq 0 ]
+ [[ "$output" == *"REFUSING"* ]]
+ [[ "$output" == *"clean-compiled"* ]]
+ [[ "$output" != *"every package is installed"* ]]
+}
+
+@test "boundary: a zero pass ceiling fails cleanly without a tail error" {
+ export BOOTSTRAP_PASSES=0
+ fake_emacs 0
+ run bash "$SCRIPT"
+ [ "$status" -ne 0 ]
+ [ "$(attempts)" -eq 0 ]
+ [[ "$output" != *"cannot open"* ]]
+ [[ "$output" == *"FAILED after 0 pass"* ]]
+}
+
+@test "boundary: recovery on the final allowed pass still succeeds" {
+ export BOOTSTRAP_PASSES=3
+ fake_emacs 1 1 0
+ run bash "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [ "$(attempts)" -eq 3 ]
+}
diff --git a/tests/test-calendar-sync--batch-failures.el b/tests/test-calendar-sync--batch-failures.el
new file mode 100644
index 00000000..3190be21
--- /dev/null
+++ b/tests/test-calendar-sync--batch-failures.el
@@ -0,0 +1,47 @@
+;;; test-calendar-sync--batch-failures.el --- Batch failure filter tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `calendar-sync--batch-failures' picks the rows that did not finish cleanly.
+;; The batch runner's exit code is derived from it, and systemd reads that exit
+;; code, so the rule is deliberately strict: only `ok' passes. A calendar left
+;; `syncing' at the timeout, or one that never started, is a failure -- both
+;; states mean the org file on disk is not the calendar's current contents,
+;; which is exactly the silent staleness the timer exists to prevent.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calendar-sync)
+
+(ert-deftest test-calendar-sync-batch-failures-keeps-only-non-ok ()
+ "Normal: an errored calendar is returned and a healthy one is not."
+ (should (equal (calendar-sync--batch-failures
+ '(("google" . ok) ("proton" . error)))
+ '(("proton" . error)))))
+
+(ert-deftest test-calendar-sync-batch-failures-all-ok-is-empty ()
+ "Normal: a fully successful run reports no failures."
+ (should (equal (calendar-sync--batch-failures
+ '(("google" . ok) ("proton" . ok)))
+ '())))
+
+(ert-deftest test-calendar-sync-batch-failures-empty-input-is-empty ()
+ "Boundary: no rows in, no rows out."
+ (should (equal (calendar-sync--batch-failures '()) '())))
+
+(ert-deftest test-calendar-sync-batch-failures-timeout-counts-as-failure ()
+ "Error: a calendar still `syncing' when the wait expired is a failure.
+Its org file was not rewritten, so reporting success would hide the staleness."
+ (should (equal (calendar-sync--batch-failures
+ '(("google" . ok) ("proton" . syncing)))
+ '(("proton" . syncing)))))
+
+(ert-deftest test-calendar-sync-batch-failures-never-counts-as-failure ()
+ "Error: a calendar that never started is a failure, not a skip."
+ (should (equal (calendar-sync--batch-failures '(("google" . never)))
+ '(("google" . never)))))
+
+(provide 'test-calendar-sync--batch-failures)
+;;; test-calendar-sync--batch-failures.el ends here
diff --git a/tests/test-calendar-sync--batch-report.el b/tests/test-calendar-sync--batch-report.el
new file mode 100644
index 00000000..12811200
--- /dev/null
+++ b/tests/test-calendar-sync--batch-report.el
@@ -0,0 +1,89 @@
+;;; test-calendar-sync--batch-report.el --- Batch report output tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `calendar-sync-batch-run-and-report' is what the systemd timer runs, so its
+;; printed rows are the only record that survives the process. Batch Emacs
+;; discards *Messages* at exit, which is where the interactive failure path
+;; logs its reason -- so a failed row has to carry its recorded `:last-error'
+;; in the printed output or the journal shows "error" with no way to tell a
+;; cold gpg-agent from a revoked feed token or a dead network.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib) ;; cl-letf; calendar-sync pulls it in transitively, don't rely on that
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calendar-sync)
+
+(defun test-calendar-sync-batch-report--capture (states results)
+ "Return the report's printed output for STATES and RESULTS.
+STATES is an alist of NAME . PLIST seeded into the state table; RESULTS is
+what `calendar-sync-batch-run' is stubbed to return, so the report is
+exercised without driving a real sync."
+ (let ((calendar-sync--calendar-states (make-hash-table :test 'equal)))
+ (dolist (entry states)
+ (puthash (car entry) (cdr entry) calendar-sync--calendar-states))
+ (cl-letf (((symbol-function 'calendar-sync-batch-run)
+ (lambda (&rest _) results)))
+ (with-output-to-string
+ (calendar-sync-batch-run-and-report)))))
+
+;;; Normal
+
+(ert-deftest test-calendar-sync-batch-report-failed-row-carries-its-reason ()
+ "Normal: a failed calendar prints the recorded `:last-error' reason.
+Without it the journal records only \"error\", and the operator cannot tell a
+cold gpg-agent from a revoked token without re-running the sync by hand."
+ (let ((out (test-calendar-sync-batch-report--capture
+ '(("google" . (:status error :last-error "Decryption failed"))
+ ("proton" . (:status ok)))
+ '(("google" . error) ("proton" . ok)))))
+ (should (string-match-p "google: error" out))
+ (should (string-match-p "Decryption failed" out))))
+
+(ert-deftest test-calendar-sync-batch-report-ok-row-stays-bare ()
+ "Normal: a calendar that synced prints its status and nothing more.
+A stale `:last-error' from an earlier failure must not be appended to a row
+that succeeded this run."
+ (let ((out (test-calendar-sync-batch-report--capture
+ '(("google" . (:status ok :last-error "Decryption failed")))
+ '(("google" . ok)))))
+ (should (string-match-p "google: ok" out))
+ (should-not (string-match-p "Decryption failed" out))))
+
+;;; Boundary
+
+(ert-deftest test-calendar-sync-batch-report-failure-without-reason-still-prints ()
+ "Boundary: a failed row with no recorded reason prints its status alone.
+`never' and `syncing' never record a `:last-error', so the reason lookup has
+to tolerate nil rather than printing \"nil\" or signalling."
+ (let ((out (test-calendar-sync-batch-report--capture
+ '(("google" . (:status syncing)))
+ '(("google" . syncing) ("absent" . never)))))
+ (should (string-match-p "google: syncing" out))
+ (should (string-match-p "absent: never" out))
+ (should-not (string-match-p "nil" out))))
+
+;;; Error
+
+(defun test-calendar-sync-batch-report--exit-code (results)
+ "Return the report's exit code for RESULTS, discarding its printed output."
+ (let ((calendar-sync--calendar-states (make-hash-table :test 'equal)))
+ (cl-letf (((symbol-function 'calendar-sync-batch-run)
+ (lambda (&rest _) results)))
+ (with-temp-buffer
+ (let ((standard-output (current-buffer)))
+ (calendar-sync-batch-run-and-report))))))
+
+(ert-deftest test-calendar-sync-batch-report-exit-code-tracks-failures ()
+ "Error: the return value becomes the process exit code, so it stays 1 on any
+non-ok row and 0 only when every calendar synced. Appending the reason to the
+printed line must not disturb it."
+ (should (equal 1 (test-calendar-sync-batch-report--exit-code '(("google" . error)))))
+ (should (equal 1 (test-calendar-sync-batch-report--exit-code
+ '(("google" . ok) ("proton" . never)))))
+ (should (equal 0 (test-calendar-sync-batch-report--exit-code '(("google" . ok))))))
+
+(provide 'test-calendar-sync--batch-report)
+;;; test-calendar-sync--batch-report.el ends here
diff --git a/tests/test-calendar-sync--batch-results.el b/tests/test-calendar-sync--batch-results.el
new file mode 100644
index 00000000..03ee2aee
--- /dev/null
+++ b/tests/test-calendar-sync--batch-results.el
@@ -0,0 +1,59 @@
+;;; test-calendar-sync--batch-results.el --- Batch result collection tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; `calendar-sync--batch-results' reads the per-calendar state table and
+;; returns one (NAME . STATUS) pair per requested calendar. The batch runner
+;; turns that into an exit code, so a calendar that never reached the table at
+;; all has to read as `never' rather than nil -- a nil status would compare
+;; equal to nothing and quietly drop out of the failure count.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calendar-sync)
+
+(defun test-calendar-sync-batch-results--with-states (states body)
+ "Run BODY with STATES (an alist of NAME . PLIST) in the state table."
+ (let ((calendar-sync--calendar-states (make-hash-table :test 'equal)))
+ (dolist (entry states)
+ (puthash (car entry) (cdr entry) calendar-sync--calendar-states))
+ (funcall body)))
+
+(ert-deftest test-calendar-sync-batch-results-reports-each-status ()
+ "Normal: every requested calendar comes back with its recorded status."
+ (test-calendar-sync-batch-results--with-states
+ '(("google" . (:status ok))
+ ("proton" . (:status error :last-error "boom")))
+ (lambda ()
+ (should (equal (calendar-sync--batch-results '("google" "proton"))
+ '(("google" . ok) ("proton" . error)))))))
+
+(ert-deftest test-calendar-sync-batch-results-empty-names-is-empty ()
+ "Boundary: no calendars requested yields no rows, not an error."
+ (test-calendar-sync-batch-results--with-states
+ '(("google" . (:status ok)))
+ (lambda ()
+ (should (equal (calendar-sync--batch-results '()) '())))))
+
+(ert-deftest test-calendar-sync-batch-results-missing-calendar-reads-never ()
+ "Error: a calendar absent from the state table reads `never', never nil.
+A nil status would drop out of the failure count and report success for a
+calendar that never ran."
+ (test-calendar-sync-batch-results--with-states
+ '(("google" . (:status ok)))
+ (lambda ()
+ (should (equal (calendar-sync--batch-results '("google" "absent"))
+ '(("google" . ok) ("absent" . never)))))))
+
+(ert-deftest test-calendar-sync-batch-results-preserves-request-order ()
+ "Boundary: rows come back in the order asked for, not hash order."
+ (test-calendar-sync-batch-results--with-states
+ '(("a" . (:status ok)) ("b" . (:status ok)) ("c" . (:status ok)))
+ (lambda ()
+ (should (equal (mapcar #'car (calendar-sync--batch-results '("c" "a" "b")))
+ '("c" "a" "b"))))))
+
+(provide 'test-calendar-sync--batch-results)
+;;; test-calendar-sync--batch-results.el ends here
diff --git a/tests/test-calendar-sync--batch-wait.el b/tests/test-calendar-sync--batch-wait.el
new file mode 100644
index 00000000..7deee5e1
--- /dev/null
+++ b/tests/test-calendar-sync--batch-wait.el
@@ -0,0 +1,68 @@
+;;; test-calendar-sync--batch-wait.el --- Batch wait-loop tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The sync pipeline is asynchronous end to end: curl runs in one process and
+;; the org conversion in a second batch Emacs. Under `emacs --batch' the
+;; process exits as soon as the top-level form returns, killing both children
+;; mid-flight -- a run that does nothing and reports success.
+;;
+;; `calendar-sync--batch-wait' is what stops that: it blocks until every
+;; calendar has left the `syncing' state, or until the timeout expires. These
+;; tests drive it with a stubbed state predicate, so the loop's exit conditions
+;; are covered without a live network fetch.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calendar-sync)
+
+(ert-deftest test-calendar-sync-batch-wait-returns-when-nothing-in-flight ()
+ "Normal: with no calendar syncing the wait returns success immediately."
+ (let ((polls 0))
+ (cl-letf (((symbol-function 'calendar-sync--syncing-p) (lambda (_) nil))
+ ((symbol-function 'accept-process-output)
+ (lambda (&rest _) (setq polls (1+ polls)))))
+ (should (calendar-sync--batch-wait '("google" "proton") 5))
+ (should (= polls 0)))))
+
+(ert-deftest test-calendar-sync-batch-wait-blocks-until-settled ()
+ "Normal: the wait polls while a sync is in flight and returns once it lands."
+ (let ((remaining 3)
+ (polls 0))
+ (cl-letf (((symbol-function 'calendar-sync--syncing-p)
+ (lambda (_) (> remaining 0)))
+ ((symbol-function 'accept-process-output)
+ (lambda (&rest _)
+ (setq polls (1+ polls))
+ (setq remaining (1- remaining)))))
+ (should (calendar-sync--batch-wait '("google") 5))
+ (should (= polls 3)))))
+
+(ert-deftest test-calendar-sync-batch-wait-empty-names-returns-immediately ()
+ "Boundary: no calendars to wait on settles at once."
+ (let ((polls 0))
+ (cl-letf (((symbol-function 'accept-process-output)
+ (lambda (&rest _) (setq polls (1+ polls)))))
+ (should (calendar-sync--batch-wait '() 5))
+ (should (= polls 0)))))
+
+(ert-deftest test-calendar-sync-batch-wait-times-out-when-stuck ()
+ "Error: a sync that never settles returns nil once the timeout expires.
+Returning nil is what lets the runner exit non-zero instead of reporting a
+success it cannot vouch for."
+ (let ((calendar-sync--batch-poll-seconds 0.01))
+ (cl-letf (((symbol-function 'calendar-sync--syncing-p) (lambda (_) t))
+ ((symbol-function 'accept-process-output) (lambda (&rest _) nil)))
+ (should-not (calendar-sync--batch-wait '("google") 0.05)))))
+
+(ert-deftest test-calendar-sync-batch-wait-zero-timeout-does-not-hang ()
+ "Boundary: a zero timeout returns at once rather than looping forever."
+ (cl-letf (((symbol-function 'calendar-sync--syncing-p) (lambda (_) t))
+ ((symbol-function 'accept-process-output) (lambda (&rest _) nil)))
+ (should-not (calendar-sync--batch-wait '("google") 0))))
+
+(provide 'test-calendar-sync--batch-wait)
+;;; test-calendar-sync--batch-wait.el ends here
diff --git a/tests/test-calendar-sync--sync-dispatch.el b/tests/test-calendar-sync--sync-dispatch.el
index 22deeef0..9b12b167 100644
--- a/tests/test-calendar-sync--sync-dispatch.el
+++ b/tests/test-calendar-sync--sync-dispatch.el
@@ -77,5 +77,44 @@ than crashing."
(should (equal (list cal) ics-calls))
(should (null api-calls)))))
+(ert-deftest test-calendar-sync--sync-dispatch-error-leaf-signal-is-contained ()
+ "Error: a syncer that signals marks the calendar failed instead of propagating.
+
+Resolving a `:secret-host' feed reads authinfo.gpg, and a cold gpg-agent makes
+that signal a `file-error' before any process starts — so the failure arrives
+synchronously, where the async callbacks that normally record a failure never
+run."
+ (let ((failed '())
+ (calendar-sync--calendar-states (make-hash-table :test 'equal)))
+ (cl-letf (((symbol-function 'calendar-sync--sync-calendar-ics)
+ (lambda (_) (signal 'file-error '("Decryption failed"))))
+ ((symbol-function 'calendar-sync--mark-sync-failed)
+ (lambda (name reason) (push (cons name reason) failed))))
+ (calendar-sync--sync-calendar
+ '(:name "google" :url "https://x/y.ics" :file "/tmp/c.org"))
+ (should (equal "google" (car (car failed)))))))
+
+(ert-deftest test-calendar-sync--sync-all-continues-past-a-failing-calendar ()
+ "Error: one calendar's synchronous failure does not stop the ones after it.
+
+This is the whole cost of leaving the signal uncontained: on a machine whose
+feeds resolve through authinfo, the first calendar's decryption error aborted
+the entire run, so calendars that would have synced fine never got the chance."
+ (let ((synced '())
+ (calendar-sync--calendar-states (make-hash-table :test 'equal))
+ (calendar-sync-calendars
+ '((:name "bad" :url "https://x/a.ics" :file "/tmp/a.org")
+ (:name "good" :url "https://x/b.ics" :file "/tmp/b.org"))))
+ (cl-letf (((symbol-function 'calendar-sync--sync-calendar-ics)
+ (lambda (cal)
+ (if (equal (plist-get cal :name) "bad")
+ (signal 'file-error '("Decryption failed"))
+ (push (plist-get cal :name) synced))))
+ ((symbol-function 'calendar-sync--mark-sync-failed)
+ (lambda (&rest _) nil))
+ ((symbol-function 'message) (lambda (&rest _) nil)))
+ (calendar-sync--sync-all-calendars)
+ (should (equal '("good") synced)))))
+
(provide 'test-calendar-sync--sync-dispatch)
;;; test-calendar-sync--sync-dispatch.el ends here
diff --git a/tests/test-calendar-sync-run.bats b/tests/test-calendar-sync-run.bats
new file mode 100644
index 00000000..da817060
--- /dev/null
+++ b/tests/test-calendar-sync-run.bats
@@ -0,0 +1,116 @@
+#!/usr/bin/env bats
+# Tests for scripts/calendar-sync-run — the batch syncer behind the timer.
+#
+# The elisp tests cover the wait loop and the result tally with the state
+# predicate stubbed. What only a shell test can cover is the thing that makes
+# the whole script necessary: the sync pipeline is asynchronous end to end
+# (curl in one process, the org conversion in a second batch Emacs), and a
+# batch Emacs exits as soon as its top-level form returns. A version that
+# launches the fetch and returns would exit zero, write nothing, and look
+# exactly like a success. Every assertion here that checks the output file
+# exists is really asserting that the script waited.
+#
+# Isolation rules, mirroring test-agenda-render-cache.bats:
+#
+# EMACS_D points at THIS checkout, so a broken tree cannot pass by running
+# the installed config's elisp.
+#
+# CALENDAR_SYNC_CONFIG and CALENDAR_SYNC_STATE point at fixtures, so the run
+# neither reads Craig's real feed URLs nor writes his persisted sync state.
+#
+# The feed is a file:// URL served to the script's own curl. That keeps the
+# test hermetic -- no network, no live calendar -- while still exercising the
+# real fetch path rather than a stub.
+
+setup() {
+ SCRIPT="${BATS_TEST_DIRNAME}/../scripts/calendar-sync-run"
+ export EMACS_D="${BATS_TEST_DIRNAME}/.."
+ export CALENDAR_SYNC_STATE="${BATS_TEST_TMPDIR}/state.el"
+ export CALENDAR_SYNC_TIMEOUT=120
+
+ OUT="${BATS_TEST_TMPDIR}/testcal.org"
+ ICS="${BATS_TEST_TMPDIR}/feed.ics"
+ TODAY="$(date +%Y%m%d)"
+
+ cat > "$ICS" <<-EOF
+ BEGIN:VCALENDAR
+ VERSION:2.0
+ PRODID:-//bats//test//EN
+ BEGIN:VEVENT
+ UID:bats-fixture-1
+ DTSTART:${TODAY}T140000Z
+ DTEND:${TODAY}T150000Z
+ SUMMARY:Batch Fixture Event
+ END:VEVENT
+ END:VCALENDAR
+ EOF
+
+ write_config "file://${ICS}"
+}
+
+# The calendar list is normally private config; the test writes its own so the
+# feed URL is a local file and the output lands in the temp dir.
+write_config() {
+ export CALENDAR_SYNC_CONFIG="${BATS_TEST_TMPDIR}/config.el"
+ cat > "$CALENDAR_SYNC_CONFIG" <<-EOF
+ (setq calendar-sync-calendars
+ (list (list :name "testcal" :url "$1" :file "${OUT}")))
+ EOF
+}
+
+@test "the script is executable" {
+ [ -x "$SCRIPT" ]
+}
+
+@test "waits for the async pipeline and writes the org file" {
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ # The file existing at all is the assertion: it is written by a grandchild
+ # process, so a script that did not wait would have exited before this.
+ [ -f "$OUT" ]
+ grep -q "Batch Fixture Event" "$OUT"
+}
+
+@test "reports the calendar and its status on stdout" {
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"testcal: ok"* ]]
+}
+
+@test "a failed fetch exits non-zero so systemd records it" {
+ write_config "file://${BATS_TEST_TMPDIR}/does-not-exist.ics"
+ run "$SCRIPT"
+ [ "$status" -ne 0 ]
+ [ ! -f "$OUT" ]
+}
+
+@test "a failed fetch names the calendar rather than failing silently" {
+ write_config "file://${BATS_TEST_TMPDIR}/does-not-exist.ics"
+ run "$SCRIPT"
+ [[ "$output" == *"testcal"* ]]
+ [[ "$output" != *"testcal: ok"* ]]
+}
+
+@test "a failed fetch prints why, not just that it failed" {
+ # The interactive path logs the reason to *Messages*, which batch Emacs
+ # discards at exit. Without the reason on stdout the journal shows only
+ # "error" -- no way to tell a cold gpg-agent from a revoked feed token.
+ write_config "file://${BATS_TEST_TMPDIR}/does-not-exist.ics"
+ run "$SCRIPT"
+ [[ "$output" == *"testcal: error"* ]]
+ [[ "$output" == *"Fetch failed"* ]]
+}
+
+@test "refuses to run against a checkout with no modules directory" {
+ EMACS_D="${BATS_TEST_TMPDIR}/empty" run "$SCRIPT"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"no modules directory"* ]]
+}
+
+@test "does not write the real session's sync state" {
+ run "$SCRIPT"
+ [ "$status" -eq 0 ]
+ # The state override is honoured, so a timer run cannot corrupt or race
+ # the interactive session's persisted state.
+ [ -f "$CALENDAR_SYNC_STATE" ]
+}
diff --git a/tests/test-calibredb-epub-config.el b/tests/test-calibredb-epub-config.el
index 7afc58f3..0e430a4e 100644
--- a/tests/test-calibredb-epub-config.el
+++ b/tests/test-calibredb-epub-config.el
@@ -16,6 +16,11 @@
(package-initialize)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'calibredb-epub-config)
+;; Load calibredb before any test stubs its functions with `cl-letf'. The
+;; module's jump path calls `(require 'calibredb)' inside the body; if the
+;; package is still an autoload at that point, the real `defun' lands on top of
+;; the stub and the test runs the real command against the real library.
+(require 'calibredb)
(require 'nov nil t) ; for the nov-mode-map keybinding test; harmless if absent
(declare-function cj/nov--text-width "calibredb-epub-config" (total-cols))
diff --git a/tests/test-config-utilities--compile-this-elisp-buffer.el b/tests/test-config-utilities--compile-this-elisp-buffer.el
index a06440ab..f1a442b4 100644
--- a/tests/test-config-utilities--compile-this-elisp-buffer.el
+++ b/tests/test-config-utilities--compile-this-elisp-buffer.el
@@ -1,10 +1,14 @@
;;; test-config-utilities--compile-this-elisp-buffer.el --- Tests for cj/compile-this-elisp-buffer -*- lexical-binding: t; -*-
;;; Commentary:
-;; Tests for `cj/compile-this-elisp-buffer'. The function dispatches
-;; among native-compile-async, native-compile (sync), and
-;; byte-compile-file based on which is fboundp. Tests force each
-;; branch by mocking fboundp at the boundary.
+;; Tests for `cj/compile-this-elisp-buffer' and its helper
+;; `cj/--compile-elisp-file'. The helper dispatches among
+;; native-compile-async, native-compile (sync), and byte-compile-file based
+;; on an AVAILABLE-P predicate that defaults to `fboundp'. Tests force each
+;; branch by passing the predicate, never by redefining `fboundp': an `fset'
+;; on that subr autoloads comp-run, which requires bytecomp, whose `defun' of
+;; `byte-compile-file' replaces any test double installed earlier in the same
+;; `cl-letf' (Emacs 30.2 hid this because ert happened to preload bytecomp).
;;; Code:
@@ -14,6 +18,10 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'config-utilities)
+(defun test-config-utilities--available (&rest syms)
+ "Return a predicate that reports only SYMS as available compilers."
+ (lambda (sym) (memq sym syms)))
+
(defmacro test-config-utilities--with-elisp-buffer (path &rest body)
"Run BODY in a temp buffer visiting PATH (a .el file path).
Skips the interactive `save-buffer' so tests stay free of disk side
@@ -24,72 +32,108 @@ effects."
(cl-letf (((symbol-function 'save-buffer) (lambda (&rest _) nil)))
,@body)))
+;; -- the interactive wrapper -------------------------------------------------
+
(ert-deftest test-config-utilities-compile-buffer-not-elisp-raises ()
"Error: a buffer whose file isn't .el raises `user-error'."
(test-config-utilities--with-elisp-buffer "/tmp/not-elisp.txt"
(should-error (cj/compile-this-elisp-buffer) :type 'user-error)))
-(ert-deftest test-config-utilities-compile-buffer-no-buffer-file-name-raises ()
- "Error: a buffer with no `buffer-file-name' raises `user-error'."
+(ert-deftest test-config-utilities-compile-buffer-no-file-raises ()
+ "Boundary: a buffer visiting no file raises `user-error' rather than
+passing nil to the compiler."
(with-temp-buffer
- (setq buffer-file-name nil)
(should-error (cj/compile-this-elisp-buffer) :type 'user-error)))
+(ert-deftest test-config-utilities-compile-buffer-saves-then-delegates ()
+ "Normal: the wrapper saves the buffer and hands its file to the helper."
+ (let (saved compiled)
+ (with-temp-buffer
+ (setq buffer-file-name "/tmp/some.el")
+ (cl-letf (((symbol-function 'save-buffer) (lambda (&rest _) (setq saved t)))
+ ((symbol-function 'cj/--compile-elisp-file)
+ (lambda (file &optional _) (setq compiled file))))
+ (cj/compile-this-elisp-buffer)))
+ (should saved)
+ (should (equal compiled "/tmp/some.el"))))
+
+;; -- the helper's dispatch ---------------------------------------------------
+
(ert-deftest test-config-utilities-compile-buffer-prefers-native-async ()
"Normal: `native-compile-async' is preferred when available."
(let (called-with)
- (test-config-utilities--with-elisp-buffer "/tmp/some.el"
- (cl-letf (((symbol-function 'fboundp)
- (lambda (sym)
- (memq sym '(native-compile-async native-compile byte-compile-file))))
- ((symbol-function 'native-compile-async)
- (lambda (file) (setq called-with file)))
- ((symbol-function 'native-compile)
- (lambda (_) (error "should not call sync native-compile")))
- ((symbol-function 'byte-compile-file)
- (lambda (&rest _) (error "should not call byte-compile-file"))))
- (cj/compile-this-elisp-buffer)
- (should (equal called-with "/tmp/some.el"))))))
+ (cl-letf (((symbol-function 'native-compile-async)
+ (lambda (file) (setq called-with file)))
+ ((symbol-function 'native-compile)
+ (lambda (_) (error "should not call sync native-compile")))
+ ((symbol-function 'byte-compile-file)
+ (lambda (&rest _) (error "should not call byte-compile-file"))))
+ (cj/--compile-elisp-file
+ "/tmp/some.el"
+ (test-config-utilities--available 'native-compile-async 'native-compile
+ 'byte-compile-file))
+ (should (equal called-with "/tmp/some.el")))))
(ert-deftest test-config-utilities-compile-buffer-falls-back-to-sync-native ()
"Normal: `native-compile' is used when async isn't available."
(let (called-with)
- (test-config-utilities--with-elisp-buffer "/tmp/some.el"
- (cl-letf (((symbol-function 'fboundp)
- (lambda (sym) (memq sym '(native-compile byte-compile-file))))
- ((symbol-function 'native-compile)
- (lambda (file) (setq called-with file)))
- ((symbol-function 'byte-compile-file)
- (lambda (&rest _) (error "should not call byte-compile-file"))))
- (cj/compile-this-elisp-buffer)
- (should (equal called-with "/tmp/some.el"))))))
+ (cl-letf (((symbol-function 'native-compile)
+ (lambda (file) (setq called-with file)))
+ ((symbol-function 'byte-compile-file)
+ (lambda (&rest _) (error "should not call byte-compile-file"))))
+ (cj/--compile-elisp-file
+ "/tmp/some.el"
+ (test-config-utilities--available 'native-compile 'byte-compile-file))
+ (should (equal called-with "/tmp/some.el")))))
(ert-deftest test-config-utilities-compile-buffer-falls-back-to-byte-compile ()
"Normal: `byte-compile-file' is used when neither native option is available."
(let (called-with)
- (test-config-utilities--with-elisp-buffer "/tmp/some.el"
- (cl-letf (((symbol-function 'fboundp)
- (lambda (sym) (eq sym 'byte-compile-file)))
- ((symbol-function 'byte-compile-file)
- (lambda (file &rest _) (setq called-with file) "/tmp/some.elc")))
- (cj/compile-this-elisp-buffer)
- (should (equal called-with "/tmp/some.el"))))))
+ (cl-letf (((symbol-function 'byte-compile-file)
+ (lambda (file &rest _) (setq called-with file) "/tmp/some.elc")))
+ (cj/--compile-elisp-file
+ "/tmp/some.el"
+ (test-config-utilities--available 'byte-compile-file))
+ (should (equal called-with "/tmp/some.el")))))
+
+(ert-deftest test-config-utilities-compile-buffer-reports-when-nothing-available ()
+ "Boundary: with no compiler available the helper only messages, calling none."
+ (let (captured)
+ (cl-letf (((symbol-function 'native-compile-async)
+ (lambda (&rest _) (error "should not call native-compile-async")))
+ ((symbol-function 'native-compile)
+ (lambda (&rest _) (error "should not call native-compile")))
+ ((symbol-function 'byte-compile-file)
+ (lambda (&rest _) (error "should not call byte-compile-file")))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq captured (apply #'format fmt args)))))
+ (cj/--compile-elisp-file "/tmp/some.el" (test-config-utilities--available)))
+ (should (string-match-p "No compilation available" captured))))
(ert-deftest test-config-utilities-compile-buffer-handles-sync-native-error ()
"Error: a sync `native-compile' that signals is caught and reported.
-Asserts no error escapes by running the function and checking that the
+Asserts no error escapes by running the helper and checking that the
message captured contains the failure prefix."
- (test-config-utilities--with-elisp-buffer "/tmp/some.el"
- (let (captured)
- (cl-letf (((symbol-function 'fboundp)
- (lambda (sym) (memq sym '(native-compile byte-compile-file))))
- ((symbol-function 'native-compile)
- (lambda (_) (error "boom")))
- ((symbol-function 'message)
- (lambda (fmt &rest args)
- (setq captured (apply #'format fmt args)))))
- (cj/compile-this-elisp-buffer))
- (should (string-match-p "Native compile failed" captured)))))
+ (let (captured)
+ (cl-letf (((symbol-function 'native-compile)
+ (lambda (_) (error "boom")))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq captured (apply #'format fmt args)))))
+ (cj/--compile-elisp-file
+ "/tmp/some.el"
+ (test-config-utilities--available 'native-compile 'byte-compile-file)))
+ (should (string-match-p "Native compile failed" captured))))
+
+(ert-deftest test-config-utilities-compile-buffer-default-predicate-is-fboundp ()
+ "Normal: with no predicate the helper consults `fboundp', so on a real
+Emacs it reaches whichever compiler exists rather than the no-compiler
+message."
+ (let (captured)
+ (cl-letf (((symbol-function 'native-compile-async) (lambda (&rest _) nil))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq captured (apply #'format fmt args)))))
+ (cj/--compile-elisp-file "/tmp/some.el"))
+ (should (string-match-p "Queued native compilation" captured))))
(provide 'test-config-utilities--compile-this-elisp-buffer)
;;; test-config-utilities--compile-this-elisp-buffer.el ends here
diff --git a/tests/test-dirvish-config--side-key.el b/tests/test-dirvish-config--side-key.el
new file mode 100644
index 00000000..1f08314c
--- /dev/null
+++ b/tests/test-dirvish-config--side-key.el
@@ -0,0 +1,40 @@
+;;; test-dirvish-config--side-key.el --- dirvish-side lives on F9 -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The sidebar toggle moved from F11 to F9 on 2026-09-22 so F11 could go back
+;; to `toggle-frame-fullscreen', the stock binding every other application
+;; shares and which the :bind had been shadowing. F9 was the free bare
+;; F-key: ai-term left it for M-SPC in June and the recording chord came off
+;; it the same day this moved. F2 is the preview key and F5 is reserved for
+;; the debug backend, so neither was a candidate.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'keybindings)
+(require 'dirvish-config)
+
+;;; Normal
+
+(ert-deftest test-dirvish-config-side-on-f9 ()
+ "Normal: F9 toggles the dirvish sidebar."
+ (should (eq (keymap-lookup global-map "<f9>") #'dirvish-side)))
+
+;;; Boundary
+
+(ert-deftest test-dirvish-config-side-not-on-f11 ()
+ "Boundary/regression: F11 no longer reaches dirvish-side, so the stock
+`toggle-frame-fullscreen' binding is no longer shadowed."
+ (should-not (eq (keymap-lookup global-map "<f11>") #'dirvish-side))
+ (should (eq (keymap-lookup global-map "<f11>") #'toggle-frame-fullscreen)))
+
+;;; Error
+
+(ert-deftest test-dirvish-config-side-target-is-a-command ()
+ "Error (positive control): the binding points at a real interactive
+command, so a keypress does not fail with a `commandp' error at press time."
+ (should (commandp (keymap-lookup global-map "<f9>"))))
+
+(provide 'test-dirvish-config--side-key)
+;;; test-dirvish-config--side-key.el ends here
diff --git a/tests/test-google-keep-config--local-config.el b/tests/test-google-keep-config--local-config.el
new file mode 100644
index 00000000..18769c5a
--- /dev/null
+++ b/tests/test-google-keep-config--local-config.el
@@ -0,0 +1,52 @@
+;;; test-google-keep-config--local-config.el --- Tests for the Keep machine-local config loader -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/keep--load-local-config, the loader for the gitignored
+;; machine-local google-keep.local.el (venv interpreter path, account email) —
+;; the same shape calendar-sync uses for calendar-sync.local.el.
+
+;;; Code:
+
+(require 'ert)
+(require 'google-keep-config)
+
+(ert-deftest test-google-keep-local-config-loads-readable-file ()
+ "Normal: a readable local config file is loaded and its settings apply."
+ (let ((file (make-temp-file "keep-local-" nil ".el")))
+ (unwind-protect
+ (progn
+ (with-temp-file file
+ (insert "(setq test-google-keep--local-marker 'loaded)"))
+ (defvar test-google-keep--local-marker nil)
+ (setq test-google-keep--local-marker nil)
+ (let ((cj/keep-local-config-file file))
+ (should (cj/keep--load-local-config))
+ (should (eq test-google-keep--local-marker 'loaded))))
+ (delete-file file))))
+
+(ert-deftest test-google-keep-local-config-missing-file-is-quiet ()
+ "Boundary: an absent local config file is a silent no-op, no error."
+ (let ((cj/keep-local-config-file "/nonexistent/google-keep.local.el"))
+ (should-not (cj/keep--load-local-config))))
+
+(ert-deftest test-google-keep-local-config-broken-file-does-not-signal ()
+ "Error: a local config file with a broken form is caught and reported,
+never propagated as a load-time error."
+ (let ((file (make-temp-file "keep-local-broken-" nil ".el"))
+ (messages nil))
+ (unwind-protect
+ (progn
+ (with-temp-file file
+ (insert "(error \"deliberately broken local config\")"))
+ (let ((cj/keep-local-config-file file))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (push (apply #'format fmt args) messages)
+ nil)))
+ (should-not (cj/keep--load-local-config)))
+ (should (seq-find (lambda (m) (string-match-p "google-keep.*local config" m))
+ messages))))
+ (delete-file file))))
+
+(provide 'test-google-keep-config--local-config)
+;;; test-google-keep-config--local-config.el ends here
diff --git a/tests/test-init-defer-games.el b/tests/test-init-defer-games.el
index f3ec94de..4f349908 100644
--- a/tests/test-init-defer-games.el
+++ b/tests/test-init-defer-games.el
@@ -42,5 +42,36 @@ load failed to define malyon."
(should (featurep 'games-config))
(should (equal malyon-stories-directory "/tmp/games-defer-test/text.games/"))))
+(defun test-init-defer-games--declaration-installs-p (init package)
+ "Return non-nil when INIT (init.el's text) declares PACKAGE in an installing form.
+A `use-package' form that carries `:ensure nil' or `:load-path' does not
+install (use-package suppresses `use-package-always-ensure' for both), so
+the check rejects those rather than accepting any form that names the package."
+ (and (string-match (format "^(use-package %s\\b\\([^\n]*\\))[ \t]*$"
+ (regexp-quote package))
+ init)
+ (let ((args (match-string 1 init)))
+ (not (string-match-p ":ensure nil\\|:load-path" args)))))
+
+(ert-deftest test-init-defer-games-init-declares-both-packages ()
+ "Normal: init.el declares malyon and 2048-game in forms that install them.
+`use-package-always-ensure' is the installer for these two. a8571eff dropped
+the declarations along with the eager require, and both packages silently
+vanished on the next rebuild while every other test still passed."
+ (let ((init (with-temp-buffer
+ (insert-file-contents (expand-file-name "init.el" default-directory))
+ (buffer-string))))
+ (should (test-init-defer-games--declaration-installs-p init "malyon"))
+ (should (test-init-defer-games--declaration-installs-p init "2048-game"))))
+
+(ert-deftest test-init-defer-games-declaration-check-rejects-non-installing-forms ()
+ "Boundary: the declaration check refuses forms use-package would not install."
+ (should-not (test-init-defer-games--declaration-installs-p
+ "(use-package malyon :ensure nil :defer t)\n" "malyon"))
+ (should-not (test-init-defer-games--declaration-installs-p
+ "(use-package malyon :load-path \"~/x\" :defer t)\n" "malyon"))
+ (should (test-init-defer-games--declaration-installs-p
+ "(use-package malyon :defer t :commands (malyon))\n" "malyon")))
+
(provide 'test-init-defer-games)
;;; test-init-defer-games.el ends here
diff --git a/tests/test-integration-recurring-events.el b/tests/test-integration-recurring-events.el
index 8339d167..44ddfb00 100644
--- a/tests/test-integration-recurring-events.el
+++ b/tests/test-integration-recurring-events.el
@@ -24,13 +24,22 @@
;;; Setup and Teardown
+(defvar test-integration-recurring-events--saved-tz nil
+ "The TZ in force before setup pinned it, restored by teardown.")
+
(defun test-integration-recurring-events-setup ()
- "Setup for recurring events integration tests."
- nil)
+ "Setup for recurring events integration tests.
+Pins TZ to America/Chicago: the fixtures are TZID=America/Chicago and the
+assertions expect that zone's local rendering (\"Sat 10:30-11:00\"), so on
+any other machine zone the pipeline's correct conversion reads as a failure.
+`setenv' on TZ also calls `set-time-zone-rule', which is what the time
+functions actually consult."
+ (setq test-integration-recurring-events--saved-tz (getenv "TZ"))
+ (setenv "TZ" "America/Chicago"))
(defun test-integration-recurring-events-teardown ()
- "Teardown for recurring events integration tests."
- nil)
+ "Teardown for recurring events integration tests: restore the machine TZ."
+ (setenv "TZ" test-integration-recurring-events--saved-tz))
;;; Test Data
diff --git a/tests/test-keybindings--fullscreen.el b/tests/test-keybindings--fullscreen.el
new file mode 100644
index 00000000..c35e398c
--- /dev/null
+++ b/tests/test-keybindings--fullscreen.el
@@ -0,0 +1,45 @@
+;;; test-keybindings--fullscreen.el --- F11 toggles frame fullscreen -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Stock Emacs binds F11 to `toggle-frame-fullscreen', the same key every
+;; other application on the desktop uses for it. This config never rebound
+;; it; dirvish-config's :bind put dirvish-side on F11 and shadowed the stock
+;; binding for as long as that entry existed. It moved to F9 on 2026-09-22
+;; (see test-dirvish-config--side-key.el, which pins the un-shadowing).
+;;
+;; These tests pin the foundation layer's side of that: keybindings.el must
+;; leave the stock F11 binding in place. Only keybindings.el is loaded here
+;; on purpose. A feature module that later claimed F11 would fail the
+;; dirvish-side regression test, not this one.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'keybindings)
+
+;;; Normal
+
+(ert-deftest test-keybindings-f11-toggles-fullscreen ()
+ "Normal: F11 runs `toggle-frame-fullscreen' once the foundation layer has
+loaded. Stock Emacs provides the binding; this pins that keybindings.el does
+not unset or rebind it."
+ (should (eq (keymap-lookup global-map "<f11>") #'toggle-frame-fullscreen)))
+
+;;; Boundary
+
+(ert-deftest test-keybindings-f11-is-the-only-fullscreen-chord ()
+ "Boundary: the plain key carries it; the shifted chord is left free so a
+grazed Shift does not toggle the frame twice."
+ (should-not (eq (keymap-lookup global-map "S-<f11>")
+ #'toggle-frame-fullscreen)))
+
+;;; Error
+
+(ert-deftest test-keybindings-f11-target-is-a-command ()
+ "Error (positive control): the target is a real interactive command, so a
+press does not fail with a `commandp' error at press time."
+ (should (commandp (keymap-lookup global-map "<f11>"))))
+
+(provide 'test-keybindings--fullscreen)
+;;; test-keybindings--fullscreen.el ends here
diff --git a/tests/test-music-config--append-track-to-m3u-file.el b/tests/test-music-config--append-track-to-m3u-file.el
index be0cbd8e..cc40438c 100644
--- a/tests/test-music-config--append-track-to-m3u-file.el
+++ b/tests/test-music-config--append-track-to-m3u-file.el
@@ -39,7 +39,8 @@
"Append to brand new empty M3U file."
(test-music-config--append-track-to-m3u-file-setup)
(unwind-protect
- (let* ((m3u-file (cj/create-temp-test-file "test-playlist-"))
+ (let* ((cj/music-root (cj/create-test-base-dir))
+ (m3u-file (cj/create-temp-test-file "test-playlist-"))
(track-path (expand-file-name "artist/song.mp3" cj/music-root))
(expected-relative "artist/song.mp3"))
(cj/music--append-track-to-m3u-file track-path m3u-file)
@@ -53,6 +54,7 @@
(test-music-config--append-track-to-m3u-file-setup)
(unwind-protect
(let* ((existing-content "first.mp3\n")
+ (cj/music-root (cj/create-test-base-dir))
(m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-"))
(track-path (expand-file-name "second.mp3" cj/music-root))
(expected-relative "second.mp3"))
@@ -68,6 +70,7 @@
(test-music-config--append-track-to-m3u-file-setup)
(unwind-protect
(let* ((existing-content "first.mp3")
+ (cj/music-root (cj/create-test-base-dir))
(m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-"))
(track-path (expand-file-name "second.mp3" cj/music-root))
(expected-relative "second.mp3"))
@@ -82,7 +85,8 @@
"Multiple appends to same file all succeed (allows duplicates)."
(test-music-config--append-track-to-m3u-file-setup)
(unwind-protect
- (let* ((m3u-file (cj/create-temp-test-file "test-playlist-"))
+ (let* ((cj/music-root (cj/create-test-base-dir))
+ (m3u-file (cj/create-temp-test-file "test-playlist-"))
(track1 (expand-file-name "track1.mp3" cj/music-root))
(track2 (expand-file-name "track2.mp3" cj/music-root))
(track1-duplicate (expand-file-name "track1.mp3" cj/music-root))
@@ -98,13 +102,157 @@
(concat rel1 "\n" rel2 "\n" rel1 "\n"))))))
(test-music-config--append-track-to-m3u-file-teardown)))
+;;; Normal Cases: round-trip with the reader
+
+(ert-deftest test-music-config--append-track-to-m3u-file-normal-round-trips-through-the-reader ()
+ "Normal: the same-directory case round-trips through the reader.
+A positive control only. With the playlist and the music root in one
+directory both candidate bases produce the same string, so this passes
+against the old writer too — the discriminating cases are the two tests
+below, which put the bases at different depths."
+ (test-music-config--append-track-to-m3u-file-setup)
+ (unwind-protect
+ (let* ((base (cj/create-test-base-dir))
+ (cj/music-root base)
+ (m3u-file (cj/create-temp-test-file "test-playlist-"))
+ (track-path (expand-file-name "artist/song.mp3" base)))
+ (cj/music--append-track-to-m3u-file track-path m3u-file)
+ (should (equal (cj/music--m3u-file-tracks m3u-file)
+ (list track-path))))
+ (test-music-config--append-track-to-m3u-file-teardown)))
+
+(ert-deftest test-music-config--append-track-to-m3u-file-normal-round-trips-outside-the-music-root ()
+ "Normal/regression: a playlist living outside `cj/music-root' round-trips.
+This is the case the old writer got wrong. It based every relative path on
+`cj/music-root' wherever the playlist sat, while the reader resolved against
+the playlist's directory. Inside the music root the two coincide, which is
+why the defect stayed invisible until a playlist moved out of it."
+ (test-music-config--append-track-to-m3u-file-setup)
+ (unwind-protect
+ ;; The layout mirrors the real one: playlists/ and audio/ are siblings
+ ;; under mpd/, and the music root is a separate tree at a different depth.
+ ;; The depth difference is load-bearing -- put the music root alongside
+ ;; playlists/ instead and both bases yield the same relative path, so the
+ ;; test passes against the broken writer and proves nothing.
+ (let* ((base (cj/create-test-base-dir))
+ (playlists (expand-file-name "mpd/playlists/" base))
+ (audio (expand-file-name "mpd/audio/" base))
+ (cj/music-root (expand-file-name "music/" base))
+ (m3u-file (expand-file-name "ambience.m3u" playlists))
+ (track-path (expand-file-name "rain-loop.mp3" audio)))
+ (make-directory playlists t)
+ (make-directory audio t)
+ (make-directory cj/music-root t)
+ (with-temp-buffer (write-file m3u-file))
+ (cj/music--append-track-to-m3u-file track-path m3u-file)
+ (should (equal (cj/music--m3u-file-tracks m3u-file)
+ (list track-path))))
+ (test-music-config--append-track-to-m3u-file-teardown)))
+
+(ert-deftest test-music-config--append-track-to-m3u-file-normal-under-playlist-dir-is-relative ()
+ "Normal: a track under the playlist's directory is written relative to it.
+The music root sits at a different depth on purpose. Put it alongside the
+playlist directory instead and both candidate bases produce the same string,
+so the assertion would hold against a writer using either one."
+ (test-music-config--append-track-to-m3u-file-setup)
+ (unwind-protect
+ (let* ((base (cj/create-test-base-dir))
+ (playlists (expand-file-name "mpd/playlists/" base))
+ (cj/music-root (expand-file-name "music/" base))
+ (m3u-file (expand-file-name "album.m3u" playlists))
+ (track-path (expand-file-name "sub/song.mp3" playlists)))
+ (make-directory (expand-file-name "sub/" playlists) t)
+ (make-directory cj/music-root t)
+ (with-temp-buffer (write-file m3u-file))
+ (cj/music--append-track-to-m3u-file track-path m3u-file)
+ (with-temp-buffer
+ (insert-file-contents m3u-file)
+ (should (string= (buffer-string) "sub/song.mp3\n")))
+ (should (equal (cj/music--m3u-file-tracks m3u-file) (list track-path))))
+ (test-music-config--append-track-to-m3u-file-teardown)))
+
+(ert-deftest test-music-config--append-track-to-m3u-file-normal-sibling-dir-is-absolute ()
+ "Normal: a track outside the playlist's directory is written absolute.
+A sibling would otherwise come out as \"../audio/x.mp3\". Absolute is the
+convention for cross-tree references here, and it survives the playlist being
+moved again later, which a ../ chain does not."
+ (test-music-config--append-track-to-m3u-file-setup)
+ (unwind-protect
+ (let* ((base (cj/create-test-base-dir))
+ (playlists (expand-file-name "mpd/playlists/" base))
+ (audio (expand-file-name "mpd/audio/" base))
+ (cj/music-root (expand-file-name "music/" base))
+ (m3u-file (expand-file-name "ambience.m3u" playlists))
+ (track-path (expand-file-name "rain-loop.mp3" audio)))
+ (make-directory playlists t)
+ (make-directory audio t)
+ (make-directory cj/music-root t)
+ (with-temp-buffer (write-file m3u-file))
+ (cj/music--append-track-to-m3u-file track-path m3u-file)
+ (with-temp-buffer
+ (insert-file-contents m3u-file)
+ (should (string= (buffer-string) (concat track-path "\n"))))
+ (should (equal (cj/music--m3u-file-tracks m3u-file) (list track-path))))
+ (test-music-config--append-track-to-m3u-file-teardown)))
+
+(ert-deftest test-music-config--append-track-to-m3u-file-normal-deep-parent-chain-goes-absolute ()
+ "Normal: a track several levels away is written absolute, not as a ../ chain.
+This is the case the absolute fallback exists for. A four-level chain is
+unreadable and breaks the moment the playlist moves, so distance from the
+playlist is exactly when an absolute path earns its keep."
+ (test-music-config--append-track-to-m3u-file-setup)
+ (unwind-protect
+ (let* ((base (cj/create-test-base-dir))
+ (playlists (expand-file-name "a/b/c/playlists/" base))
+ (cj/music-root (expand-file-name "music/" base))
+ (m3u-file (expand-file-name "deep.m3u" playlists))
+ (track-path (expand-file-name "faraway/song.mp3" base)))
+ (make-directory playlists t)
+ (make-directory (expand-file-name "faraway/" base) t)
+ (make-directory cj/music-root t)
+ (with-temp-buffer (write-file m3u-file))
+ (cj/music--append-track-to-m3u-file track-path m3u-file)
+ (with-temp-buffer
+ (insert-file-contents m3u-file)
+ ;; Four hops up (playlists -> c -> b -> a -> base) would be the
+ ;; relative form; the writer declines it and emits the absolute path.
+ (should (string= (buffer-string) (concat track-path "\n"))))
+ (should (equal (cj/music--m3u-file-tracks m3u-file)
+ (list track-path))))
+ (test-music-config--append-track-to-m3u-file-teardown)))
+
;;; Boundary Cases
+(ert-deftest test-music-config--append-track-to-m3u-file-boundary-dotdot-named-dir-stays-relative ()
+ "Boundary: a directory whose name merely begins with two dots stays relative.
+This is the input the relative-vs-absolute test actually turns on. The check
+looks for a leading \"../\", so a real subdirectory named \"..hidden\" is under
+the playlist and must not be mistaken for an escape. Loosening the check to
+\"..\" would break exactly this case and nothing else in the suite would catch
+it."
+ (test-music-config--append-track-to-m3u-file-setup)
+ (unwind-protect
+ (let* ((base (cj/create-test-base-dir))
+ (playlists (expand-file-name "mpd/playlists/" base))
+ (cj/music-root (expand-file-name "music/" base))
+ (m3u-file (expand-file-name "p.m3u" playlists))
+ (track-path (expand-file-name "..hidden/song.mp3" playlists)))
+ (make-directory (expand-file-name "..hidden/" playlists) t)
+ (make-directory cj/music-root t)
+ (with-temp-buffer (write-file m3u-file))
+ (cj/music--append-track-to-m3u-file track-path m3u-file)
+ (with-temp-buffer
+ (insert-file-contents m3u-file)
+ (should (string= (buffer-string) "..hidden/song.mp3\n")))
+ (should (equal (cj/music--m3u-file-tracks m3u-file) (list track-path))))
+ (test-music-config--append-track-to-m3u-file-teardown)))
+
(ert-deftest test-music-config--append-track-to-m3u-file-boundary-very-long-path-appends-successfully ()
"Append very long track path without truncation."
(test-music-config--append-track-to-m3u-file-setup)
(unwind-protect
- (let* ((m3u-file (cj/create-temp-test-file "test-playlist-"))
+ (let* ((cj/music-root (cj/create-test-base-dir))
+ (m3u-file (cj/create-temp-test-file "test-playlist-"))
;; Create a relative path that's ~450 chars long
(relative-path (concat (make-string 440 ?a) "/song.mp3"))
(track-path (expand-file-name relative-path cj/music-root)))
@@ -119,7 +267,8 @@
"Append path with unicode characters preserves UTF-8 encoding."
(test-music-config--append-track-to-m3u-file-setup)
(unwind-protect
- (let* ((m3u-file (cj/create-temp-test-file "test-playlist-"))
+ (let* ((cj/music-root (cj/create-test-base-dir))
+ (m3u-file (cj/create-temp-test-file "test-playlist-"))
(relative-path "中文/artist-名前/song🎵.mp3")
(track-path (expand-file-name relative-path cj/music-root)))
(cj/music--append-track-to-m3u-file track-path m3u-file)
@@ -132,7 +281,8 @@
"Append path with spaces and special characters."
(test-music-config--append-track-to-m3u-file-setup)
(unwind-protect
- (let* ((m3u-file (cj/create-temp-test-file "test-playlist-"))
+ (let* ((cj/music-root (cj/create-test-base-dir))
+ (m3u-file (cj/create-temp-test-file "test-playlist-"))
(relative-path "Artist Name/Album (2024)/01 - Song's Title [Remix].mp3")
(track-path (expand-file-name relative-path cj/music-root)))
(cj/music--append-track-to-m3u-file track-path m3u-file)
@@ -146,6 +296,7 @@
(test-music-config--append-track-to-m3u-file-setup)
(unwind-protect
(let* ((existing-content "#EXTM3U\n#EXTINF:-1,Radio Station\nhttp://stream.url/radio\n")
+ (cj/music-root (cj/create-test-base-dir))
(m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-"))
(relative-path "local-track.mp3")
(track-path (expand-file-name relative-path cj/music-root)))
@@ -156,6 +307,73 @@
(concat existing-content relative-path "\n")))))
(test-music-config--append-track-to-m3u-file-teardown)))
+;;; Boundary Cases: symlinked playlists
+
+(defun test-music-config--append--make-symlinked-playlist (base content link-depth)
+ "Create a playlist whose deployed path is a symlink, and return that path.
+CONTENT is written to the real file. LINK-DEPTH controls how long the link
+string is, which is the whole point: `file-attributes' does not follow
+symlinks, so a writer sizing the file that way reads the length of the link
+rather than the content."
+ (let* ((deployed (expand-file-name "deployed/" base))
+ (deep (expand-file-name (mapconcat #'identity
+ (make-list link-depth "longdirname")
+ "/")
+ base))
+ (real (expand-file-name "p.m3u" deep))
+ (link (expand-file-name "p.m3u" deployed)))
+ (make-directory deep t)
+ (make-directory deployed t)
+ (with-temp-buffer (insert content) (write-file real))
+ (make-symbolic-link (file-relative-name real deployed) link t)
+ link))
+
+(ert-deftest test-music-config--append-track-to-m3u-file-boundary-symlink-longer-than-content ()
+ "Boundary: appending to a symlinked playlist whose link string is longer than
+its content must not signal. Sizing the file with `file-attributes' returns
+the link's length, so the read range falls outside the file, nothing is
+inserted, and `char-after' hands nil to a numeric comparison. Measured on the
+real deployed set: 31 of 100 symlinked playlists are in this state."
+ (test-music-config--append-track-to-m3u-file-setup)
+ (unwind-protect
+ (let* ((base (cj/create-test-base-dir))
+ (m3u-file (test-music-config--append--make-symlinked-playlist
+ base "https://example.com/s.mp3\n" 8))
+ (track-path (expand-file-name "song.mp3" (file-name-directory m3u-file))))
+ (should (> (file-attribute-size (file-attributes m3u-file))
+ (file-attribute-size (file-attributes (file-truename m3u-file)))))
+ (cj/music--append-track-to-m3u-file track-path m3u-file)
+ ;; The seeded line is a stream URL, which the reader passes through, so
+ ;; both entries come back.
+ (should (equal (cj/music--m3u-file-tracks m3u-file)
+ (list "https://example.com/s.mp3" track-path))))
+ (test-music-config--append-track-to-m3u-file-teardown)))
+
+(ert-deftest test-music-config--append-track-to-m3u-file-boundary-symlink-no-spurious-blank-line ()
+ "Boundary: a symlinked playlist already ending in a newline gains no blank line.
+The trailing-newline probe reads a byte chosen from the wrong size, so it
+misreads a terminated file as unterminated and prepends a newline. All 100
+symlinked playlists in the deployed set read the wrong byte this way."
+ (test-music-config--append-track-to-m3u-file-setup)
+ (unwind-protect
+ ;; Content deliberately longer than the link string, so the misread byte
+ ;; still lands inside the file. That separates this from the sibling test
+ ;; above: here the probe reads a valid but wrong byte and silently
+ ;; misjudges, rather than reading past the end and signalling.
+ (let* ((base (cj/create-test-base-dir))
+ (content (mapconcat (lambda (i) (format "track-%03d-with-a-longish-name.mp3" i))
+ (number-sequence 1 12) "\n"))
+ (m3u-file (test-music-config--append--make-symlinked-playlist
+ base (concat content "\n") 2))
+ (track-path (expand-file-name "second.mp3" (file-name-directory m3u-file))))
+ (should (< (file-attribute-size (file-attributes m3u-file))
+ (file-attribute-size (file-attributes (file-truename m3u-file)))))
+ (cj/music--append-track-to-m3u-file track-path m3u-file)
+ (with-temp-buffer
+ (insert-file-contents m3u-file)
+ (should (string= (buffer-string) (concat content "\nsecond.mp3\n")))))
+ (test-music-config--append-track-to-m3u-file-teardown)))
+
;;; Error Cases
(ert-deftest test-music-config--append-track-to-m3u-file-error-nonexistent-file-signals-error ()
@@ -172,6 +390,9 @@
"Signal error when M3U file is read-only."
(test-music-config--append-track-to-m3u-file-setup)
(unwind-protect
+ ;; No `cj/music-root' rebinding here: the writable-p guard signals before
+ ;; any path computation runs, so binding it would imply a dependency the
+ ;; read-only path does not have.
(let* ((m3u-file (cj/create-temp-test-file "test-playlist-"))
(track-path "/home/user/music/song.mp3"))
;; Make file read-only
diff --git a/tests/test-org-capture-config-fkey-guard.el b/tests/test-org-capture-config-fkey-guard.el
index b288ce4f..c0687bb2 100644
--- a/tests/test-org-capture-config-fkey-guard.el
+++ b/tests/test-org-capture-config-fkey-guard.el
@@ -2,8 +2,10 @@
;;; Commentary:
;; While a capture is in progress, the global popup keys (F1 dashboard
-;; sweep, F10 music, F11 dirvish-side, F12 terminal, M-SPC agent swap)
-;; must not fire and pop UI over the capture. org-capture-mode is a
+;; sweep, F9 dirvish-side, F10 music, F12 terminal, M-SPC agent swap)
+;; must not fire and pop UI over the capture. F11 is deliberately not on
+;; the list: it toggles frame fullscreen, which pops nothing over the
+;; capture, so blocking it would only get in the way. org-capture-mode is a
;; minor mode active exactly for the capture's duration and its keymap
;; shadows the global map, so the guard binds those keys there to a
;; blocker that signals a `user-error' naming the way out.
@@ -21,10 +23,17 @@
(ert-deftest test-org-capture-fkey-guard-keys-bound ()
"Normal: every leaking popup key is bound to the blocker in capture mode."
- (dolist (key '("<f1>" "<f10>" "<f11>" "<f12>" "M-SPC"))
+ (dolist (key '("<f1>" "<f9>" "<f10>" "<f12>" "M-SPC"))
(should (eq (keymap-lookup org-capture-mode-map key)
#'cj/--org-capture-blocked-key))))
+(ert-deftest test-org-capture-fkey-guard-leaves-fullscreen-alone ()
+ "Boundary: F11 (frame fullscreen) is not blocked. It pops no UI, and
+before dirvish-side moved to F9 it was on this list only because F11 was the
+sidebar key; the entry must follow the command, not the key."
+ (should-not (eq (keymap-lookup org-capture-mode-map "<f11>")
+ #'cj/--org-capture-blocked-key)))
+
(ert-deftest test-org-capture-fkey-guard-blocker-signals-user-error ()
"Error: the blocker signals a user-error rather than doing nothing."
(should-error (cj/--org-capture-blocked-key) :type 'user-error))
diff --git a/tests/test-package-resilience.el b/tests/test-package-resilience.el
new file mode 100644
index 00000000..d5fdde4a
--- /dev/null
+++ b/tests/test-package-resilience.el
@@ -0,0 +1,504 @@
+;;; test-package-resilience.el --- Tests for surviving failed package installs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for package-resilience.el, which keeps a failed package download from
+;; aborting init. The regression these guard is concrete: early-init.el sets
+;; `debug-on-error' during startup so config errors are loud, and that disarms
+;; the `condition-case-unless-debug' inside `use-package-ensure-elpa', so one
+;; transient download error dropped a fresh install into the debugger with two
+;; thirds of the config unloaded.
+;;
+;; The fakes below stand in for the package archive so no test touches the
+;; network or the real elpa directory.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'package-resilience)
+
+;;; ------------------------------- Fake registry -------------------------------
+
+(defvar test-pkg-res--installed nil
+ "Package symbols the fake registry considers installed.")
+
+(defvar test-pkg-res--install-log nil
+ "Packages `package-install' was called with, newest first.")
+
+(defvar test-pkg-res--failures nil
+ "Alist of (PACKAGE . N): the next N install attempts for PACKAGE signal.")
+
+(defvar test-pkg-res--dynamic-state nil
+ "Captured dynamic state at each `package-install' call, newest first.")
+
+(defun test-pkg-res--should-fail-p (package)
+ "Return non-nil when this attempt at PACKAGE should signal, and count it."
+ (let ((cell (assq package test-pkg-res--failures)))
+ (when (and cell (> (cdr cell) 0))
+ (setcdr cell (1- (cdr cell)))
+ t)))
+
+(defun test-pkg-res--install (package)
+ "Fake `package-install' for PACKAGE: record the call, then fail or install."
+ (push package test-pkg-res--install-log)
+ (push (list :debug-on-error debug-on-error
+ :find-file-hook find-file-hook
+ :prog-mode-hook prog-mode-hook
+ :lisp-data-mode-hook lisp-data-mode-hook
+ :emacs-lisp-mode-hook emacs-lisp-mode-hook)
+ test-pkg-res--dynamic-state)
+ (if (test-pkg-res--should-fail-p package)
+ (signal 'file-error (list "https://elpa.example.invalid/x.tar" "No Data"))
+ (push package test-pkg-res--installed)))
+
+(defmacro test-pkg-res--with-registry (available installed failures &rest body)
+ "Run BODY against a fake package registry.
+AVAILABLE lists package symbols the archives carry, INSTALLED those already
+installed, and FAILURES is an alist of (PACKAGE . N) attempts that signal."
+ (declare (indent 3) (debug t))
+ `(let ((test-pkg-res--installed (copy-sequence ,installed))
+ (test-pkg-res--install-log nil)
+ (test-pkg-res--failures (copy-tree ,failures))
+ (test-pkg-res--dynamic-state nil)
+ (cj/failed-package-installs nil)
+ (cj/failed-source-package-installs nil)
+ (cj/package-install-retry-delay 0)
+ ;; Both of these accumulate across a whole session by design, so a
+ ;; test that leaves them set changes what a later test does: an
+ ;; unbound failure counter tripped the circuit breaker and three
+ ;; install tests stopped installing anything at all.
+ (cj/--package-retry-spent 0.0)
+ (cj/--package-consecutive-failures 0)
+ (package-archive-contents (mapcar #'list ,available)))
+ (cl-letf (((symbol-function 'package-installed-p)
+ (lambda (pkg &rest _) (and (memq pkg test-pkg-res--installed) t)))
+ ((symbol-function 'package-install)
+ (lambda (pkg &rest _) (test-pkg-res--install pkg)))
+ ((symbol-function 'package-refresh-contents) (lambda (&rest _) nil))
+ ((symbol-function 'package-read-all-archive-contents) (lambda (&rest _) nil))
+ ((symbol-function 'sleep-for) (lambda (&rest _) nil)))
+ ,@body)))
+
+;;; --------------------------- Resolving :ensure args --------------------------
+
+(ert-deftest test-package-resilience-packages-resolves-t-to-name ()
+ "Normal: an :ensure of t resolves to the use-package form's own name."
+ (should (equal '(foo) (cj/--package-ensure-packages 'foo '(t)))))
+
+(ert-deftest test-package-resilience-packages-resolves-explicit-symbol ()
+ "Normal: an explicit :ensure symbol names a different package."
+ (should (equal '(bar) (cj/--package-ensure-packages 'foo '(bar)))))
+
+(ert-deftest test-package-resilience-packages-nil-ensure-is-empty ()
+ "Boundary: :ensure nil requests no package at all."
+ (should (equal '() (cj/--package-ensure-packages 'foo '(nil)))))
+
+(ert-deftest test-package-resilience-packages-unwraps-pinned-cons ()
+ "Boundary: a pinned (PACKAGE . ARCHIVE) cell resolves to the package symbol."
+ (should (equal '(bar) (cj/--package-ensure-packages 'foo '((bar . "melpa"))))))
+
+(ert-deftest test-package-resilience-packages-accepts-string-name ()
+ "Boundary: a use-package form named with a string still resolves to a symbol."
+ (should (equal '(foo) (cj/--package-ensure-packages "foo" '(t)))))
+
+(ert-deftest test-package-resilience-packages-handles-several-ensures ()
+ "Boundary: several :ensure keywords resolve to several packages."
+ (should (equal '(bar baz) (cj/--package-ensure-packages 'foo '(bar baz)))))
+
+;;; ------------------------------ Installing ----------------------------------
+
+(ert-deftest test-package-resilience-installs-missing-package ()
+ "Normal: a missing package is installed and nothing is recorded as failed."
+ (test-pkg-res--with-registry '(foo) '() '()
+ (cj/package-ensure 'foo '(t) nil)
+ (should (equal '(foo) test-pkg-res--install-log))
+ (should (memq 'foo test-pkg-res--installed))
+ (should-not cj/failed-package-installs)))
+
+(ert-deftest test-package-resilience-skips-installed-package ()
+ "Normal: an already-installed package is never downloaded again."
+ (test-pkg-res--with-registry '(foo) '(foo) '()
+ (cj/package-ensure 'foo '(t) nil)
+ (should-not test-pkg-res--install-log)
+ (should-not cj/failed-package-installs)))
+
+(ert-deftest test-package-resilience-survives-failure-under-debug-on-error ()
+ "Error: a failed install is recorded, not signalled, even with debug-on-error.
+This is the regression: `condition-case-unless-debug' inside use-package does
+not catch while `debug-on-error' is non-nil, so a transient download error
+aborted init in place."
+ (test-pkg-res--with-registry '(foo) '() '((foo . 999))
+ (let ((debug-on-error t))
+ (cj/package-ensure 'foo '(t) nil)
+ (should (memq 'foo cj/failed-package-installs))
+ (should-not (memq 'foo test-pkg-res--installed)))))
+
+(ert-deftest test-package-resilience-retries-transient-failure ()
+ "Error: a download that fails once and then succeeds installs on the retry."
+ (test-pkg-res--with-registry '(foo) '() '((foo . 1))
+ (cj/package-ensure 'foo '(t) nil)
+ (should (= 2 (length test-pkg-res--install-log)))
+ (should (memq 'foo test-pkg-res--installed))
+ (should-not cj/failed-package-installs)))
+
+(ert-deftest test-package-resilience-stops-after-configured-retries ()
+ "Boundary: a package that always fails is attempted retries-plus-one times."
+ (test-pkg-res--with-registry '(foo) '() '((foo . 999))
+ (let ((cj/package-install-retries 2))
+ (cj/package-ensure 'foo '(t) nil)
+ (should (= 3 (length test-pkg-res--install-log)))
+ (should (memq 'foo cj/failed-package-installs)))))
+
+(ert-deftest test-package-resilience-does-not-retry-unknown-package ()
+ "Boundary: a package no archive carries is attempted once, then recorded.
+Retrying a name the archives have never heard of only burns refreshes."
+ (test-pkg-res--with-registry '() '() '((foo . 999))
+ (let ((cj/package-install-retries 2))
+ (cj/package-ensure 'foo '(t) nil)
+ (should (= 1 (length test-pkg-res--install-log)))
+ (should (memq 'foo cj/failed-package-installs)))))
+
+(ert-deftest test-package-resilience-inhibits-editing-hooks-during-install ()
+ "Error: editing hooks are silenced while a package installs.
+Installation generates autoloads by visiting .el files, so a hook belonging to
+a package that failed earlier would otherwise break unrelated installs."
+ (test-pkg-res--with-registry '(foo) '() '()
+ (let ((find-file-hook '(ignore))
+ (prog-mode-hook '(ignore))
+ (lisp-data-mode-hook '(ignore))
+ (emacs-lisp-mode-hook '(ignore)))
+ (cj/package-ensure 'foo '(t) nil)
+ (let ((seen (car test-pkg-res--dynamic-state)))
+ (should-not (plist-get seen :find-file-hook))
+ (should-not (plist-get seen :prog-mode-hook))
+ (should-not (plist-get seen :lisp-data-mode-hook))
+ (should-not (plist-get seen :emacs-lisp-mode-hook))
+ (should-not (plist-get seen :debug-on-error))))))
+
+(ert-deftest test-package-resilience-records-each-failure-once ()
+ "Boundary: repeated ensure calls for one package record it a single time."
+ (test-pkg-res--with-registry '(foo) '() '((foo . 999))
+ (cj/package-ensure 'foo '(t) nil)
+ (cj/package-ensure 'foo '(t) nil)
+ (should (equal '(foo) cj/failed-package-installs))))
+
+;;; ------------------------------ Retry budget ---------------------------------
+
+(ert-deftest test-package-resilience-budget-caps-retrying ()
+ "Boundary: with the retry budget spent, a failure gets its one attempt only.
+An offline machine fails every package, so an uncapped per-package retry would
+turn the abort this module removes into a startup that appears to hang."
+ (test-pkg-res--with-registry '(foo) '() '((foo . 999))
+ (let ((cj/package-install-retries 2)
+ (cj/--package-retry-spent 999.0))
+ (cj/package-ensure 'foo '(t) nil)
+ (should (= 1 (length test-pkg-res--install-log)))
+ (should (memq 'foo cj/failed-package-installs)))))
+
+(ert-deftest test-package-resilience-budget-still-records-failures ()
+ "Boundary: a package skipped for budget is still recorded and reportable."
+ (test-pkg-res--with-registry '(foo) '() '((foo . 999))
+ (let ((cj/--package-retry-spent 999.0))
+ (cj/package-ensure 'foo '(t) nil)
+ (should (equal '(foo) (cj/package-still-missing))))))
+
+(ert-deftest test-package-resilience-budget-accrues-across-packages ()
+ "Error: retry time spent on one package counts against the next one's budget.
+The budget is per session, not per package, which is what bounds a machine
+offline for all ~190 of them. The clock is advanced ten seconds per reading so
+the accrual is real rather than an artifact of a mocked sleep."
+ (test-pkg-res--with-registry '(foo bar) '() '((foo . 999) (bar . 999))
+ (let ((cj/package-install-retries 2)
+ (cj/package-install-retry-budget 15.0)
+ (cj/--package-retry-spent 0.0)
+ (clock 0.0))
+ (cl-letf (((symbol-function 'float-time)
+ (lambda (&rest _) (setq clock (+ clock 10.0)))))
+ (cj/package-ensure 'foo '(t) nil)
+ (cj/package-ensure 'bar '(t) nil))
+ ;; foo: one attempt plus two retries, spending 20s. bar: one attempt,
+ ;; because foo already overspent the shared budget.
+ (should (= 4 (length test-pkg-res--install-log)))
+ (should (> cj/--package-retry-spent cj/package-install-retry-budget)))))
+
+;;; ----------------------------- Circuit breaker -------------------------------
+
+(ert-deftest test-package-resilience-stops-attempting-after-failure-run ()
+ "Error: enough failures in a row and later packages are recorded untried.
+Offline, nothing populates the archive list, so every single attempt pays a
+full `package-refresh-contents' before failing. Across ~190 packages that is
+the dominant cost, and no retry ceiling bounds it."
+ (test-pkg-res--with-registry '(a b c) '() '((a . 999) (b . 999) (c . 999))
+ (let ((cj/package-install-retries 0)
+ (cj/package-install-failure-limit 2))
+ (cj/package-ensure 'a '(t) nil)
+ (cj/package-ensure 'b '(t) nil)
+ (cj/package-ensure 'c '(t) nil)
+ ;; a and b were tried; c was not, because the run had already reached 2.
+ (should (equal '(b a) test-pkg-res--install-log))
+ (should (memq 'c cj/failed-package-installs)))))
+
+(ert-deftest test-package-resilience-failure-run-resets-on-success ()
+ "Boundary: one success clears the run, so an unlucky package is not fatal.
+The breaker exists to detect a dead network, not to give up after N scattered
+failures across an otherwise healthy install."
+ (test-pkg-res--with-registry '(a b c) '() '((a . 999) (c . 999))
+ (let ((cj/package-install-retries 0)
+ (cj/package-install-failure-limit 2))
+ (cj/package-ensure 'a '(t) nil) ; fails, run = 1
+ (cj/package-ensure 'b '(t) nil) ; succeeds, run = 0
+ (cj/package-ensure 'c '(t) nil) ; fails, run = 1, still under the limit
+ (should (equal '(c b a) test-pkg-res--install-log)))))
+
+(ert-deftest test-package-resilience-installed-package-does-not-clear-run ()
+ "Boundary: a package that was already present tells us nothing about the net.
+Counting it as a success would reset the run on every built-in-backed form and
+the breaker would never trip on an offline machine."
+ (test-pkg-res--with-registry '(a b c) '(b) '((a . 999) (c . 999))
+ (let ((cj/package-install-retries 0)
+ (cj/package-install-failure-limit 2))
+ (cj/package-ensure 'a '(t) nil) ; fails, run = 1
+ (cj/package-ensure 'b '(t) nil) ; already installed, untouched
+ (cj/package-ensure 'c '(t) nil) ; fails, run = 2
+ (should (equal '(c a) test-pkg-res--install-log))
+ (should (cj/--package-giving-up-p)))))
+
+;;; --------------------- Packages installed from source (:vc) ------------------
+
+(defun test-pkg-res--vc-orig (fails)
+ "Return a fake `use-package-vc-install' that signals when FAILS is non-nil."
+ (lambda (arg &optional _local-path)
+ (push (car arg) test-pkg-res--install-log)
+ (if fails
+ (signal 'error (list "Cloning failed: Permission denied (publickey)"))
+ (push (car arg) test-pkg-res--installed))))
+
+(ert-deftest test-package-resilience-vc-install-succeeds-quietly ()
+ "Normal: a working source install is not recorded as a failure."
+ (test-pkg-res--with-registry '() '() '()
+ (cj/--package-vc-install-guard (test-pkg-res--vc-orig nil) '(gloss nil nil))
+ (should (memq 'gloss test-pkg-res--installed))
+ (should-not cj/failed-package-installs)))
+
+(ert-deftest test-package-resilience-vc-install-survives-failed-clone ()
+ "Error: a failed clone is recorded, not signalled, even with debug-on-error.
+`:vc' forms route around `use-package-ensure-function' entirely and
+`use-package-vc-install' has no error handling, so without this guard a fresh
+machine lacking credentials for the git host aborts init exactly as before."
+ (test-pkg-res--with-registry '() '() '()
+ (let ((debug-on-error t))
+ (cj/--package-vc-install-guard (test-pkg-res--vc-orig t) '(gloss nil nil))
+ (should (memq 'gloss cj/failed-source-package-installs))
+ (should (memq 'gloss (cj/package-still-missing)))
+ ;; Never the archive list: `package-install' cannot recover a source
+ ;; package, and for one that also exists on an archive it would install
+ ;; the archive build instead of the checkout that was asked for.
+ (should-not (memq 'gloss cj/failed-package-installs))
+ (should-not (memq 'gloss test-pkg-res--installed)))))
+
+(ert-deftest test-package-resilience-vc-failure-counts-toward-breaker ()
+ "Error: a failed clone counts toward the consecutive-failure run.
+No credentials means every source package fails, the same shape as no network."
+ (test-pkg-res--with-registry '() '() '()
+ (let ((cj/package-install-failure-limit 2))
+ (cj/--package-vc-install-guard (test-pkg-res--vc-orig t) '(gloss nil nil))
+ (cj/--package-vc-install-guard (test-pkg-res--vc-orig t) '(chime nil nil))
+ (should (cj/--package-giving-up-p)))))
+
+(ert-deftest test-package-resilience-vc-skipped-once-breaker-tripped ()
+ "Boundary: with the breaker tripped a source install is recorded untried."
+ (test-pkg-res--with-registry '() '() '()
+ (let ((cj/--package-consecutive-failures 99))
+ (cj/--package-vc-install-guard (test-pkg-res--vc-orig t) '(gloss nil nil))
+ (should-not test-pkg-res--install-log)
+ (should (memq 'gloss cj/failed-source-package-installs)))))
+
+(ert-deftest test-package-resilience-vc-installed-package-passes-through ()
+ "Boundary: an already-installed source package neither counts nor records.
+It says nothing about whether the git host is reachable, so treating it as a
+success would reset the run and stop the breaker ever tripping."
+ (test-pkg-res--with-registry '() '(gloss) '()
+ (let ((cj/--package-consecutive-failures 3))
+ (cj/--package-vc-install-guard (test-pkg-res--vc-orig nil) '(gloss nil nil))
+ (should (= 3 cj/--package-consecutive-failures))
+ (should-not cj/failed-package-installs))))
+
+(ert-deftest test-package-resilience-vc-success-clears-failure-run ()
+ "Boundary: a clone that works clears the run, like any other install."
+ (test-pkg-res--with-registry '() '() '()
+ (let ((cj/--package-consecutive-failures 3))
+ (cj/--package-vc-install-guard (test-pkg-res--vc-orig nil) '(gloss nil nil))
+ (should (= 0 cj/--package-consecutive-failures)))))
+
+;;; --------------------------------- Retrying ----------------------------------
+
+(ert-deftest test-package-resilience-retry-clears-recovered-package ()
+ "Normal: retrying installs a package that is now reachable and clears it."
+ (test-pkg-res--with-registry '(foo) '() '()
+ (setq cj/failed-package-installs '(foo))
+ (cj/retry-failed-package-installs)
+ (should (memq 'foo test-pkg-res--installed))
+ (should-not cj/failed-package-installs)))
+
+(ert-deftest test-package-resilience-retry-converges-on-cascade ()
+ "Boundary: a package installable only on a later pass still converges.
+A failed package leaves hooks that break other installs, so recovery has to
+keep passing over the set until a pass installs nothing new."
+ (test-pkg-res--with-registry '(foo bar) '() '((bar . 1))
+ (setq cj/failed-package-installs '(foo bar))
+ (cj/retry-failed-package-installs)
+ (should (memq 'foo test-pkg-res--installed))
+ (should (memq 'bar test-pkg-res--installed))
+ (should-not cj/failed-package-installs)))
+
+(ert-deftest test-package-resilience-retry-leaves-source-packages-alone ()
+ "Boundary: retrying never runs `package-install' on a source package.
+It cannot recover one, and for a source package that also exists on an archive
+it would install the archive build instead of the checkout that was declared,
+leaving `package-installed-p' true and the source install permanently skipped."
+ (test-pkg-res--with-registry '(gloss) '() '()
+ (setq cj/failed-source-package-installs '(gloss))
+ (cj/retry-failed-package-installs)
+ (should-not test-pkg-res--install-log)
+ (should (equal '(gloss) (cj/package-still-missing)))))
+
+(ert-deftest test-package-resilience-retry-works-with-empty-archive-list ()
+ "Error: recovery still attempts installs when no archive list is loaded yet.
+This is the case the command exists for: a laptop that booted before its wifi
+came up has an empty `package-archive-contents', and screening recorded
+packages against it would make the command a silent no-op right when the user
+finally has a network. `package-install' populates the archives itself."
+ (test-pkg-res--with-registry '() '() '()
+ (setq cj/failed-package-installs '(foo bar))
+ (should-not package-archive-contents)
+ (cj/retry-failed-package-installs)
+ (should (equal '(bar foo) test-pkg-res--install-log))
+ (should-not (cj/package-still-missing))))
+
+(ert-deftest test-package-resilience-retry-terminates-when-impossible ()
+ "Error: a package that can never install terminates the loop and stays listed."
+ (test-pkg-res--with-registry '(foo) '() '((foo . 999))
+ (setq cj/failed-package-installs '(foo))
+ (cj/retry-failed-package-installs)
+ (should (equal '(foo) cj/failed-package-installs))))
+
+(ert-deftest test-package-resilience-retry-with-nothing-failed-is-quiet ()
+ "Boundary: retrying an empty failure set installs nothing."
+ (test-pkg-res--with-registry '(foo) '() '()
+ (setq cj/failed-package-installs nil)
+ (cj/retry-failed-package-installs)
+ (should-not test-pkg-res--install-log)))
+
+;;; -------------------------------- Reporting ----------------------------------
+
+(ert-deftest test-package-resilience-report-is-silent-when-clean ()
+ "Normal: a run with no failed installs raises no warning."
+ (test-pkg-res--with-registry '() '() '()
+ (let ((warned nil))
+ (cl-letf (((symbol-function 'display-warning)
+ (lambda (&rest _) (setq warned t))))
+ (cj/report-failed-package-installs)
+ (should-not warned)))))
+
+(ert-deftest test-package-resilience-still-missing-does-not-mutate-records ()
+ "Error: reading the missing set leaves both record lists intact.
+`append' does not copy its last argument and `delete-dups' splices, so the
+obvious spelling edits `cj/failed-source-package-installs' in place. Reading a
+value must not destroy it, least of all from the startup report."
+ (test-pkg-res--with-registry '() '() '()
+ (setq cj/failed-package-installs '(foo shared))
+ (setq cj/failed-source-package-installs '(gloss shared chime))
+ (cj/package-still-missing)
+ (should (equal '(foo shared) cj/failed-package-installs))
+ (should (equal '(gloss shared chime) cj/failed-source-package-installs))))
+
+(ert-deftest test-package-resilience-report-omits-package-installed-since ()
+ "Boundary: a package that arrived later as a dependency is not reported.
+It failed on its own use-package form, so it is on the recorded list, but it is
+present now and there is nothing for the user to do about it."
+ (test-pkg-res--with-registry '(foo bar) '(foo) '()
+ (setq cj/failed-package-installs '(foo bar))
+ (should (equal '(bar) (cj/package-still-missing)))
+ (let ((message-text nil))
+ (cl-letf (((symbol-function 'display-warning)
+ (lambda (_type msg &rest _) (setq message-text msg))))
+ (cj/report-failed-package-installs)
+ (should (string-match-p "bar" message-text))
+ (should-not (string-match-p "foo" message-text))))))
+
+(ert-deftest test-package-resilience-report-silent-when-all-arrived-since ()
+ "Boundary: recorded failures that are all installed now raise no warning."
+ (test-pkg-res--with-registry '(foo) '(foo) '()
+ (setq cj/failed-package-installs '(foo))
+ (let ((warned nil))
+ (cl-letf (((symbol-function 'display-warning)
+ (lambda (&rest _) (setq warned t))))
+ (cj/report-failed-package-installs)
+ (should-not warned)))))
+
+(ert-deftest test-package-resilience-report-says-when-it-stopped-early ()
+ "Error: a tripped breaker is said out loud, so untried is not read as failed.
+Most of a long list would never have been attempted, and reporting those as
+install failures would send the user hunting for ~185 individual problems."
+ (test-pkg-res--with-registry '(foo) '() '()
+ (setq cj/failed-package-installs '(foo))
+ (let ((cj/--package-consecutive-failures 99)
+ (message-text nil))
+ (cl-letf (((symbol-function 'display-warning)
+ (lambda (_type msg &rest _) (setq message-text msg))))
+ (cj/report-failed-package-installs)
+ (should (string-match-p "never" message-text))
+ ;; Names both causes: a run of failures is a dead network or missing
+ ;; credentials, and the message should not pick one.
+ (should (string-match-p "network" message-text))
+ (should (string-match-p "credentials" message-text))))))
+
+(ert-deftest test-package-resilience-report-omits-early-stop-when-not-tripped ()
+ "Boundary: an ordinary failure is not dressed up as a machine being offline."
+ (test-pkg-res--with-registry '(foo) '() '()
+ (setq cj/failed-package-installs '(foo))
+ (let ((cj/--package-consecutive-failures 0)
+ (message-text nil))
+ (cl-letf (((symbol-function 'display-warning)
+ (lambda (_type msg &rest _) (setq message-text msg))))
+ (cj/report-failed-package-installs)
+ (should-not (string-match-p "never" message-text))))))
+
+(ert-deftest test-package-resilience-report-warns-and-names-failures ()
+ "Error: failed installs raise one warning that names every package."
+ (test-pkg-res--with-registry '(foo bar) '() '()
+ (setq cj/failed-package-installs '(foo bar))
+ (let ((message-text nil))
+ (cl-letf (((symbol-function 'display-warning)
+ (lambda (_type msg &rest _) (setq message-text msg))))
+ (cj/report-failed-package-installs)
+ (should message-text)
+ (should (string-match-p "foo" message-text))
+ (should (string-match-p "bar" message-text))))))
+
+;;; ---------------------------------- Wiring -----------------------------------
+
+;; The guard tests above call the functions directly, which says nothing about
+;; whether they are actually reachable from a real startup. If use-package
+;; renamed either seam, every test above would still pass while the whole
+;; module sat dead -- this repo's recurring failure, a gate that was green
+;; because it never ran.
+
+(ert-deftest test-package-resilience-is-wired-to-use-package ()
+ "Normal: loading the module actually takes over both use-package seams.
+`advice-member-p' answers yes for advice attached to a symbol that was never
+defined, so it alone would still pass if upstream renamed the function and left
+the advice on a dead symbol. That rename is the whole scenario this test
+exists for, hence the `fboundp'."
+ (should (eq use-package-ensure-function #'cj/package-ensure))
+ (should (fboundp 'use-package-vc-install))
+ (should (advice-member-p #'cj/--package-vc-install-guard
+ 'use-package-vc-install)))
+
+(ert-deftest test-package-resilience-reports-at-startup ()
+ "Normal: the end-of-startup report is on `emacs-startup-hook'."
+ (should (memq #'cj/report-failed-package-installs
+ (default-value 'emacs-startup-hook))))
+
+(provide 'test-package-resilience)
+;;; test-package-resilience.el ends here
diff --git a/tests/test-prog-general-yas-activation.el b/tests/test-prog-general-yas-activation.el
index d6ea42cd..d9ae76e3 100644
--- a/tests/test-prog-general-yas-activation.el
+++ b/tests/test-prog-general-yas-activation.el
@@ -122,7 +122,11 @@ produces the marker block."
"Boundary: <cj + expand in python-ts-mode (a tree-sitter prog-mode-derived
mode) produces the marker block. Verifies the snippet reaches modern
tree-sitter modes through fundamental-mode inheritance."
- (skip-unless (fboundp 'python-ts-mode))
+ ;; `python-ts-mode' prompts to install a missing grammar, which a batch run
+ ;; cannot answer, so skip on the grammar rather than on the mode's existence.
+ (skip-unless (and (fboundp 'python-ts-mode)
+ (require 'treesit nil t)
+ (treesit-ready-p 'python t)))
(should (string= (test-prog-general--expand-cj-in-mode #'python-ts-mode)
test-prog-general--cj-expected)))
diff --git a/tests/test-setup-telega.bats b/tests/test-setup-telega.bats
index 3282b9e1..518ecc7b 100644
--- a/tests/test-setup-telega.bats
+++ b/tests/test-setup-telega.bats
@@ -57,11 +57,11 @@ setup() {
# --------------------------- pull_or_announce_image -----------------------
-@test "pull_or_announce_image: announces the in-Emacs build when no image is set" {
+@test "pull_or_announce_image: points at make telega-image when no image is set" {
TELEGA_DOCKER_IMAGE=""
run pull_or_announce_image
[ "$status" -eq 0 ]
- [[ "$output" == *"M-x telega-server-build"* ]]
+ [[ "$output" == *"make telega-image"* ]]
}
@test "pull_or_announce_image: pulls when TELEGA_DOCKER_IMAGE is set" {
diff --git a/tests/test-system-defaults--warning-display-dead-buffer.el b/tests/test-system-defaults--warning-display-dead-buffer.el
new file mode 100644
index 00000000..48c7797e
--- /dev/null
+++ b/tests/test-system-defaults--warning-display-dead-buffer.el
@@ -0,0 +1,103 @@
+;;; test-system-defaults--warning-display-dead-buffer.el --- Dead-buffer guard on deferred warnings -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Emacs 31.1's warnings.el defers daemon-startup warnings into a one-shot
+;; `after-make-frame-functions' closure that calls `warning--display-buffer'
+;; on the first client frame with the *Warnings* buffer object it captured at
+;; warning time. If anything killed that buffer in between, `display-buffer'
+;; signals inside `make-frame', server.el swallows the error as
+;; "-window-system-unsupported", and emacsclient silently retries on $DISPLAY:
+;; the session's first frame lands on XWayland.
+;;
+;; The root fix keeps *Warnings* alive (undead-buffers.el). This is the
+;; defense in depth: `cj/warning--display-buffer-if-live' wraps
+;; `warning--display-buffer' so a dead buffer is skipped rather than passed
+;; on. Load happens once in the shared sandbox (testutil-system-defaults.el);
+;; `warning--display-buffer' only exists from Emacs 31, so the end-to-end case
+;; skips on older builds while the pure-function cases always run.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "tests" user-emacs-directory))
+(require 'testutil-system-defaults)
+
+(test-system-defaults--with-load-environment
+ (test-system-defaults--load))
+
+(defun test-system-defaults--recording-orig ()
+ "Return (ORIG . CALLS) where ORIG records every argument into CALLS."
+ (let ((calls (list nil)))
+ (cons (lambda (buffer)
+ (push buffer (car calls))
+ 'displayed)
+ calls)))
+
+;;; Normal Cases
+
+(ert-deftest test-system-defaults-warning-guard-passes-live-buffer-through ()
+ "Normal: a live buffer reaches the original and its value is returned."
+ (let* ((rec (test-system-defaults--recording-orig))
+ (buf (generate-new-buffer " *warning-guard-live*")))
+ (unwind-protect
+ (progn
+ (should (eq 'displayed
+ (cj/warning--display-buffer-if-live (car rec) buf)))
+ (should (equal (list buf) (car (cdr rec)))))
+ (kill-buffer buf))))
+
+(ert-deftest test-system-defaults-warning-guard-is-installed ()
+ "Normal: loading system-defaults installs the guard on the deferred display.
+From Emacs 31 the advised symbol must actually be defined: pending advice on
+an undefined symbol would still count as installed, so a rename upstream
+would otherwise silently disable the backstop."
+ (should (advice-member-p #'cj/warning--display-buffer-if-live
+ 'warning--display-buffer))
+ (when (>= emacs-major-version 31)
+ (should (fboundp 'warning--display-buffer))))
+
+(ert-deftest test-system-defaults-warning-guard-resolves-live-buffer-name ()
+ "Normal: a live buffer's name is resolved and passed through as the buffer."
+ (let* ((rec (test-system-defaults--recording-orig))
+ (buf (generate-new-buffer " *warning-guard-named*")))
+ (unwind-protect
+ (progn
+ (should (eq 'displayed
+ (cj/warning--display-buffer-if-live
+ (car rec) (buffer-name buf))))
+ (should (equal (list buf) (car (cdr rec)))))
+ (kill-buffer buf))))
+
+;;; Boundary Cases
+
+(ert-deftest test-system-defaults-warning-guard-skips-killed-buffer ()
+ "Boundary: a killed buffer never reaches the original; result is nil."
+ (let* ((rec (test-system-defaults--recording-orig))
+ (buf (generate-new-buffer " *warning-guard-dead*")))
+ (kill-buffer buf)
+ (should-not (cj/warning--display-buffer-if-live (car rec) buf))
+ (should-not (car (cdr rec)))))
+
+(ert-deftest test-system-defaults-warning-guard-end-to-end-dead-buffer-does-not-signal ()
+ "Boundary: the real deferred display survives a dead buffer.
+Mirrors the live failure: a string condition in `display-buffer-alist' is
+what `buffer-match-p' tripped over when the buffer name came back nil."
+ (skip-unless (fboundp 'warning--display-buffer))
+ (let ((buf (generate-new-buffer "*Warnings*"))
+ (display-buffer-alist '(("^ \\*test-guard\\*" display-buffer-no-window))))
+ (kill-buffer buf)
+ (should-not (warning--display-buffer buf))))
+
+;;; Error Cases
+
+(ert-deftest test-system-defaults-warning-guard-rejects-non-buffer ()
+ "Error: nil, or a name that resolves to no buffer, is skipped without a signal."
+ (let ((rec (test-system-defaults--recording-orig))
+ (missing " *warning-guard-no-such-buffer*"))
+ (when (get-buffer missing) (kill-buffer missing))
+ (should-not (cj/warning--display-buffer-if-live (car rec) nil))
+ (should-not (cj/warning--display-buffer-if-live (car rec) missing))
+ (should-not (car (cdr rec)))))
+
+(provide 'test-system-defaults--warning-display-dead-buffer)
+;;; test-system-defaults--warning-display-dead-buffer.el ends here
diff --git a/tests/test-system-defaults-functions.el b/tests/test-system-defaults-functions.el
index 4b647166..09bf9f2a 100644
--- a/tests/test-system-defaults-functions.el
+++ b/tests/test-system-defaults-functions.el
@@ -55,6 +55,12 @@
;; so it doesn't leak into a shared batch session. `make test-name' loads
;; every test file into one Emacs; a leaked cwd there breaks the relative
;; loads of every file that follows.
+;; Declared special before the `let' below binds it: this file is lexical,
+;; so without the defvar the binding is a lexical local, and use-package's
+;; own `defcustom' then fails with "Defining as dynamic an already lexical
+;; var" (fatal at load since 31.1 moved the defcustom to autoload time).
+(defvar use-package-always-ensure)
+
(let ((default-directory default-directory)
(use-package-always-ensure nil))
(cl-letf (((symbol-function 'server-running-p) (lambda (&rest _) t))
diff --git a/tests/test-system-lib-auth-source-secret-value.el b/tests/test-system-lib-auth-source-secret-value.el
index ec526cec..27a2696b 100644
--- a/tests/test-system-lib-auth-source-secret-value.el
+++ b/tests/test-system-lib-auth-source-secret-value.el
@@ -63,5 +63,44 @@ Captures the call args in `test-ass--args'."
(test-ass--with-search (list (list :host "h"))
(should (null (cj/auth-source-secret-value "h")))))
+;;; Error
+
+(ert-deftest test-auth-source-secret-value-loads-auth-source-when-absent ()
+ "Error: with `auth-source-search' unavailable, the helper loads auth-source.
+
+Under `emacs --batch -Q' nothing else pulls auth-source in, so a helper
+carrying only a `declare-function' dies with a void-function on the first
+lookup. An interactive Emacs hides this completely -- something in init
+always has auth-source loaded by the time anyone calls here -- which is why
+it surfaced only on the batch calendar sync, and only on the machine whose
+feeds resolve through `:secret-host' rather than an inline URL.
+
+The stubbed `require' installs the entry point the way loading auth-source.el
+would, so the call can complete and the return value is checked too."
+ (let ((required nil))
+ (cl-letf (((symbol-function 'auth-source-search) nil)
+ ((symbol-function 'require)
+ (lambda (feature &rest _)
+ (push feature required)
+ (fset 'auth-source-search
+ (lambda (&rest _) (list (list :secret "loaded"))))
+ feature)))
+ (should (equal "loaded" (cj/auth-source-secret-value "h")))
+ (should (memq 'auth-source required)))))
+
+(ert-deftest test-auth-source-secret-value-does-not-reload-when-present ()
+ "Error: an available `auth-source-search' is used as-is, never re-required.
+
+An unconditional `require' re-loads auth-source.el over whatever is in place,
+replacing a caller's stub mid-call -- which sent a test that meant to fake the
+lookup out to the real authinfo, where it hung for twelve seconds on gpg."
+ (let ((required nil))
+ (cl-letf (((symbol-function 'require)
+ (lambda (feature &rest _) (push feature required) feature))
+ ((symbol-function 'auth-source-search)
+ (lambda (&rest _) (list (list :secret "stubbed")))))
+ (should (equal "stubbed" (cj/auth-source-secret-value "h")))
+ (should-not (memq 'auth-source required)))))
+
(provide 'test-system-lib-auth-source-secret-value)
;;; test-system-lib-auth-source-secret-value.el ends here
diff --git a/tests/test-telega-config--docker-pin.el b/tests/test-telega-config--docker-pin.el
index 894dbfdd..8f5b62bd 100644
--- a/tests/test-telega-config--docker-pin.el
+++ b/tests/test-telega-config--docker-pin.el
@@ -5,9 +5,15 @@
;;
;; telega infers its image from `telega-tdlib-min-version' and only pins to a
;; version tag when min and max versions are equal and the version ends in
-;; ".0". This config has min "1.8.64" and max nil, so the inference always
+;; ".0". This config has min "1.8.66" and max nil, so the inference always
;; falls through to "zevlg/telega-server:latest" -- a moving tag that can
;; swap the server out from under a fixed elisp version without notice.
+;;
+;; Since 2026-08-25 the pin names an image built locally from
+;; docker/telega-server/Dockerfile (upstream's image is missing a shared
+;; library, zevlg/telega.el#596). Three files have to agree on that image:
+;; the defcustom default, the Makefile's build tag, and the Dockerfile's
+;; digest-pinned base. The tests below hold them together.
;;; Code:
@@ -17,6 +23,18 @@
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
(require 'telega-config)
+(defun test-telega-config--file-string (relative)
+ "Return the contents of RELATIVE under `user-emacs-directory'."
+ (with-temp-buffer
+ (insert-file-contents (expand-file-name relative user-emacs-directory))
+ (buffer-string)))
+
+(defun test-telega-config--pin-default ()
+ "Return the defcustom's shipped default, not the live value.
+Customizing the pin (including to nil) is not a test failure; only
+changing the shipped default is."
+ (eval (car (get 'cj/telega-docker-image 'standard-value)) t))
+
;; -- cj/--telega-docker-pinned-image -----------------------------------------
(ert-deftest test-telega-config-pin-returns-configured-reference ()
@@ -77,17 +95,118 @@ name, so this stays a reversible setting."
"Normal: the advice is a named function so it can be removed by reference."
(should (fboundp 'cj/--telega-docker-image-name)))
-(ert-deftest test-telega-config-pin-default-is-a-digest-reference ()
- "Normal: the shipped default pins by digest, not by a floating tag.
-A tag pin (:latest, or even a version tag upstream can re-push) still
-moves; a digest names one immutable image.
-
-Reads the defcustom's standard value rather than the live variable, so
-customizing the pin (including to nil, handing the choice back to telega)
-is not a test failure -- only changing the shipped default is."
- (let ((default (eval (car (get 'cj/telega-docker-image 'standard-value)) t)))
+;; -- the shipped default and the files it depends on -------------------------
+
+(ert-deftest test-telega-config-pin-default-matches-makefile-build-tag ()
+ "Normal: the default pin is exactly the tag `make telega-image' builds.
+The image is built locally, so the pin is a tag rather than a registry
+digest. The Makefile owns the tag; the defcustom must name the same one
+or a fresh machine builds an image telega never looks for."
+ (let ((makefile (test-telega-config--file-string "Makefile")))
+ (should (string-match "^TELEGA_IMAGE[ \t]*[?:]?=[ \t]*\\([^ \t\n]+\\)" makefile))
+ (should (equal (test-telega-config--pin-default)
+ (match-string 1 makefile)))))
+
+(ert-deftest test-telega-config-pin-default-is-a-local-tag-not-a-digest ()
+ "Boundary: the default is a plain tag, with no registry digest suffix.
+A locally built image has no RepoDigest, so a digest reference here could
+never resolve."
+ (let ((default (test-telega-config--pin-default)))
(should (stringp default))
- (should (string-match-p "@sha256:[0-9a-f]\\{64\\}\\'" default))))
+ (should (string-match-p "\\`[a-z0-9./-]+:[A-Za-z0-9._-]+\\'" default))
+ (should-not (string-match-p "@sha256:" default))))
+
+(ert-deftest test-telega-config-dockerfile-pins-base-image-by-digest ()
+ "Normal: the Dockerfile's base is an immutable upstream digest.
+This is where the digest guarantee the old pin gave now lives. A tag in
+the FROM line would let upstream swap the base under a rebuild."
+ (let ((dockerfile (test-telega-config--file-string "docker/telega-server/Dockerfile")))
+ (should (string-match-p
+ "^FROM zevlg/telega-server@sha256:[0-9a-f]\\{64\\}[ \t]*$"
+ dockerfile))))
+
+(ert-deftest test-telega-config-dockerfile-adds-the-missing-library ()
+ "Normal: the Dockerfile installs libglycin, the whole reason it exists.
+Upstream's image fails to start without it (zevlg/telega.el#596)."
+ (let ((dockerfile (test-telega-config--file-string "docker/telega-server/Dockerfile")))
+ (should (string-match-p "^RUN apk add .*libglycin" dockerfile))))
+
+;; -- cj/--telega-docker-image-present-p (the docker boundary) ----------------
+;; Exercised against a fake `docker' executable on a private exec-path rather
+;; than by mocking `call-process' (a subr; see the native-comp mocking gotcha).
+
+(defun test-telega-config--with-fake-docker (exit-code thunk)
+ "Call THUNK with a fake `docker' on `exec-path' that exits EXIT-CODE."
+ (let* ((dir (make-temp-file "fake-docker-" t))
+ (script (expand-file-name "docker" dir)))
+ (unwind-protect
+ (progn
+ (with-temp-file script
+ (insert (format "#!/bin/sh\nexit %d\n" exit-code)))
+ (set-file-modes script #o700)
+ (let ((exec-path (list dir)))
+ (funcall thunk)))
+ (delete-directory dir t))))
+
+(ert-deftest test-telega-config-image-present-p-true-when-inspect-succeeds ()
+ "Normal: `docker image inspect' exiting 0 means the image is present."
+ (test-telega-config--with-fake-docker 0
+ (lambda () (should (cj/--telega-docker-image-present-p "cj/telega-server:x")))))
+
+(ert-deftest test-telega-config-image-present-p-nil-when-inspect-fails ()
+ "Boundary: a non-zero exit (no such image) reads as not present."
+ (test-telega-config--with-fake-docker 1
+ (lambda () (should-not (cj/--telega-docker-image-present-p "cj/telega-server:x")))))
+
+(ert-deftest test-telega-config-image-present-p-nil-without-docker ()
+ "Error: with no docker on `exec-path', the helper returns nil instead of
+signalling, so the launcher can still route the user to the make target."
+ (let ((exec-path nil))
+ (should-not (cj/--telega-docker-image-present-p "cj/telega-server:x"))))
+
+;; -- cj/telega refuses to launch against a missing local image ---------------
+
+(ert-deftest test-telega-config-missing-image-message-names-image-and-target ()
+ "Normal: the message names the missing image and the make target that builds it."
+ (let ((msg (cj/--telega-missing-image-message "cj/telega-server:x")))
+ (should (string-match-p "cj/telega-server:x" msg))
+ (should (string-match-p "make telega-image" msg))))
+
+(ert-deftest test-telega-config-launcher-errors-when-pinned-image-is-absent ()
+ "Error: with a pin set and no such image, `cj/telega' stops with the make hint.
+Without this, docker fails to pull a local-only tag and the error names a
+registry the image was never meant to come from."
+ (let ((cj/telega-docker-image "cj/telega-server:x")
+ (launched nil))
+ (cl-letf (((symbol-function 'locate-library) (lambda (&rest _) "telega.el"))
+ ((symbol-function 'cj/--telega-docker-image-present-p) (lambda (_) nil))
+ ((symbol-function 'telega) (lambda (&rest _) (setq launched t))))
+ (let ((err (should-error (cj/telega) :type 'user-error)))
+ (should (string-match-p "make telega-image" (cadr err))))
+ (should-not launched))))
+
+(ert-deftest test-telega-config-launcher-runs-when-pinned-image-is-present ()
+ "Normal: with the pinned image present, `cj/telega' launches telega."
+ (let ((cj/telega-docker-image "cj/telega-server:x")
+ (launched nil))
+ (cl-letf (((symbol-function 'locate-library) (lambda (&rest _) "telega.el"))
+ ((symbol-function 'cj/--telega-docker-image-present-p) (lambda (_) t))
+ ((symbol-function 'telega) (lambda (&rest _) (setq launched t))))
+ (cj/telega)
+ (should launched))))
+
+(ert-deftest test-telega-config-launcher-skips-image-check-without-a-pin ()
+ "Boundary: with no pin, telega infers and pulls its own image; no check runs."
+ (let ((cj/telega-docker-image nil)
+ (checked nil)
+ (launched nil))
+ (cl-letf (((symbol-function 'locate-library) (lambda (&rest _) "telega.el"))
+ ((symbol-function 'cj/--telega-docker-image-present-p)
+ (lambda (_) (setq checked t) nil))
+ ((symbol-function 'telega) (lambda (&rest _) (setq launched t))))
+ (cj/telega)
+ (should-not checked)
+ (should launched))))
(provide 'test-telega-config--docker-pin)
;;; test-telega-config--docker-pin.el ends here
diff --git a/tests/test-telega-config.el b/tests/test-telega-config.el
index d8aaeb4d..54a601c9 100644
--- a/tests/test-telega-config.el
+++ b/tests/test-telega-config.el
@@ -45,6 +45,10 @@ stub's cryptic load-file failure."
(let (called)
(cl-letf (((symbol-function 'featurep)
(lambda (sym &optional _sub) (eq sym 'telega)))
+ ;; The pinned image is a local build; treat it as present so
+ ;; this test stays about delegation, not the image check.
+ ((symbol-function 'cj/--telega-docker-image-present-p)
+ (lambda (_) t))
((symbol-function 'telega)
(lambda (&rest _) (setq called t))))
(cj/telega))
diff --git a/tests/test-term-tmux-detach.el b/tests/test-term-tmux-detach.el
new file mode 100644
index 00000000..9bd94776
--- /dev/null
+++ b/tests/test-term-tmux-detach.el
@@ -0,0 +1,62 @@
+;;; test-term-tmux-detach.el --- Tests for cj/term-tmux-detach -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; A keyboard C-b inside the Claude Code pane does not reach tmux as a prefix
+;; (it lands as stray text), so detaching needs the same pty string path
+;; `cj/term-copy-mode-dwim' uses for C-b [. These tests pin that path and
+;; the no-tmux fallback.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'package)
+
+;; Same shape as test-term-tmux-history.el: `make test' runs with no
+;; package-initialize, so eat has to be made loadable here before eat-config.
+(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 'eat)
+(require 'eat-config)
+
+(ert-deftest test-eat-config-tmux-detach-sends-prefix-and-d-when-attached ()
+ "Normal: with tmux attached, the command writes C-b d into the pty, nothing else."
+ (let ((sent nil))
+ (cl-letf (((symbol-function 'cj/term--in-tmux-p) (lambda () t))
+ ((symbol-function 'cj/--term-send-string) (lambda (s) (push s sent))))
+ (cj/term-tmux-detach)
+ (should (equal sent '("\C-bd"))))))
+
+(ert-deftest test-eat-config-tmux-detach-does-nothing-without-tmux ()
+ "Boundary: with no tmux client, nothing is written and the user is told why.
+Writing C-b d into a plain shell would type a control character into it."
+ (let ((sent nil)
+ (told nil))
+ (cl-letf (((symbol-function 'cj/term--in-tmux-p) (lambda () nil))
+ ((symbol-function 'cj/--term-send-string) (lambda (s) (push s sent)))
+ ((symbol-function 'message) (lambda (fmt &rest args)
+ (setq told (apply #'format fmt args)))))
+ (cj/term-tmux-detach)
+ (should-not sent)
+ (should (string-match-p "tmux" told)))))
+
+(ert-deftest test-eat-config-tmux-detach-survives-dead-process ()
+ "Error: with tmux reported attached but no live pty, the command returns
+without signalling. `cj/--term-send-string' already guards on
+`process-live-p'; this pins that the detach path relies on it rather than
+calling `process-send-string' directly."
+ (with-temp-buffer
+ (cl-letf (((symbol-function 'cj/term--in-tmux-p) (lambda () t)))
+ (should-not (condition-case err
+ (progn (cj/term-tmux-detach) nil)
+ (error err))))))
+
+(ert-deftest test-eat-config-tmux-detach-bound-on-term-map ()
+ "Normal: the command sits on the terminal map next to copy-mode (\"c\")."
+ (should (eq (keymap-lookup cj/term-map "d") #'cj/term-tmux-detach)))
+
+(provide 'test-term-tmux-detach)
+;;; test-term-tmux-detach.el ends here
diff --git a/tests/test-transcription-config--ratio-backend.el b/tests/test-transcription-config--ratio-backend.el
new file mode 100644
index 00000000..f368b859
--- /dev/null
+++ b/tests/test-transcription-config--ratio-backend.el
@@ -0,0 +1,102 @@
+;;; test-transcription-config--ratio-backend.el --- the ratio self-hosted backend -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The `ratio' backend runs whisper plus speaker diarization on my own host
+;; through the scripts/ratio-transcribe client. It needs no API key, so its
+;; descriptor carries nil for both :auth-host and :env-var, and the process
+;; environment passes through unchanged.
+;;
+;; Two seams have to agree for a backend to be usable: the descriptor alist
+;; that resolves the script, and the completing-read list the interactive
+;; switcher offers. The switcher used to carry its own copy of that list, so
+;; a descriptor added without a switcher entry was reachable only by setq. It
+;; now derives its choices from the alist; the boundary test below guards
+;; against a return to a hardcoded list.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(unless (fboundp 'notifications-notify)
+ (defun notifications-notify (&rest _args)
+ "Stub notification function for testing."
+ nil))
+
+(require 'transcription-config)
+
+(defun test-transcription-ratio--switcher-choices ()
+ "Return the backend names `cj/transcription-switch-backend' offers.
+Captures the collection handed to `completing-read' and answers with the
+current backend so the switcher is a no-op."
+ (let (offered)
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_prompt collection &rest _)
+ (setq offered (mapcar #'car collection))
+ (symbol-name cj/transcribe-backend))))
+ (let ((cj/transcribe-backend cj/transcribe-backend))
+ (cj/transcription-switch-backend)))
+ offered))
+
+;;; Normal
+
+(ert-deftest test-transcription-config-ratio-descriptor-resolves ()
+ "Normal: the ratio backend resolves to the ratio-transcribe client with no
+API-key requirement."
+ (let ((desc (cj/--backend-plist 'ratio)))
+ (should (equal (plist-get desc :script) "ratio-transcribe"))
+ (should (null (plist-get desc :auth-host)))
+ (should (null (plist-get desc :env-var)))))
+
+(ert-deftest test-transcription-config-ratio-script-path-and-executable ()
+ "Normal: the script path lands on scripts/ratio-transcribe and the file is
+there and executable, so a transcription can actually start."
+ (let ((cj/transcribe-backend 'ratio))
+ (let ((path (cj/--transcription-script-path)))
+ (should (string-suffix-p "scripts/ratio-transcribe" path))
+ (should (file-executable-p path)))))
+
+(ert-deftest test-transcription-config-ratio-environment-passes-through ()
+ "Normal: no API key means the process environment is returned unchanged,
+and nothing consults authinfo."
+ (cl-letf (((symbol-function 'cj/--auth-source-password)
+ (lambda (&rest _) (ert-fail "auth-source consulted for a keyless backend"))))
+ (should (eq (cj/--build-process-environment 'ratio) process-environment))))
+
+(ert-deftest test-transcription-config-switcher-offers-ratio ()
+ "Normal: the interactive switcher lists ratio, so the backend is reachable
+without a setq."
+ (should (member "ratio" (test-transcription-ratio--switcher-choices))))
+
+;;; Boundary
+
+(ert-deftest test-transcription-config-switcher-matches-descriptors ()
+ "Boundary: the switcher offers exactly the descriptor set. It derives the
+list from the alist now; this is what stops a hardcoded copy coming back."
+ (should (equal (sort (test-transcription-ratio--switcher-choices) #'string<)
+ (sort (mapcar (lambda (entry) (symbol-name (car entry)))
+ cj/--transcription-backends)
+ #'string<))))
+
+(ert-deftest test-transcription-config-every-descriptor-script-exists ()
+ "Boundary: every descriptor names a script that exists under scripts/.
+A descriptor for a script that never landed (a hosted alternative referenced
+from another repo, say) would fail at transcription time instead."
+ (dolist (entry cj/--transcription-backends)
+ (let ((cj/transcribe-backend (car entry)))
+ (should (file-exists-p (cj/--transcription-script-path))))))
+
+;;; Error
+
+(ert-deftest test-transcription-config-unknown-backend-still-signals ()
+ "Error: adding ratio did not loosen the descriptor lookup; an unknown
+backend still signals `user-error'."
+ (should-error (cj/--backend-plist 'no-such-backend) :type 'user-error))
+
+(provide 'test-transcription-config--ratio-backend)
+;;; test-transcription-config--ratio-backend.el ends here
diff --git a/tests/test-undead-buffers--native-comp-log-undead.el b/tests/test-undead-buffers--native-comp-log-undead.el
new file mode 100644
index 00000000..dee9a134
--- /dev/null
+++ b/tests/test-undead-buffers--native-comp-log-undead.el
@@ -0,0 +1,92 @@
+;;; test-undead-buffers--native-comp-log-undead.el --- the native-comp log survives the sweep -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Async native compilation parks every worker process on one buffer,
+;; `comp-async-buffer-name' (*Async-native-compile-log*), and the worker's
+;; sentinel reads that buffer back before it starts the next job. Killing
+;; the buffer sends SIGHUP to every worker under it (they are :noquery, so
+;; nothing asks), each sentinel then dies in `with-current-buffer' on the
+;; dead buffer, and `comp--run-async-workers' is never called again: the
+;; queue is stranded for the life of the daemon and nothing is ever cached.
+;;
+;; `cj/dashboard-only' on `emacs-startup-hook' runs
+;; `cj/kill-all-other-buffers-and-windows', which is exactly such a sweep,
+;; and in a real daemon `dashboard-insert-startupify-lists' has already
+;; created *dashboard* on `after-init-hook', so the sweep branch is the one
+;; that runs. These tests pin the log buffer to the undead list so the
+;; sweep buries it and the workers live. The fixture puts a live :noquery
+;; process on the buffer, because that is the state the bug needs; a plain
+;; buffer would survive a kill-and-recreate just the same.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'undead-buffers)
+
+(defconst test-undead--comp-log "*Async-native-compile-log*")
+
+(defun test-undead--make-sleeper (buffer)
+ "Start a quiet, long-lived process attached to BUFFER and return it."
+ (make-process :name "test-undead-sleeper" :buffer buffer
+ :command '("sleep" "30") :noquery t))
+
+(defun test-undead--settle ()
+ "Let any signal the sweep sent land before liveness is observed."
+ (let ((deadline (+ (float-time) 0.3)))
+ (while (< (float-time) deadline)
+ (accept-process-output nil 0.05))))
+
+;;; Normal Cases
+
+(ert-deftest test-undead-buffers-native-comp-log-is-undead-by-default ()
+ "Normal: the module's default list makes the async-compile log bury-only."
+ (should (member test-undead--comp-log cj/undead-buffer-list))
+ (should (cj/--buffer-undead-p test-undead--comp-log)))
+
+(ert-deftest test-undead-buffers-native-comp-log-name-matches-comp-run ()
+ "Normal: the pinned name is the one comp-run actually uses.
+A rename upstream would silently reopen the bug, so pin it to the variable."
+ (skip-unless (require 'comp-run nil t))
+ (should (equal comp-async-buffer-name test-undead--comp-log)))
+
+(ert-deftest test-undead-buffers-native-comp-log-workers-survive-sweep ()
+ "Normal: a worker parked on the log buffer is still running after the sweep.
+The positive control is an ordinary process buffer, which the sweep kills
+out from under its process -- that is what happened to the workers without
+the undead entry. The control's process is not asserted dead: killing the
+buffer sends SIGHUP, and a launching shell that ignores SIGHUP (nohup) hands
+that disposition down, so its death is not deterministic across harnesses."
+ (skip-unless (executable-find "sleep"))
+ (delete-other-windows)
+ (let* ((main (current-buffer))
+ (existing (get-buffer test-undead--comp-log))
+ (log (or existing (get-buffer-create test-undead--comp-log)))
+ (victim (generate-new-buffer "*test-sweep-victim*"))
+ (worker (test-undead--make-sleeper log))
+ (control (test-undead--make-sleeper victim)))
+ (unwind-protect
+ (progn
+ (cj/kill-all-other-buffers-and-windows)
+ (test-undead--settle)
+ (should (buffer-live-p main))
+ (should (buffer-live-p log))
+ (should (process-live-p worker))
+ (should-not (buffer-live-p victim)))
+ (when (process-live-p worker) (delete-process worker))
+ (when (process-live-p control) (delete-process control))
+ (when (buffer-live-p victim) (kill-buffer victim))
+ ;; Only remove what this test created. `kill-buffer' the function is
+ ;; not the remapped command, so the undead list doesn't apply.
+ (when (and (not existing) (buffer-live-p log)) (kill-buffer log))
+ (delete-other-windows))))
+
+;;; Boundary Cases
+
+(ert-deftest test-undead-buffers-native-comp-log-match-is-exact ()
+ "Boundary: only the exact name is undead; a uniquified copy is not."
+ (should-not (cj/--buffer-undead-p "*Async-native-compile-log*<2>"))
+ (should-not (cj/--buffer-undead-p " *Async-native-compile-log*")))
+
+(provide 'test-undead-buffers--native-comp-log-undead)
+;;; test-undead-buffers--native-comp-log-undead.el ends here
diff --git a/tests/test-undead-buffers--warnings-undead.el b/tests/test-undead-buffers--warnings-undead.el
new file mode 100644
index 00000000..858dbf76
--- /dev/null
+++ b/tests/test-undead-buffers--warnings-undead.el
@@ -0,0 +1,63 @@
+;;; test-undead-buffers--warnings-undead.el --- *Warnings* survives the buffer sweep -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Emacs 31.1's warnings.el defers daemon-startup warnings into a one-shot
+;; `after-make-frame-functions' closure that holds the *Warnings* buffer
+;; object and displays it on the first client frame. Killing that buffer
+;; during startup leaves the closure holding a dead buffer, `display-buffer'
+;; then signals inside `make-frame', server.el reports the window system as
+;; unsupported, and emacsclient silently retries on $DISPLAY -- the first
+;; frame of the session lands on XWayland instead of Wayland.
+;;
+;; `cj/dashboard-only' on `emacs-startup-hook' runs
+;; `cj/kill-all-other-buffers-and-windows', which is exactly such a sweep.
+;; These tests pin *Warnings* to the undead list so the sweep buries it
+;; instead of killing it. Error-path coverage of the predicate itself (a nil
+;; or non-string name) lives in test-undead-buffers--buffer-undead-p.el.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'undead-buffers)
+
+;;; Normal Cases
+
+(ert-deftest test-undead-buffers-warnings-is-undead-by-default ()
+ "Normal: the module's default list makes *Warnings* bury-only."
+ (should (member "*Warnings*" cj/undead-buffer-list))
+ (should (cj/--buffer-undead-p "*Warnings*")))
+
+(ert-deftest test-undead-buffers-warnings-survives-kill-all-other-buffers ()
+ "Normal: the startup sweep buries *Warnings* rather than killing it.
+This is the sweep `cj/dashboard-only' runs from `emacs-startup-hook'."
+ (delete-other-windows)
+ (unwind-protect
+ (let* ((main (current-buffer))
+ (existing (get-buffer "*Warnings*"))
+ (warnings (or existing (get-buffer-create "*Warnings*")))
+ (victim (generate-new-buffer "*test-sweep-victim*")))
+ (unwind-protect
+ (progn
+ (cj/kill-all-other-buffers-and-windows)
+ (should (buffer-live-p main))
+ (should (buffer-live-p warnings))
+ (should-not (buffer-live-p victim)))
+ (when (buffer-live-p victim) (kill-buffer victim))
+ ;; Only remove what this test created. `kill-buffer' the function
+ ;; is not the remapped command, so the undead list doesn't apply.
+ (when (and (not existing) (buffer-live-p warnings))
+ (kill-buffer warnings))))
+ (delete-other-windows)))
+
+;;; Boundary Cases
+
+(ert-deftest test-undead-buffers-warnings-match-is-exact ()
+ "Boundary: only the exact name is undead; a uniquified *Warnings*<2> is not.
+The list matches exact names, so a second warnings buffer made by
+`generate-new-buffer' is an ordinary buffer to the sweep."
+ (should-not (cj/--buffer-undead-p "*Warnings*<2>"))
+ (should-not (cj/--buffer-undead-p " *Warnings*")))
+
+(provide 'test-undead-buffers--warnings-undead)
+;;; test-undead-buffers--warnings-undead.el ends here
diff --git a/tests/test-video-audio-recording--keybindings.el b/tests/test-video-audio-recording--keybindings.el
new file mode 100644
index 00000000..9ddce661
--- /dev/null
+++ b/tests/test-video-audio-recording--keybindings.el
@@ -0,0 +1,103 @@
+;;; test-video-audio-recording--keybindings.el --- recording toggle keybinding placement -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; The two recording toggles live only under the C-; r prefix. They used to
+;; have a fast chord too: F9 for video, S-F9 for audio, claimed globally and in
+;; every EAT map so char mode's :function category could not swallow them.
+;;
+;; That chord went away on 2026-09-22. With Fn Lock on the Framework 13, the
+;; F-key row sends plain function keys, so a bare F9 press started a screen
+;; recording. The pair is removed as a unit: a lone S-F9 audio chord on a live
+;; F-key row is one fat-finger from an unwanted recording, and the module
+;; treated the two as one feature.
+;;
+;; These tests pin the removal. The char-mode case resolves through
+;; `key-binding' in a fixture that reproduces minor-mode precedence, because
+;; reading a map the module no longer writes proves nothing about which map wins
+;; on a keypress. They require eat first so any `with-eval-after-load' the
+;; module still carries would have fired.
+
+;;; Code:
+
+(require 'ert)
+(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))
+(require 'eat)
+(require 'video-audio-recording)
+
+(defconst test-video-audio-recording--toggles
+ '(cj/video-recording-toggle cj/audio-recording-toggle)
+ "The two commands the retired F9 chords used to reach.")
+
+(defun test-video-audio-recording--bound-to-toggle-p (binding)
+ "Return non-nil when BINDING is one of the recording toggles."
+ (memq binding test-video-audio-recording--toggles))
+
+;;; Normal
+
+(ert-deftest test-video-audio-recording-f9-not-bound-globally ()
+ "Normal/regression: neither F9 nor S-F9 reaches a recording toggle globally."
+ (should-not (test-video-audio-recording--bound-to-toggle-p
+ (lookup-key (current-global-map) (kbd "<f9>"))))
+ (should-not (test-video-audio-recording--bound-to-toggle-p
+ (lookup-key (current-global-map) (kbd "S-<f9>")))))
+
+(ert-deftest test-video-audio-recording-f9-not-bound-in-eat-maps ()
+ "Normal/regression: none of the four EAT maps carries a recording toggle on
+F9 or S-F9. The module used to write all four so the chord survived char
+mode; the removal has to reach every one of them."
+ (dolist (map (list eat-semi-char-mode-map eat-mode-map
+ eat-char-mode-map eat-eshell-char-mode-map))
+ (should-not (test-video-audio-recording--bound-to-toggle-p
+ (keymap-lookup map "<f9>")))
+ (should-not (test-video-audio-recording--bound-to-toggle-p
+ (keymap-lookup map "S-<f9>")))))
+
+;;; Boundary
+
+(defun test-video-audio-recording--in-char-mode (body)
+ "Run BODY in a buffer wired the way a live EAT char-mode buffer is.
+`eat--char-mode' is a minor mode, so its map is consulted ahead of the
+major-mode map. Reproducing that ordering is the point: the assertion is
+about which map wins when a key is actually pressed."
+ (with-temp-buffer
+ (use-local-map eat-mode-map)
+ (let ((minor-mode-overriding-map-alist
+ (list (cons 'eat--char-mode eat-char-mode-map)))
+ (eat--char-mode t))
+ (funcall body))))
+
+(ert-deftest test-video-audio-recording-f9-reaches-program-in-char-mode ()
+ "Boundary: in a char-mode buffer F9 goes to the program under the cursor
+again. EAT's :function category binds f1 through f63 to `eat-self-input';
+the module's override used to sit in front of it, and now nothing does."
+ (test-video-audio-recording--in-char-mode
+ (lambda ()
+ (should (eq (key-binding (kbd "<f9>")) #'eat-self-input)))))
+
+;;; Error
+
+(ert-deftest test-video-audio-recording-char-mode-fixture-really-is-char-mode ()
+ "Error (positive control): the char-mode fixture genuinely puts EAT's map in
+front. F8 sits in the same :function category as F9 and this module never
+touched it, so it must reach `eat-self-input'. If it resolves anywhere else
+the fixture is inert and the F9 assertion above passes for the wrong reason."
+ (test-video-audio-recording--in-char-mode
+ (lambda ()
+ (should (eq (key-binding (kbd "<f8>")) #'eat-self-input)))))
+
+(ert-deftest test-video-audio-recording-prefix-bindings-still-reachable ()
+ "Error/regression (positive control): removing the fast chords must leave
+the C-; r prefix path intact. Without this, deleting the toggles outright
+would leave every not-bound assertion above green."
+ (should (eq (keymap-lookup cj/record-map "v") #'cj/video-recording-toggle))
+ (should (eq (keymap-lookup cj/record-map "a") #'cj/audio-recording-toggle))
+ (should (eq (keymap-lookup cj/custom-keymap "r") cj/record-map))
+ (should (commandp #'cj/video-recording-toggle))
+ (should (commandp #'cj/audio-recording-toggle)))
+
+(provide 'test-video-audio-recording--keybindings)
+;;; test-video-audio-recording--keybindings.el ends here