summaryrefslogtreecommitdiff
path: root/modules/test-runner.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
committerCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
commit092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch)
treeea81999b8442246c978b364dd90e8c752af50db5 /modules/test-runner.el
changing repositories
Diffstat (limited to 'modules/test-runner.el')
-rw-r--r--modules/test-runner.el270
1 files changed, 270 insertions, 0 deletions
diff --git a/modules/test-runner.el b/modules/test-runner.el
new file mode 100644
index 00000000..73c4063c
--- /dev/null
+++ b/modules/test-runner.el
@@ -0,0 +1,270 @@
+;;; test-runner.el --- Test Runner for Emacs Configuration -*- lexical-binding: t; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Provides utilities for running ERT tests with focus/unfocus workflow
+;;
+;; Tests should be located in the Projectile project test directories,
+;; typically "test" or "tests" under the project root.
+;; Falls back to =~/.emacs.d/tests= if not in a Projectile project.
+;;
+;; The default mode is to load and run all tests.
+;;
+;; To focus on running a specific set of test files:
+;; - Toggle the mode to "focus" mode
+;; - Add specific test files to the list of tests in "focus"
+;; - Running tests (smartly) will now just run those tests
+;;
+;; Don't forget to run all tests again in default mode at least once before finishing.
+;;
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;;; Variables
+
+(defvar cj/test-global-directory nil
+ "Fallback global test directory when not in a Projectile project.")
+
+(defvar cj/test-focused-files '()
+ "List of test files for focused test execution.
+
+Each element is a filename (without path) to run.")
+
+(defvar cj/test-mode 'all
+ "Current test execution mode.
+
+Either 'all (run all tests) or 'focused (run only focused tests).")
+
+(defvar cj/test-last-results nil
+ "Results from the last test run.")
+
+;;; Core Functions
+
+;;;###autoload
+(defun cj/test--get-test-directory ()
+ "Return the test directory path for the current project.
+
+If in a Projectile project, prefers a 'test' or 'tests' directory inside the project root.
+Falls back to =cj/test-global-directory= if not found or not in a project."
+ (require 'projectile)
+ (let ((project-root (ignore-errors (projectile-project-root))))
+ (if (not (and project-root (file-directory-p project-root)))
+ ;; fallback global test directory
+ cj/test-global-directory
+ (let ((test-dir (expand-file-name "test" project-root))
+ (tests-dir (expand-file-name "tests" project-root)))
+ (cond
+ ((file-directory-p test-dir) test-dir)
+ ((file-directory-p tests-dir) tests-dir)
+ (t cj/test-global-directory))))))
+
+;;;###autoload
+(defun cj/test--get-test-files ()
+ "Return a list of test file names (without path) in the appropriate test directory."
+ (let ((dir (cj/test--get-test-directory)))
+ (when (file-directory-p dir)
+ (mapcar #'file-name-nondirectory
+ (directory-files dir t "^test-.*\\.el$")))))
+
+;;;###autoload
+(defun cj/test-load-all ()
+ "Load all test files from the appropriate test directory."
+ (interactive)
+ (cj/test--ensure-test-dir-in-load-path)
+ (let ((dir (cj/test--get-test-directory)))
+ (unless (file-directory-p dir)
+ (user-error "Test directory %s does not exist" dir))
+ (let ((test-files (directory-files dir t "^test-.*\\.el$"))
+ (loaded-count 0))
+ (dolist (file test-files)
+ (condition-case err
+ (progn
+ (load-file file)
+ (setq loaded-count (1+ loaded-count))
+ (message "Loaded test file: %s" (file-name-nondirectory file)))
+ (error
+ (message "Error loading %s: %s"
+ (file-name-nondirectory file)
+ (error-message-string err)))))
+ (message "Loaded %d test file(s)" loaded-count))))
+
+;;;###autoload
+(defun cj/test-focus-add ()
+ "Select test file(s) to add to the focused list."
+ (interactive)
+ (cj/test--ensure-test-dir-in-load-path)
+ (let* ((dir (cj/test--get-test-directory))
+ (available-files (when (file-directory-p dir)
+ (mapcar #'file-name-nondirectory
+ (directory-files dir t "^test-.*\\.el$")))))
+ (if (null available-files)
+ (user-error "No test files found in %s" dir)
+ (let* ((unfocused-files (cl-set-difference available-files
+ cj/test-focused-files
+ :test #'string=))
+ (selected (if unfocused-files
+ (completing-read "Add test file to focus: "
+ unfocused-files
+ nil t)
+ (user-error "All test files are already focused"))))
+ (push selected cj/test-focused-files)
+ (message "Added to focus: %s" selected)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused))))))
+
+;;;###autoload
+(defun cj/test-focus-add-this-buffer-file ()
+ "Add the current buffer's file to the focused test list."
+ (interactive)
+ (let ((file (buffer-file-name))
+ (dir (cj/test--get-test-directory)))
+ (unless file
+ (user-error "Current buffer is not visiting a file"))
+ (unless (string-prefix-p (file-truename dir) (file-truename file))
+ (user-error "File is not inside the test directory: %s" dir))
+ (let ((relative (file-relative-name file dir)))
+ (if (member relative cj/test-focused-files)
+ (message "Already focused: %s" relative)
+ (push relative cj/test-focused-files)
+ (message "Added to focus: %s" relative)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused))))))
+
+;;;###autoload
+(defun cj/test-focus-remove ()
+ "Remove a test file from the focused list."
+ (interactive)
+ (if (null cj/test-focused-files)
+ (user-error "No focused files to remove")
+ (let ((selected (completing-read "Remove from focus: "
+ cj/test-focused-files
+ nil t)))
+ (setq cj/test-focused-files
+ (delete selected cj/test-focused-files))
+ (message "Removed from focus: %s" selected)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused)))))
+
+;;;###autoload
+(defun cj/test-focus-clear ()
+ "Clear all focused test files."
+ (interactive)
+ (setq cj/test-focused-files '())
+ (message "Cleared all focused test files"))
+
+(defun cj/test--extract-test-names (file)
+ "Extract test names from FILE.
+
+Returns a list of test name symbols defined in the file."
+ (let ((test-names '()))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ ;; Find all (ert-deftest NAME ...) forms
+;; (while (re-search-forward "^\s-*(ert-deftest\s-+\\(\\(?:\\sw\\|\\s_\\)+\\)" nil t)
+ (while (re-search-forward "^[[:space:]]*(ert-deftest[[:space:]]+\\(\\(?:\\sw\\|\\s_\\)+\\)" nil t)
+ (push (match-string 1) test-names)))
+ test-names))
+
+;;;###autoload
+(defun cj/test-run-focused ()
+ "Run only the focused test files."
+ (interactive)
+ (if (null cj/test-focused-files)
+ (user-error "No focused files set. Use =cj/test-focus-add' first")
+ (let ((all-test-names '())
+ (loaded-count 0)
+ (dir (cj/test--get-test-directory)))
+ ;; Load the focused files and collect their test names
+ (dolist (file cj/test-focused-files)
+ (let ((full-path (expand-file-name file dir)))
+ (when (file-exists-p full-path)
+ (load-file full-path)
+ (setq loaded-count (1+ loaded-count))
+ ;; Extract test names from this file
+ (let ((test-names (cj/test--extract-test-names full-path)))
+ (setq all-test-names (append all-test-names test-names))))))
+ (if (null all-test-names)
+ (message "No tests found in focused files")
+ ;; Build a regexp that matches any of our test names
+ (let ((pattern (regexp-opt all-test-names)))
+ (message "Running %d test(s) from %d focused file(s)"
+ (length all-test-names) loaded-count)
+ ;; Run only the tests we found
+ (ert (concat "^" pattern "$")))))))
+
+(defun cj/test--ensure-test-dir-in-load-path ()
+ "Ensure the directory returned by cj/test--get-test-directory is in `load-path`."
+ (let ((dir (cj/test--get-test-directory)))
+ (when (and dir (file-directory-p dir))
+ (add-to-list 'load-path dir))))
+
+;;;###autoload
+(defun cj/run-test-at-point ()
+ "Run the ERT test at point.
+If point is inside an `ert-deftest` definition, run that test only.
+Otherwise, message that no test is found."
+ (interactive)
+ (let ((original-point (point)))
+ (save-excursion
+ (beginning-of-defun)
+ (condition-case nil
+ (let ((form (read (current-buffer))))
+ (if (and (listp form)
+ (eq (car form) 'ert-deftest)
+ (symbolp (cadr form)))
+ (ert (cadr form))
+ (message "Not in an ERT test method.")))
+ (error (message "No ERT test methods found at point."))))
+ (goto-char original-point)))
+
+;;;###autoload
+(defun cj/test-run-all ()
+ "Load and run all tests."
+ (interactive)
+ (cj/test-load-all)
+ (ert t))
+
+;;;###autoload
+(defun cj/test-toggle-mode ()
+ "Toggle between 'all and 'focused test execution modes."
+ (interactive)
+ (setq cj/test-mode (if (eq cj/test-mode 'all) 'focused 'all))
+ (message "Test mode: %s" cj/test-mode))
+
+;;;###autoload
+(defun cj/test-view-focused ()
+ "Display test files in focus."
+ (interactive)
+ (if (null cj/test-focused-files)
+ (message "No focused test files")
+ (message "Focused files: %s"
+ (mapconcat 'identity cj/test-focused-files ", "))))
+
+;;;###autoload
+(defun cj/test-run-smart ()
+ "Run tests based on current mode (all or focused)."
+ (interactive)
+ (if (eq cj/test-mode 'all)
+ (cj/test-run-all)
+ (cj/test-run-focused)))
+
+;; Test runner operations prefix and keymap
+(define-prefix-command 'cj/test-map nil
+ "Keymap for test-runner operations.")
+(define-key cj/custom-keymap "t" 'cj/test-map)
+
+(define-key cj/test-map "L" 'cj/test-load-all)
+(define-key cj/test-map "R" 'cj/test-run-all)
+(define-key cj/test-map "." 'cj/run-test-at-point)
+(define-key cj/test-map "r" 'cj/test-run-smart)
+(define-key cj/test-map "a" 'cj/test-focus-add)
+(define-key cj/test-map "b" 'cj/test-focus-add-this-buffer-file)
+(define-key cj/test-map "c" 'cj/test-focus-clear)
+(define-key cj/test-map "v" 'cj/test-view-focused)
+(define-key cj/test-map "t" 'cj/test-toggle-mode)
+
+(provide 'test-runner)
+;;; test-runner.el ends here