diff options
| author | Craig Jennings <c@cjennings.net> | 2025-10-12 11:47:26 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-10-12 11:47:26 -0500 |
| commit | 092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch) | |
| tree | ea81999b8442246c978b364dd90e8c752af50db5 /modules/test-runner.el | |
changing repositories
Diffstat (limited to 'modules/test-runner.el')
| -rw-r--r-- | modules/test-runner.el | 270 |
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 |
