summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/video-audio-recording.el81
-rw-r--r--tests/fixtures/pactl-sink-inputs-active.txt18
-rw-r--r--tests/fixtures/pactl-sink-inputs-different-sink.txt18
-rw-r--r--tests/fixtures/pactl-sink-inputs-empty.txt1
-rw-r--r--tests/test-video-audio-recording--sink-has-active-audio-p.el91
-rw-r--r--tests/test-video-audio-recording--source-exists-p.el67
-rw-r--r--tests/test-video-audio-recording-command-structure.el72
-rw-r--r--tests/test-video-audio-recording-ffmpeg-functions.el32
-rw-r--r--tests/test-video-audio-recording-get-devices.el26
-rw-r--r--tests/test-video-audio-recording-process-cleanup.el43
-rw-r--r--tests/test-video-audio-recording-validate-system-audio.el162
11 files changed, 560 insertions, 51 deletions
diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el
index 5e812881..6ab617ec 100644
--- a/modules/video-audio-recording.el
+++ b/modules/video-audio-recording.el
@@ -574,8 +574,89 @@ If devices aren't set, goes straight into quick setup (mic selection)."
(cj/recording-quick-setup))
(unless (and cj/recording-mic-device cj/recording-system-device)
(user-error "Audio devices not configured. Run C-; r s (quick setup) or C-; r S (manual select)"))
+ (cj/recording--validate-system-audio)
(cons cj/recording-mic-device cj/recording-system-device))
+(defun cj/recording--source-exists-p (source-name pactl-output)
+ "Return non-nil if SOURCE-NAME exists in PACTL-OUTPUT.
+PACTL-OUTPUT should be the output of `pactl list sources short'."
+ (let ((found nil))
+ (dolist (line (split-string pactl-output "\n" t))
+ (when (string-match "^[0-9]+\t\\([^\t]+\\)\t" line)
+ (when (equal source-name (match-string 1 line))
+ (setq found t))))
+ found))
+
+(defun cj/recording--get-sink-index (sink-name sinks-output)
+ "Return the numeric index of SINK-NAME from SINKS-OUTPUT.
+SINKS-OUTPUT should be the output of `pactl list sinks short'.
+Returns the index as a string, or nil if not found."
+ (let ((index nil))
+ (dolist (line (split-string sinks-output "\n" t))
+ (when (string-match "^\\([0-9]+\\)\t\\([^\t]+\\)\t" line)
+ (when (equal sink-name (match-string 2 line))
+ (setq index (match-string 1 line)))))
+ index))
+
+(defun cj/recording--sink-has-active-audio-p (sink-index pactl-output)
+ "Return non-nil if SINK-INDEX has active audio streams.
+PACTL-OUTPUT should be the output of `pactl list sink-inputs'.
+SINK-INDEX is the numeric sink index as a string."
+ (let ((found nil)
+ (lines (split-string pactl-output "\n")))
+ (dolist (line lines)
+ (when (string-match "^[ \t]+Sink:[ \t]+\\([0-9]+\\)" line)
+ (when (equal sink-index (match-string 1 line))
+ (setq found t))))
+ found))
+
+(defun cj/recording--validate-system-audio ()
+ "Validate that the configured system audio device will capture audio.
+Checks three things:
+1. Does the configured device still exist as a PulseAudio source?
+2. Has the default sink drifted from what we're monitoring?
+3. Is anything currently playing through the monitored sink?
+
+Auto-fixes stale/drifted devices. Warns (but doesn't block) if no audio
+is currently playing."
+ (when cj/recording-system-device
+ (let* ((sources-output (shell-command-to-string "pactl list sources short 2>/dev/null"))
+ (current-default (cj/recording--get-default-sink-monitor))
+ (device-exists (cj/recording--source-exists-p
+ cj/recording-system-device sources-output)))
+ ;; Check 1: Device no longer exists — auto-update
+ (unless device-exists
+ (let ((old cj/recording-system-device))
+ (setq cj/recording-system-device current-default)
+ (message "System audio device updated: %s → %s (old device no longer exists)"
+ old current-default)))
+ ;; Check 2: Default sink has drifted — auto-update
+ (when (and device-exists
+ (not (equal cj/recording-system-device current-default)))
+ (let ((old cj/recording-system-device))
+ (setq cj/recording-system-device current-default)
+ (message "System audio device updated: %s → %s (default output changed)"
+ old current-default)))
+ ;; Check 3: No active audio on the monitored sink — warn
+ (let* ((sink-name (if (string-suffix-p ".monitor" cj/recording-system-device)
+ (substring cj/recording-system-device 0 -8)
+ cj/recording-system-device))
+ (sinks-output (shell-command-to-string "pactl list sinks short 2>/dev/null"))
+ (sink-index (cj/recording--get-sink-index sink-name sinks-output))
+ (sink-inputs (shell-command-to-string "pactl list sink-inputs 2>/dev/null"))
+ (has-audio (and sink-index
+ (cj/recording--sink-has-active-audio-p sink-index sink-inputs))))
+ (unless has-audio
+ (unless (y-or-n-p
+ (format (concat "Warning: No audio is playing through %s.\n"
+ "If you're in a meeting, the other participants may not be recorded.\n"
+ "- Check that your call app is using the expected audio output\n"
+ "- Run C-; r w to see which device your call is using\n"
+ "- Run C-; r s to switch devices\n"
+ "Continue anyway? ")
+ sink-name))
+ (user-error "Recording cancelled")))))))
+
;;; ============================================================
;;; Toggle Commands (User-Facing)
;;; ============================================================
diff --git a/tests/fixtures/pactl-sink-inputs-active.txt b/tests/fixtures/pactl-sink-inputs-active.txt
new file mode 100644
index 00000000..d06589e3
--- /dev/null
+++ b/tests/fixtures/pactl-sink-inputs-active.txt
@@ -0,0 +1,18 @@
+Sink Input #42
+ Driver: PipeWire
+ Owner Module: 4294967295
+ Client: 51
+ Sink: 65
+ Sample Specification: float32le 2ch 48000Hz
+ Channel Map: front-left,front-right
+ Format: pcm, format.sample_format = "\"float32le\"" format.rate = "48000" format.channels = "2"
+ Corked: no
+ Mute: no
+ Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
+ balance 0.00
+ Buffer Latency: 0 usec
+ Sink Latency: 0 usec
+ Resample method: PipeWire
+ Properties:
+ media.name = "Playback"
+ application.name = "Firefox"
diff --git a/tests/fixtures/pactl-sink-inputs-different-sink.txt b/tests/fixtures/pactl-sink-inputs-different-sink.txt
new file mode 100644
index 00000000..859010ed
--- /dev/null
+++ b/tests/fixtures/pactl-sink-inputs-different-sink.txt
@@ -0,0 +1,18 @@
+Sink Input #42
+ Driver: PipeWire
+ Owner Module: 4294967295
+ Client: 51
+ Sink: 73
+ Sample Specification: float32le 2ch 48000Hz
+ Channel Map: front-left,front-right
+ Format: pcm, format.sample_format = "\"float32le\"" format.rate = "48000" format.channels = "2"
+ Corked: no
+ Mute: no
+ Volume: front-left: 65536 / 100% / 0.00 dB, front-right: 65536 / 100% / 0.00 dB
+ balance 0.00
+ Buffer Latency: 0 usec
+ Sink Latency: 0 usec
+ Resample method: PipeWire
+ Properties:
+ media.name = "Playback"
+ application.name = "Firefox"
diff --git a/tests/fixtures/pactl-sink-inputs-empty.txt b/tests/fixtures/pactl-sink-inputs-empty.txt
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/tests/fixtures/pactl-sink-inputs-empty.txt
@@ -0,0 +1 @@
+
diff --git a/tests/test-video-audio-recording--sink-has-active-audio-p.el b/tests/test-video-audio-recording--sink-has-active-audio-p.el
new file mode 100644
index 00000000..eab0745a
--- /dev/null
+++ b/tests/test-video-audio-recording--sink-has-active-audio-p.el
@@ -0,0 +1,91 @@
+;;; test-video-audio-recording--sink-has-active-audio-p.el --- Tests for cj/recording--sink-has-active-audio-p -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording--sink-has-active-audio-p function.
+;; Tests parsing of pactl sink-inputs output to detect active audio streams.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'video-audio-recording)
+
+;;; Test Fixtures Helper
+
+(defun test-load-fixture (filename)
+ "Load fixture file FILENAME from tests/fixtures directory."
+ (let ((fixture-path (expand-file-name
+ (concat "tests/fixtures/" filename)
+ user-emacs-directory)))
+ (with-temp-buffer
+ (insert-file-contents fixture-path)
+ (buffer-string))))
+
+;;; Normal Cases
+
+(ert-deftest test-sink-has-active-audio-p-normal-active-sink-returns-t ()
+ "Test that active audio on our sink returns non-nil."
+ (let ((output (test-load-fixture "pactl-sink-inputs-active.txt")))
+ (should (cj/recording--sink-has-active-audio-p "65" output))))
+
+(ert-deftest test-sink-has-active-audio-p-normal-different-sink-returns-nil ()
+ "Test that audio on a different sink returns nil."
+ (let ((output (test-load-fixture "pactl-sink-inputs-different-sink.txt")))
+ (should-not (cj/recording--sink-has-active-audio-p "65" output))))
+
+;;; Boundary Cases
+
+(ert-deftest test-sink-has-active-audio-p-boundary-empty-output-returns-nil ()
+ "Test that empty pactl output returns nil."
+ (should-not (cj/recording--sink-has-active-audio-p "65" "")))
+
+(ert-deftest test-sink-has-active-audio-p-boundary-no-sink-inputs-returns-nil ()
+ "Test that output with no sink inputs returns nil."
+ (let ((output (test-load-fixture "pactl-sink-inputs-empty.txt")))
+ (should-not (cj/recording--sink-has-active-audio-p "65" output))))
+
+(ert-deftest test-sink-has-active-audio-p-boundary-multiple-inputs-one-matches ()
+ "Test that multiple sink inputs where one matches returns non-nil."
+ (let ((output (concat "Sink Input #42\n"
+ "\tSink: 73\n"
+ "\tCorked: no\n"
+ "Sink Input #43\n"
+ "\tSink: 65\n"
+ "\tCorked: no\n")))
+ (should (cj/recording--sink-has-active-audio-p "65" output))))
+
+(ert-deftest test-sink-has-active-audio-p-boundary-index-substring-no-false-match ()
+ "Test that sink index 6 does not match sink 65."
+ (let ((output (test-load-fixture "pactl-sink-inputs-active.txt")))
+ (should-not (cj/recording--sink-has-active-audio-p "6" output))))
+
+;;; get-sink-index tests
+
+(ert-deftest test-get-sink-index-normal-returns-index ()
+ "Test that sink name is resolved to its index."
+ (let ((output "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (should (equal "65" (cj/recording--get-sink-index
+ "alsa_output.usb-Jabra-00.analog-stereo" output)))))
+
+(ert-deftest test-get-sink-index-normal-nonexistent-returns-nil ()
+ "Test that non-existent sink name returns nil."
+ (let ((output "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (should-not (cj/recording--get-sink-index "nonexistent-sink" output))))
+
+(ert-deftest test-get-sink-index-boundary-empty-output-returns-nil ()
+ "Test that empty output returns nil."
+ (should-not (cj/recording--get-sink-index "any-sink" "")))
+
+(ert-deftest test-get-sink-index-normal-multiple-sinks ()
+ "Test correct index returned when multiple sinks present."
+ (let ((output (concat "65\talsa_output.pci-0000.hdmi-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "69\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")))
+ (should (equal "69" (cj/recording--get-sink-index
+ "alsa_output.usb-Jabra-00.analog-stereo" output)))))
+
+(provide 'test-video-audio-recording--sink-has-active-audio-p)
+;;; test-video-audio-recording--sink-has-active-audio-p.el ends here
diff --git a/tests/test-video-audio-recording--source-exists-p.el b/tests/test-video-audio-recording--source-exists-p.el
new file mode 100644
index 00000000..f062ac0f
--- /dev/null
+++ b/tests/test-video-audio-recording--source-exists-p.el
@@ -0,0 +1,67 @@
+;;; test-video-audio-recording--source-exists-p.el --- Tests for cj/recording--source-exists-p -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording--source-exists-p function.
+;; Tests checking whether a PulseAudio source exists in pactl output.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'video-audio-recording)
+
+;;; Test Fixtures Helper
+
+(defun test-load-fixture (filename)
+ "Load fixture file FILENAME from tests/fixtures directory."
+ (let ((fixture-path (expand-file-name
+ (concat "tests/fixtures/" filename)
+ user-emacs-directory)))
+ (with-temp-buffer
+ (insert-file-contents fixture-path)
+ (buffer-string))))
+
+;;; Normal Cases
+
+(ert-deftest test-source-exists-p-normal-existing-device-returns-t ()
+ "Test that an existing device returns non-nil."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (should (cj/recording--source-exists-p
+ "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" output))))
+
+(ert-deftest test-source-exists-p-normal-input-device-returns-t ()
+ "Test that an existing input device returns non-nil."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (should (cj/recording--source-exists-p
+ "alsa_input.pci-0000_00_1f.3.analog-stereo" output))))
+
+(ert-deftest test-source-exists-p-normal-bluetooth-device-returns-t ()
+ "Test that a Bluetooth device returns non-nil."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (should (cj/recording--source-exists-p
+ "bluez_input.00:1B:66:C0:91:6D" output))))
+
+;;; Boundary Cases
+
+(ert-deftest test-source-exists-p-boundary-nonexistent-device-returns-nil ()
+ "Test that a non-existent device returns nil."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (should-not (cj/recording--source-exists-p
+ "nonexistent_device.monitor" output))))
+
+(ert-deftest test-source-exists-p-boundary-empty-output-returns-nil ()
+ "Test that empty pactl output returns nil."
+ (should-not (cj/recording--source-exists-p "any-device" "")))
+
+(ert-deftest test-source-exists-p-boundary-partial-name-no-match ()
+ "Test that partial device name does not match."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (should-not (cj/recording--source-exists-p
+ "alsa_output.pci-0000_00_1f.3.analog-stereo" output))))
+
+(provide 'test-video-audio-recording--source-exists-p)
+;;; test-video-audio-recording--source-exists-p.el ends here
diff --git a/tests/test-video-audio-recording-command-structure.el b/tests/test-video-audio-recording-command-structure.el
index c964b246..f4c24c39 100644
--- a/tests/test-video-audio-recording-command-structure.el
+++ b/tests/test-video-audio-recording-command-structure.el
@@ -52,7 +52,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "wf-recorder" command))))
(test-command-structure-teardown)))
@@ -66,7 +68,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "wf-recorder -y" command))))
(test-command-structure-teardown)))
@@ -80,7 +84,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
;; Should use libx264, not h264
(should (string-match-p "-c libx264" command))
@@ -96,7 +102,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "-m matroska" command))))
(test-command-structure-teardown)))
@@ -110,7 +118,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
;; Should use -f /dev/stdout, not -o -
(should (string-match-p "-f /dev/stdout" command))
@@ -126,7 +136,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
;; These flags don't exist in wf-recorder
(should-not (string-match-p "--no-audio" command))
@@ -142,7 +154,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
;; Should pipe wf-recorder to ffmpeg
(should (string-match-p "wf-recorder.*|.*ffmpeg" command))))
@@ -157,7 +171,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "ffmpeg -i pipe:0" command))))
(test-command-structure-teardown)))
@@ -173,7 +189,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "x11grab" command))))
(test-command-structure-teardown)))
@@ -187,7 +205,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should-not (string-match-p "wf-recorder" command))))
(test-command-structure-teardown)))
@@ -201,7 +221,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "-i :0" command))))
(test-command-structure-teardown)))
@@ -216,7 +238,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should (string-match-p "^ffmpeg " command))))
(test-command-structure-teardown)))
@@ -229,7 +253,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should (string-match-p "-f pulse" command))))
(test-command-structure-teardown)))
@@ -242,7 +268,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should (string-match-p "\\.m4a" command))))
(test-command-structure-teardown)))
@@ -258,7 +286,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "-filter_complex" command))
(should (string-match-p "amerge" command))))
@@ -273,7 +303,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "-map 0:v" command))
(should (string-match-p "-map.*\\[out\\]" command))))
@@ -288,7 +320,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "-c:v copy" command))))
(test-command-structure-teardown)))
@@ -302,7 +336,9 @@
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "\\.mkv" command))))
(test-command-structure-teardown)))
diff --git a/tests/test-video-audio-recording-ffmpeg-functions.el b/tests/test-video-audio-recording-ffmpeg-functions.el
index a3bac0cf..549aa317 100644
--- a/tests/test-video-audio-recording-ffmpeg-functions.el
+++ b/tests/test-video-audio-recording-ffmpeg-functions.el
@@ -52,7 +52,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer _command)
(setq process-created t)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should process-created)
(should cj/video-recording-ffmpeg-process)))
@@ -69,7 +71,9 @@
((symbol-function 'set-process-sentinel)
(lambda (_proc sentinel)
(should (eq sentinel #'cj/recording-process-sentinel))
- (setq sentinel-attached t))))
+ (setq sentinel-attached t)))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should sentinel-attached)))
(test-ffmpeg-teardown)))
@@ -83,7 +87,9 @@
(lambda (_name _buffer _command)
(make-process :name "fake-video" :command '("sleep" "1000"))))
((symbol-function 'force-mode-line-update)
- (lambda (&optional _all) (setq update-called t))))
+ (lambda (&optional _all) (setq update-called t)))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should update-called)))
(test-ffmpeg-teardown)))
@@ -96,7 +102,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should (string-match-p "test-mic-device" command))
(should (string-match-p "test-monitor-device" command))
@@ -114,7 +122,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer _command)
(setq process-created t)
- (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should process-created)
(should cj/audio-recording-ffmpeg-process)))
@@ -131,7 +141,9 @@
((symbol-function 'set-process-sentinel)
(lambda (_proc sentinel)
(should (eq sentinel #'cj/recording-process-sentinel))
- (setq sentinel-attached t))))
+ (setq sentinel-attached t)))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should sentinel-attached)))
(test-ffmpeg-teardown)))
@@ -145,7 +157,9 @@
(lambda (_name _buffer _command)
(make-process :name "fake-audio" :command '("sleep" "1000"))))
((symbol-function 'force-mode-line-update)
- (lambda (&optional _all) (setq update-called t))))
+ (lambda (&optional _all) (setq update-called t)))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should update-called)))
(test-ffmpeg-teardown)))
@@ -158,7 +172,9 @@
(cl-letf (((symbol-function 'start-process-shell-command)
(lambda (_name _buffer cmd)
(setq command cmd)
- (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-audio audio-recordings-dir)
(should (string-match-p "\\.m4a" command))))
(test-ffmpeg-teardown)))
diff --git a/tests/test-video-audio-recording-get-devices.el b/tests/test-video-audio-recording-get-devices.el
index 0af02bb3..66adecd1 100644
--- a/tests/test-video-audio-recording-get-devices.el
+++ b/tests/test-video-audio-recording-get-devices.el
@@ -39,10 +39,12 @@
(progn
(setq cj/recording-mic-device "preset-mic")
(setq cj/recording-system-device "preset-monitor")
- (let ((result (cj/recording-get-devices)))
- (should (consp result))
- (should (equal "preset-mic" (car result)))
- (should (equal "preset-monitor" (cdr result)))))
+ (cl-letf (((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
+ (let ((result (cj/recording-get-devices)))
+ (should (consp result))
+ (should (equal "preset-mic" (car result)))
+ (should (equal "preset-monitor" (cdr result))))))
(test-get-devices-teardown)))
(ert-deftest test-video-audio-recording-get-devices-normal-calls-quick-setup ()
@@ -54,7 +56,9 @@
(lambda ()
(setq quick-setup-called t)
(setq cj/recording-mic-device "quick-mic")
- (setq cj/recording-system-device "quick-monitor"))))
+ (setq cj/recording-system-device "quick-monitor")))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/recording-get-devices)
(should quick-setup-called)))
(test-get-devices-teardown)))
@@ -66,7 +70,9 @@
(cl-letf (((symbol-function 'cj/recording-quick-setup)
(lambda ()
(setq cj/recording-mic-device "test-mic")
- (setq cj/recording-system-device "test-monitor"))))
+ (setq cj/recording-system-device "test-monitor")))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(let ((result (cj/recording-get-devices)))
(should (consp result))
(should (equal "test-mic" (car result)))
@@ -87,7 +93,9 @@
(lambda ()
(setq quick-setup-called t)
(setq cj/recording-mic-device "new-mic")
- (setq cj/recording-system-device "new-monitor"))))
+ (setq cj/recording-system-device "new-monitor")))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/recording-get-devices)
(should quick-setup-called))))
(test-get-devices-teardown)))
@@ -104,7 +112,9 @@
(lambda ()
(setq quick-setup-called t)
(setq cj/recording-mic-device "new-mic")
- (setq cj/recording-system-device "new-monitor"))))
+ (setq cj/recording-system-device "new-monitor")))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/recording-get-devices)
(should quick-setup-called))))
(test-get-devices-teardown)))
diff --git a/tests/test-video-audio-recording-process-cleanup.el b/tests/test-video-audio-recording-process-cleanup.el
index d1cd442c..42b5b96d 100644
--- a/tests/test-video-audio-recording-process-cleanup.el
+++ b/tests/test-video-audio-recording-process-cleanup.el
@@ -166,7 +166,9 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
0))
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer _command)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should pkill-called)
(should (member "-INT" pkill-args))
@@ -186,7 +188,9 @@ so ffmpeg sees EOF on its video input pipe and starts finalizing the file."
0))
((symbol-function 'start-process-shell-command)
(lambda (_name _buffer _command)
- (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
(cj/ffmpeg-record-video video-recordings-dir)
(should-not pkill-called)))
(test-cleanup-teardown)))
@@ -299,13 +303,15 @@ This is an integration test that requires wf-recorder and Wayland."
(skip-unless (cj/recording--wayland-p))
(test-cleanup-setup)
(unwind-protect
- (let ((initial-count (test-cleanup--count-wf-recorder-processes)))
- (cj/ffmpeg-record-video video-recordings-dir)
- (sit-for 1.0)
- (should (> (test-cleanup--count-wf-recorder-processes) initial-count))
- (cj/video-recording-stop)
- (sit-for 1.0)
- (should (= (test-cleanup--count-wf-recorder-processes) initial-count)))
+ (cl-letf (((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
+ (let ((initial-count (test-cleanup--count-wf-recorder-processes)))
+ (cj/ffmpeg-record-video video-recordings-dir)
+ (sit-for 1.0)
+ (should (> (test-cleanup--count-wf-recorder-processes) initial-count))
+ (cj/video-recording-stop)
+ (sit-for 1.0)
+ (should (= (test-cleanup--count-wf-recorder-processes) initial-count))))
(test-cleanup-teardown)
(ignore-errors (call-process "pkill" nil nil nil "-INT" "wf-recorder"))))
@@ -317,7 +323,8 @@ This is an integration test that requires wf-recorder and Wayland."
(skip-unless (cj/recording--wayland-p))
(test-cleanup-setup)
(unwind-protect
- (progn
+ (cl-letf (((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
;; Create an orphan wf-recorder (simulating a crash)
(start-process "orphan-wf" nil "wf-recorder" "-c" "libx264" "-m" "matroska" "-f" "/dev/null")
(sit-for 0.5)
@@ -342,13 +349,15 @@ This is an integration test that requires wf-recorder and Wayland."
(skip-unless (cj/recording--wayland-p))
(test-cleanup-setup)
(unwind-protect
- (let ((initial-count (test-cleanup--count-wf-recorder-processes)))
- (dotimes (_ 3)
- (cj/ffmpeg-record-video video-recordings-dir)
- (sit-for 0.5)
- (cj/video-recording-stop)
- (sit-for 0.5))
- (should (= (test-cleanup--count-wf-recorder-processes) initial-count)))
+ (cl-letf (((symbol-function 'cj/recording--validate-system-audio)
+ (lambda () nil)))
+ (let ((initial-count (test-cleanup--count-wf-recorder-processes)))
+ (dotimes (_ 3)
+ (cj/ffmpeg-record-video video-recordings-dir)
+ (sit-for 0.5)
+ (cj/video-recording-stop)
+ (sit-for 0.5))
+ (should (= (test-cleanup--count-wf-recorder-processes) initial-count))))
(test-cleanup-teardown)
(ignore-errors (call-process "pkill" nil nil nil "-INT" "wf-recorder"))))
diff --git a/tests/test-video-audio-recording-validate-system-audio.el b/tests/test-video-audio-recording-validate-system-audio.el
new file mode 100644
index 00000000..ef730ab3
--- /dev/null
+++ b/tests/test-video-audio-recording-validate-system-audio.el
@@ -0,0 +1,162 @@
+;;; test-video-audio-recording-validate-system-audio.el --- Tests for cj/recording--validate-system-audio -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording--validate-system-audio function.
+;; Tests the pre-recording validation that catches stale/drifted system
+;; audio devices before they cause silent recordings.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+(require 'video-audio-recording)
+
+;;; Setup and Teardown
+
+(defun test-validate-setup ()
+ "Reset device variables before each test."
+ (setq cj/recording-system-device nil))
+
+(defun test-validate-teardown ()
+ "Clean up device variables after each test."
+ (setq cj/recording-system-device nil))
+
+;;; Normal Cases
+
+(ert-deftest test-validate-system-audio-normal-device-matches-default-no-change ()
+ "Test that no change occurs when device matches current default and audio is active."
+ (test-validate-setup)
+ (unwind-protect
+ (let ((cj/recording-system-device "alsa_output.usb-Jabra-00.analog-stereo.monitor"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (cmd)
+ (cond
+ ((string-match-p "sources short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sinks short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sink-inputs" cmd) "Sink Input #1\n\tSink: 65\n")
+ ((string-match-p "get-default-sink" cmd) "alsa_output.usb-Jabra-00.analog-stereo")
+ (t "")))))
+ (cj/recording--validate-system-audio)
+ (should (equal "alsa_output.usb-Jabra-00.analog-stereo.monitor"
+ cj/recording-system-device))))
+ (test-validate-teardown)))
+
+(ert-deftest test-validate-system-audio-normal-stale-device-auto-updates ()
+ "Test that a stale (non-existent) device is auto-updated to current default."
+ (test-validate-setup)
+ (unwind-protect
+ (let ((cj/recording-system-device "old_disappeared_device.monitor")
+ (messages nil))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (cmd)
+ (cond
+ ((string-match-p "sources short" cmd)
+ ;; Old device NOT in list
+ "65\talsa_output.usb-Jabra-00.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sinks short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sink-inputs" cmd) "Sink Input #1\n\tSink: 65\n")
+ ((string-match-p "get-default-sink" cmd) "alsa_output.usb-Jabra-00.analog-stereo")
+ (t ""))))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (push (apply #'format fmt args) messages))))
+ (cj/recording--validate-system-audio)
+ (should (equal "alsa_output.usb-Jabra-00.analog-stereo.monitor"
+ cj/recording-system-device))
+ (should (cl-some (lambda (m) (string-match-p "no longer exists" m)) messages))))
+ (test-validate-teardown)))
+
+(ert-deftest test-validate-system-audio-normal-drifted-default-auto-updates ()
+ "Test that device is updated when default sink has drifted."
+ (test-validate-setup)
+ (unwind-protect
+ (let ((cj/recording-system-device "alsa_output.pci-0000.hdmi-stereo.monitor")
+ (messages nil))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (cmd)
+ (cond
+ ((string-match-p "sources short" cmd)
+ ;; Old device still exists
+ (concat "65\talsa_output.pci-0000.hdmi-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "69\talsa_output.usb-Jabra-00.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ ((string-match-p "sinks short" cmd)
+ "69\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sink-inputs" cmd) "Sink Input #1\n\tSink: 69\n")
+ ;; But default has changed to Jabra
+ ((string-match-p "get-default-sink" cmd) "alsa_output.usb-Jabra-00.analog-stereo")
+ (t ""))))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (push (apply #'format fmt args) messages))))
+ (cj/recording--validate-system-audio)
+ (should (equal "alsa_output.usb-Jabra-00.analog-stereo.monitor"
+ cj/recording-system-device))
+ (should (cl-some (lambda (m) (string-match-p "default output changed" m)) messages))))
+ (test-validate-teardown)))
+
+(ert-deftest test-validate-system-audio-normal-no-audio-warns ()
+ "Test that no active audio triggers a y-or-n-p warning."
+ (test-validate-setup)
+ (unwind-protect
+ (let ((cj/recording-system-device "alsa_output.usb-Jabra-00.analog-stereo.monitor")
+ (prompted nil))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (cmd)
+ (cond
+ ((string-match-p "sources short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sinks short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ;; No sink inputs — nothing playing
+ ((string-match-p "sink-inputs" cmd) "")
+ ((string-match-p "get-default-sink" cmd) "alsa_output.usb-Jabra-00.analog-stereo")
+ (t ""))))
+ ((symbol-function 'y-or-n-p)
+ (lambda (prompt)
+ (setq prompted prompt)
+ t))) ; User says yes, continue
+ (cj/recording--validate-system-audio)
+ (should prompted)
+ (should (string-match-p "No audio is playing" prompted))))
+ (test-validate-teardown)))
+
+(ert-deftest test-validate-system-audio-normal-no-audio-user-cancels ()
+ "Test that user declining the warning cancels recording."
+ (test-validate-setup)
+ (unwind-protect
+ (let ((cj/recording-system-device "alsa_output.usb-Jabra-00.analog-stereo.monitor"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (cmd)
+ (cond
+ ((string-match-p "sources short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sinks short" cmd)
+ "65\talsa_output.usb-Jabra-00.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")
+ ((string-match-p "sink-inputs" cmd) "")
+ ((string-match-p "get-default-sink" cmd) "alsa_output.usb-Jabra-00.analog-stereo")
+ (t ""))))
+ ((symbol-function 'y-or-n-p)
+ (lambda (_prompt) nil))) ; User says no
+ (should-error (cj/recording--validate-system-audio) :type 'user-error)))
+ (test-validate-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-validate-system-audio-boundary-nil-device-skips-validation ()
+ "Test that nil system device skips all validation."
+ (let ((cj/recording-system-device nil)
+ (shell-called nil))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) (setq shell-called t) "")))
+ (cj/recording--validate-system-audio)
+ (should-not shell-called))))
+
+(provide 'test-video-audio-recording-validate-system-audio)
+;;; test-video-audio-recording-validate-system-audio.el ends here