From c5c191ef9d39df0f734a892c8f4c710b12e87413 Mon Sep 17 00:00:00 2001 From: Daniel Kraus Date: Mon, 11 May 2026 09:44:24 +0200 Subject: [PATCH] Debounce password prompt and auto-cancel on falling edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical+!echo heuristic is identical to ghostty's, but ghostel checks on every redraw (sub-100ms) and reacts with a modal `read-passwd' minibuffer — so short-lived flips that ghostty's 200ms termios poller silently misses become user-visible popups in ghostel. Two defenses: 1. Debounce the rising edge. `ghostel-password-prompt-debounce' (default 0.2s) schedules a confirm timer; the source chain only runs if the heuristic still reports password mode at the deadline. Sub-debounce flickers leave no trace beyond a brief mode-line indicator flash, matching ghostty's transient lock-icon UX. 2. Auto-cancel on falling edge. When the heuristic flips off while our `read-passwd' is up, `abort-recursive-edit' closes it — useful when the user kills the foreground program (e.g. C-c C-c sending Ctrl+C to sudo) and expects the prompt to disappear with it. Three gates ensure we only abort our own minibuffer: - Active flag: source chain is in flight. - Depth: `minibuffer-depth' equals the value captured before our prompt opened, plus one (our minibuffer is innermost). - Minibuffer identity: `(window-buffer (active-minibuffer-window))' matches the buffer captured by `minibuffer-with-setup-hook' when our `read-passwd' was entered. The setup hook only captures when `minibuffer-selected-window' points at our buffer, so an unrelated minibuffer opened concurrently (cross-buffer M-x race) can't poison the capture. Identity is robust to focus switching — `minibuffer-selected-window' alone returns nil once the user moves focus out of the minibuffer. Refs #244. --- lisp/ghostel.el | 155 +++++++++++++++++++++++---- test/ghostel-test.el | 248 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 377 insertions(+), 26 deletions(-) diff --git a/lisp/ghostel.el b/lisp/ghostel.el index a305bf47..84292215 100644 --- a/lisp/ghostel.el +++ b/lisp/ghostel.el @@ -476,6 +476,15 @@ regex run after each redraw and `read-passwd' is invoked on a rising edge. See `ghostel--detect-password-prompt'." :type 'boolean) +(defcustom ghostel-password-prompt-debounce 0.2 + "Seconds to wait after a rising edge before opening `read-passwd'. +When the canonical+!echo heuristic first fires, ghostel waits this +long and re-checks before invoking `ghostel-password-prompt-functions'. +Sub-debounce flips (e.g. shell quirks that flap echo briefly) never +reach the user - matching ghostty's natural 200 ms termios polling +cadence. Set to 0 to open the prompt immediately." + :type 'number) + (defcustom ghostel-notification-function #'ghostel-default-notify "Function called for OSC 9 / OSC 777 desktop notifications. Called with two string arguments: TITLE and BODY. Title is empty @@ -4181,7 +4190,6 @@ prompts while output continues streaming in." (ghostel-emacs-mode)) (ghostel--navigate-previous-prompt n)) - ;;; OSC 133 imenu integration @@ -4328,6 +4336,39 @@ naturally re-arm the detector for follow-on prompts (a second `sudo' in a script, a wrong-password retry that prints `Sorry, try again.' on a new row).") +(defvar-local ghostel--password-prompt-active nil + "Non-nil while the `ghostel-password-prompt-functions' chain is running. +Set around the hook chain in `ghostel--prompt-password' and cleared +in its unwind. Note: \"active\" means the source chain is executing, +not that a minibuffer is necessarily open - a source may complete +without opening one (e.g. `auth-source' returning a cached secret). +The falling-edge handler uses this together with +`ghostel--password-prompt-outer-depth' and the minibuffer-identity +gate in `ghostel--cancel-password-prompt' to abort only our own +minibuffer.") + +(defvar-local ghostel--password-prompt-outer-depth nil + "Value of `minibuffer-depth' captured when our prompt opened. +`ghostel--cancel-password-prompt' aborts the active minibuffer only +when the current depth is `(1+ outer-depth)' - i.e. our `read-passwd' +is the innermost recursive edit. This keeps the cancel from +clobbering an unrelated minibuffer (e.g. an `M-x' the user opened +before the false-positive rising edge fired).") + +(defvar-local ghostel--password-confirm-timer nil + "Pending debounce timer for `ghostel-password-prompt-debounce'. +Scheduled by `ghostel--detect-password-prompt' on the rising edge; +cancelled on the falling edge or before re-scheduling. The timer +body (`ghostel--confirm-and-prompt') re-runs the heuristic before calling +`ghostel--prompt-password' so short-lived flips never reach the user.") + +(defvar-local ghostel--password-prompt-mb-buffer nil + "Minibuffer buffer of our currently-open `read-passwd' prompt, or nil. +Captured by a `minibuffer-with-setup-hook' wrapped around the +`ghostel-password-prompt-functions' chain, but only for minibuffers entered +from this ghostel buffer's window - so an unrelated minibuffer that happens +to open while our source is mid-IO doesn't poison the capture.") + (defun ghostel--remote-shell-p () "Return non-nil when the foreground shell is on a remote host. Trusts TRAMP `default-directory': ghostel's OSC 7 handler @@ -4395,20 +4436,72 @@ Matching is case-insensitive, mirroring `comint-watch-for-password-prompt'." (case-fold-search t)) (string-match-p ghostel-password-prompt-regex row))) +(defun ghostel--cancel-password-confirm-timer () + "Cancel the pending `ghostel--password-confirm-timer', if any." + (when ghostel--password-confirm-timer + (cancel-timer ghostel--password-confirm-timer) + (setq ghostel--password-confirm-timer nil))) + +(defun ghostel--cancel-password-prompt () + "Abort our in-flight `read-passwd' minibuffer, if it is the innermost ours. +Two gates make sure we abort only a minibuffer we opened: + + - Depth: current `minibuffer-depth' must be `outer+1', i.e. exactly + one minibuffer-level (ours) opened since our prompt began. + - Minibuffer identity: `(active-minibuffer-window)''s buffer must + equal `ghostel--password-prompt-mb-buffer', captured by the + setup hook when our `read-passwd' was entered. This is robust + to the user switching focus out of the minibuffer (which makes + `minibuffer-selected-window' return nil) and to cross-buffer + races where an unrelated minibuffer (e.g. `M-x' in another + buffer) is at the matching depth." + (when (and ghostel--password-prompt-active + ghostel--password-prompt-outer-depth + (= (minibuffer-depth) + (1+ ghostel--password-prompt-outer-depth)) + ghostel--password-prompt-mb-buffer + (let ((amw (active-minibuffer-window))) + (and amw (eq (window-buffer amw) + ghostel--password-prompt-mb-buffer)))) + (abort-recursive-edit))) + +(defun ghostel--confirm-and-prompt (buf) + "Re-check the password heuristic in BUF and open `ghostel--prompt-password'. +Body of the `ghostel-password-prompt-debounce' timer scheduled on +the rising edge by `ghostel--detect-password-prompt'. Re-running +`ghostel--password-prompt-detected-p' here is what filters sub-debounce +flickers — they cleared `ghostel--password-mode-p' on the falling +edge, so we no-op." + (when (buffer-live-p buf) + (with-current-buffer buf + (setq ghostel--password-confirm-timer nil) + (when (and ghostel--password-mode-p + (ghostel--password-prompt-detected-p)) + (ghostel--prompt-password))))) + (defun ghostel--detect-password-prompt () - "Update `ghostel--password-mode-p' and run hook on rising edge. -Called from `ghostel--delayed-redraw' once the buffer reflects -the latest output. No-op when `ghostel-detect-password-prompts' -is nil (e.g. ghostel-compile buffers, which run the pty in -`canonical+!echo' on purpose). Suppresses re-fires while the -cursor is still on the row where the previous handler returned -\(see `ghostel--password-handled-cursor')." + "Update `ghostel--password-mode-p' and arm the confirm timer. +Called from `ghostel--delayed-redraw' once the buffer reflects the +latest output. No-op when `ghostel-detect-password-prompts' is nil +\(e.g. ghostel-compile buffers, which run the pty in `canonical+!echo' +on purpose). Suppresses re-fires while the cursor is still on the +row where the previous handler returned (see +`ghostel--password-handled-cursor'). + +A rising edge schedules `ghostel--confirm-and-prompt' after +`ghostel-password-prompt-debounce' seconds; the falling edge cancels +that timer and, if our `read-passwd' has already opened, aborts it +via `ghostel--cancel-password-prompt'. Net effect: short-lived +canonical+!echo flips trigger nothing more than a brief mode-line +indicator flash, mirroring ghostty's transient lock-icon behavior." (when ghostel-detect-password-prompts (let ((now (ghostel--password-prompt-detected-p)) (cursor ghostel--cursor-pos)) (cond ;; Echo back on — clear all state so a future prompt re-arms. ((not now) + (ghostel--cancel-password-confirm-timer) + (ghostel--cancel-password-prompt) (when (or ghostel--password-mode-p ghostel--password-handled-cursor) (setq ghostel--password-mode-p nil ghostel--password-handled-cursor nil) @@ -4427,15 +4520,13 @@ cursor is still on the row where the previous handler returned (ghostel--mode-line-refresh) ;; Defer so the prompt minibuffer doesn't open from inside the ;; process filter — opening it there blocks further PTY output - ;; until the user submits. - (let ((buf (current-buffer))) - (run-at-time - 0 nil - (lambda () - (when (buffer-live-p buf) - (with-current-buffer buf - (when ghostel--password-mode-p - (ghostel--prompt-password)))))))))))) + ;; until the user submits. The debounce additionally gates the + ;; open on a re-check after `ghostel-password-prompt-debounce'. + (ghostel--cancel-password-confirm-timer) + (setq ghostel--password-confirm-timer + (run-at-time ghostel-password-prompt-debounce nil + #'ghostel--confirm-and-prompt + (current-buffer)))))))) (defun ghostel--default-password-source (row) "Default password source: prompt with `read-passwd'. @@ -4456,12 +4547,31 @@ suppression so the detector doesn't re-fire while the foreground program restores echo. State cleanup runs even when a source signals quit (`keyboard-quit' during `read-passwd'), so the indicator and suppression always reach a sane state." - (let ((pwd nil) - (row (ghostel--cursor-row-text))) + (let* ((pwd nil) + (row (ghostel--cursor-row-text)) + (origin (current-buffer))) + (setq ghostel--password-prompt-outer-depth (minibuffer-depth) + ghostel--password-prompt-active t + ghostel--password-prompt-mb-buffer nil) (unwind-protect - (setq pwd (run-hook-with-args-until-success - 'ghostel-password-prompt-functions - row)) + (minibuffer-with-setup-hook + (lambda () + ;; Runs in the minibuffer buffer during setup; `current-buffer' is + ;; the new minibuffer and `selected-window' is its window. + ;; Only capture when the minibuffer was entered from ORIGIN - an + ;; unrelated minibuffer opened concurrently has a different parent + ;; window and must not poison our captured buffer. + (let ((sw (minibuffer-selected-window)) + (mb (current-buffer))) + (when (and sw (eq (window-buffer sw) origin)) + (with-current-buffer origin + (setq ghostel--password-prompt-mb-buffer mb))))) + (setq pwd (run-hook-with-args-until-success + 'ghostel-password-prompt-functions + row))) + (setq ghostel--password-prompt-active nil + ghostel--password-prompt-outer-depth nil + ghostel--password-prompt-mb-buffer nil) ;; The (concat pwd "\r") wire copy is freshly allocated and owned by us, ;; so `clear-string' it after the send. Nested `unwind-protect' so the ;; wire is cleared even if `process-send-string' errors (e.g. process died @@ -4946,6 +5056,7 @@ PROCESS is the shell process, EVENT describes the state change." (setq ghostel--plain-link-detection-timer nil ghostel--plain-link-detection-begin nil ghostel--plain-link-detection-end nil)) + (ghostel--cancel-password-confirm-timer) (ghostel--spinner-stop) (remove-hook 'pre-redisplay-functions #'ghostel--fake-cursor-update t) (ghostel--fake-cursor-clear) diff --git a/test/ghostel-test.el b/test/ghostel-test.el index b6e9a62f..76051aa5 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -4128,7 +4128,10 @@ a successful submission." (when (buffer-live-p buf) (kill-buffer buf))))) (ert-deftest ghostel-test-detect-password-prompt-fires-once-per-edge () - "Hook fires on rising edge only; falling edge clears state." + "Hook fires on rising edge only; falling edge clears state. +`ghostel-password-prompt-debounce' is set to 0 so the confirm timer +fires on the next event-loop tick — the test is about edge logic, +not the debounce window itself (covered separately)." (let* ((buf (generate-new-buffer " *ghostel-test-pwd-edge*")) (calls 0) (now nil)) @@ -4139,7 +4142,8 @@ a successful submission." (setq ghostel--term-rows 5) (ghostel--redraw ghostel--term) (let ((ghostel-password-prompt-functions - (list (lambda (_row) (cl-incf calls) nil)))) + (list (lambda (_row) (cl-incf calls) nil))) + (ghostel-password-prompt-debounce 0)) (cl-letf (((symbol-function 'ghostel--password-prompt-detected-p) (lambda () now))) (setq now t) @@ -4164,7 +4168,9 @@ a successful submission." Regression: sudo (and friends) hold the tty in canonical+!echo for tens of milliseconds after read() returns; the next PTY chunk would otherwise look like a fresh rising edge and pop a second -`read-passwd' minibuffer." +`read-passwd' minibuffer. Debounce is set to 0 so the confirm +timer fires on the next event-loop tick — this test is about the +handled-row suppression, not the debounce window." (let* ((buf (generate-new-buffer " *ghostel-test-pwd-suppress*")) (calls 0)) (unwind-protect @@ -4173,7 +4179,8 @@ otherwise look like a fresh rising edge and pop a second (setq ghostel--term (ghostel--new 5 80 1000)) (setq ghostel--term-rows 5) (let ((ghostel-password-prompt-functions - (list (lambda (_row) (cl-incf calls) nil)))) + (list (lambda (_row) (cl-incf calls) nil))) + (ghostel-password-prompt-debounce 0)) (cl-letf (((symbol-function 'ghostel--password-prompt-detected-p) (lambda () t)) (ghostel--cursor-pos '(0 . 2))) @@ -4201,6 +4208,239 @@ otherwise look like a fresh rising edge and pop a second (should (= 2 calls))))) (when (buffer-live-p buf) (kill-buffer buf))))) +(ert-deftest ghostel-test-password-debounce-defers-source-call () + "Rising edge schedules a confirm timer; source is NOT called synchronously. +The source runs only after the debounce elapses and the heuristic +is re-confirmed (mirrors ghostty's ~200 ms termios polling cadence)." + (let* ((buf (generate-new-buffer " *ghostel-test-pwd-debounce-defer*")) + (calls 0)) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (setq ghostel--term (ghostel--new 5 80 1000)) + (setq ghostel--term-rows 5) + (ghostel--redraw ghostel--term) + (let ((ghostel-password-prompt-functions + (list (lambda (_row) (cl-incf calls) "x"))) + (ghostel-password-prompt-debounce 0.1)) + (cl-letf (((symbol-function 'ghostel--password-prompt-detected-p) + (lambda () t)) + ((symbol-function 'process-send-string) + (lambda (_p _d) nil))) + (ghostel--detect-password-prompt) + (should ghostel--password-mode-p) + (should ghostel--password-confirm-timer) + (should (= 0 calls)) + (sleep-for 0.25) + (should (= 1 calls)) + (should-not ghostel--password-confirm-timer)))) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest ghostel-test-password-debounce-cancels-on-sub-debounce-flicker () + "Sub-debounce flicker cancels the confirm timer; source is never called. +This is the false-positive defense that mirrors ghostty's natural +undersampling — a canonical+!echo flip shorter than the debounce +window never reaches the user." + (let* ((buf (generate-new-buffer " *ghostel-test-pwd-debounce-flicker*")) + (calls 0) + (now t)) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (setq ghostel--term (ghostel--new 5 80 1000)) + (setq ghostel--term-rows 5) + (ghostel--redraw ghostel--term) + (let ((ghostel-password-prompt-functions + (list (lambda (_row) (cl-incf calls) "x"))) + (ghostel-password-prompt-debounce 0.5)) + (cl-letf (((symbol-function 'ghostel--password-prompt-detected-p) + (lambda () now))) + (ghostel--detect-password-prompt) + (should ghostel--password-confirm-timer) + (should ghostel--password-mode-p) + ;; Falling edge before timer fires. + (setq now nil) + (ghostel--detect-password-prompt) + (should-not ghostel--password-confirm-timer) + (should-not ghostel--password-mode-p) + (sleep-for 0.6) + (should (= 0 calls))))) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(ert-deftest ghostel-test-cancel-password-prompt-depth-gate () + "`ghostel--cancel-password-prompt' aborts only when depth is outer+1. +Other depths (no minibuffer, equal to outer, or deeper) are no-ops +so unrelated minibuffers (e.g. `M-x' the user opened before the +rising edge) survive. The minibuffer-identity gate is satisfied +by stubbing `active-minibuffer-window' + `window-buffer' to return +the same buffer we set as `ghostel--password-prompt-mb-buffer'." + (let ((buf (generate-new-buffer " *ghostel-test-pwd-cancel-gate*")) + (mb-buf (generate-new-buffer " *ghostel-test-pwd-mb*")) + (aborted 0) + (depth 0)) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (cl-letf (((symbol-function 'minibuffer-depth) (lambda () depth)) + ((symbol-function 'active-minibuffer-window) + (lambda () 'fake-window)) + ((symbol-function 'window-buffer) + (lambda (_w) mb-buf)) + ((symbol-function 'abort-recursive-edit) + (lambda () (cl-incf aborted)))) + (setq ghostel--password-prompt-mb-buffer mb-buf) + ;; Flag off → no abort regardless of depth. + (setq ghostel--password-prompt-active nil + ghostel--password-prompt-outer-depth 0 + depth 1) + (ghostel--cancel-password-prompt) + (should (= 0 aborted)) + ;; Flag on, depth == outer (prompt not yet opened) → no abort. + (setq ghostel--password-prompt-active t + ghostel--password-prompt-outer-depth 1 + depth 1) + (ghostel--cancel-password-prompt) + (should (= 0 aborted)) + ;; Flag on, depth == outer+1 → our minibuffer is innermost; abort. + (setq depth 2) + (ghostel--cancel-password-prompt) + (should (= 1 aborted)) + ;; Flag on, depth > outer+1 (something stacked on top) → no abort. + (setq depth 3) + (ghostel--cancel-password-prompt) + (should (= 1 aborted)))) + (when (buffer-live-p buf) (kill-buffer buf)) + (when (buffer-live-p mb-buf) (kill-buffer mb-buf))))) + +(ert-deftest ghostel-test-cancel-password-prompt-mb-identity-gate () + "Identity gate blocks abort when the active minibuffer isn't ours. +Stub setup: depth=1, outer=0 (so depth gate passes). The cancel +fires only when `(window-buffer (active-minibuffer-window))' equals +`ghostel--password-prompt-mb-buffer' — i.e. the active minibuffer +is the one our setup hook captured. Covers two failure modes: + - Captured nil (our chain ran but never opened a minibuffer); + - Active minibuffer is some other buffer (cross-buffer race or + nested minibuffer that pushed us off the innermost slot)." + (let ((buf (generate-new-buffer " *ghostel-test-pwd-mb-gate*")) + (our-mb (generate-new-buffer " *ghostel-test-pwd-our-mb*")) + (other-mb (generate-new-buffer " *ghostel-test-pwd-other-mb*")) + (aborted 0) + (active-mb-buf nil)) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (cl-letf (((symbol-function 'minibuffer-depth) (lambda () 1)) + ((symbol-function 'active-minibuffer-window) + (lambda () (and active-mb-buf 'fake-window))) + ((symbol-function 'window-buffer) + (lambda (_w) active-mb-buf)) + ((symbol-function 'abort-recursive-edit) + (lambda () (cl-incf aborted)))) + (setq ghostel--password-prompt-active t + ghostel--password-prompt-outer-depth 0) + ;; Captured nil → never abort. + (setq ghostel--password-prompt-mb-buffer nil + active-mb-buf our-mb) + (ghostel--cancel-password-prompt) + (should (= 0 aborted)) + ;; Captured but active minibuffer is a different buffer → no abort. + (setq ghostel--password-prompt-mb-buffer our-mb + active-mb-buf other-mb) + (ghostel--cancel-password-prompt) + (should (= 0 aborted)) + ;; Captured matches active minibuffer → abort. + (setq active-mb-buf our-mb) + (ghostel--cancel-password-prompt) + (should (= 1 aborted)) + ;; No active minibuffer at all → no abort. + (setq active-mb-buf nil) + (ghostel--cancel-password-prompt) + (should (= 1 aborted)))) + (when (buffer-live-p buf) (kill-buffer buf)) + (when (buffer-live-p our-mb) (kill-buffer our-mb)) + (when (buffer-live-p other-mb) (kill-buffer other-mb))))) + +(ert-deftest ghostel-test-prompt-password-mb-buffer-capture () + "Setup hook in `ghostel--prompt-password' captures the right minibuffer. +The lambda installed by `minibuffer-with-setup-hook' must set +`ghostel--password-prompt-mb-buffer' only when the minibuffer was +entered from the origin buffer's window — an unrelated minibuffer +opened concurrently (cross-buffer race) must not poison the +capture. Drives `minibuffer-setup-hook' directly from a stubbed +source so the hook fires under controlled conditions without +involving a real `read-passwd' call, and snapshots +`ghostel--password-prompt-mb-buffer' before the unwind clears it." + (let ((origin (generate-new-buffer " *ghostel-test-pwd-capture-origin*")) + (mb (generate-new-buffer " *ghostel-test-pwd-capture-mb*")) + (other (generate-new-buffer " *ghostel-test-pwd-capture-other*")) + (snapshot 'sentinel)) + (unwind-protect + (with-current-buffer origin + (ghostel-mode) + (setq ghostel--process 'fake-proc) + (cl-letf (((symbol-function 'processp) (lambda (_p) t)) + ((symbol-function 'process-live-p) (lambda (_p) t)) + ((symbol-function 'process-send-string) (lambda (_p _d) nil))) + ;; Case 1: minibuffer entered from ORIGIN → capture. + (let ((ghostel-password-prompt-functions + (list (lambda (_row) + (with-current-buffer mb + (cl-letf (((symbol-function 'minibuffer-selected-window) + (lambda () 'fake-win)) + ((symbol-function 'window-buffer) + (lambda (_w) origin))) + (run-hooks 'minibuffer-setup-hook))) + (setq snapshot ghostel--password-prompt-mb-buffer) + "x")))) + (ghostel--prompt-password)) + (should (eq snapshot mb)) + ;; Case 2: minibuffer entered from a different buffer → no capture. + (setq snapshot 'sentinel) + (let ((ghostel-password-prompt-functions + (list (lambda (_row) + (with-current-buffer mb + (cl-letf (((symbol-function 'minibuffer-selected-window) + (lambda () 'fake-win)) + ((symbol-function 'window-buffer) + (lambda (_w) other))) + (run-hooks 'minibuffer-setup-hook))) + (setq snapshot ghostel--password-prompt-mb-buffer) + "x")))) + (ghostel--prompt-password)) + (should-not snapshot))) + (when (buffer-live-p origin) (kill-buffer origin)) + (when (buffer-live-p mb) (kill-buffer mb)) + (when (buffer-live-p other) (kill-buffer other))))) + +(ert-deftest ghostel-test-password-detect-aborts-on-falling-edge () + "Falling edge with prompt active and right depth calls `abort-recursive-edit'. +Routes through `ghostel--cancel-password-prompt' from the falling-edge +branch of `ghostel--detect-password-prompt'. Identity gate is +satisfied by setting `ghostel--password-prompt-mb-buffer' and +stubbing `active-minibuffer-window' / `window-buffer' to return it." + (let ((buf (generate-new-buffer " *ghostel-test-pwd-falling-abort*")) + (mb-buf (generate-new-buffer " *ghostel-test-pwd-falling-mb*")) + (aborted 0)) + (unwind-protect + (with-current-buffer buf + (ghostel-mode) + (cl-letf (((symbol-function 'ghostel--password-prompt-detected-p) + (lambda () nil)) + ((symbol-function 'minibuffer-depth) (lambda () 1)) + ((symbol-function 'active-minibuffer-window) + (lambda () 'fake-window)) + ((symbol-function 'window-buffer) + (lambda (_w) mb-buf)) + ((symbol-function 'abort-recursive-edit) + (lambda () (cl-incf aborted)))) + (setq ghostel--password-prompt-active t + ghostel--password-prompt-outer-depth 0 + ghostel--password-prompt-mb-buffer mb-buf) + (ghostel--detect-password-prompt) + (should (= 1 aborted)))) + (when (buffer-live-p buf) (kill-buffer buf)) + (when (buffer-live-p mb-buf) (kill-buffer mb-buf))))) + ;; ----------------------------------------------------------------------- ;; Test: ghostel-command-finish-functions hook ;; -----------------------------------------------------------------------