Files
plyngent/tests/test_cli/test_notify.py
T
NCBM 16c5e01e44 test: cover answers from the notification
Three seams: the offer is known when the wait starts (a confirm and a question,
with and without the platform capability), a button pressed after the
notification appears answers the tool confirm it belongs to — a pty stands in
for the terminal so the read has to consult both surfaces — and `confirm()`
takes an answer passed in and one offered by the wait hook, without asking the
backend.
2026-10-23 10:24:56 +08:00

412 lines
14 KiB
Python

from __future__ import annotations
import time
from typing import TYPE_CHECKING
import pytest
from plyngent.cli.notify import (
ALLOW,
DENY,
REVIEW,
DesktopRequest,
WaitNotifier,
detect_multiplexer,
detect_protocol,
install_notifier,
terminal_sequence,
)
from plyngent.config.models import NotifyConfig
from plyngent.prompting import (
PromptWait,
configure_prompting,
get_prompt_wait_hook,
reset_prompting,
temporary_backend,
)
if TYPE_CHECKING:
from collections.abc import Iterator
class _InteractiveBackend:
"""An interactive backend that never actually reads (the prompts do not run)."""
def is_interactive(self) -> bool:
return True
def read_line(self, prompt: str, *, default: str | None = None, completions: object = None) -> str:
del prompt, completions
return default or ""
def read_secret_line(self, prompt: str) -> str:
del prompt
return ""
def confirm(self, prompt: str, *, default: bool = False) -> bool:
del prompt
return default
def echo(self, message: str = "", *, err: bool = False) -> None:
del message, err
def secho(self, message: str, *, fg: str | None = None, err: bool = False) -> None:
del message, fg, err
class _FakeDesktop:
"""A desktop channel that records what it was asked to show."""
def __init__(
self,
capabilities: frozenset[str] | None = None,
*,
fail: bool = False,
) -> None:
self.caps = frozenset({"buttons", "reply"}) if capabilities is None else capabilities
self.fail = fail
self.requests: list[DesktopRequest] = []
self.cleared: list[str | None] = []
def capabilities(self) -> frozenset[str]:
return self.caps
def send(self, request: DesktopRequest) -> str | None:
if self.fail:
msg = "no notification server"
raise RuntimeError(msg)
self.requests.append(request)
return "id-1"
def clear(self, identifier: str | None) -> None:
self.cleared.append(identifier)
@pytest.fixture(autouse=True)
def _prompting_state() -> Iterator[None]:
reset_prompting()
configure_prompting(backend=_InteractiveBackend())
yield
reset_prompting()
def _notifier(
*,
desktop: _FakeDesktop | None = None,
grace_seconds: float = 60.0,
min_interval_seconds: float = 0.0,
**kwargs: object,
) -> tuple[WaitNotifier, list[str]]:
"""A notifier with a captured terminal writer and a fake desktop channel."""
written: list[str] = []
config = NotifyConfig(
grace_seconds=grace_seconds,
min_interval_seconds=min_interval_seconds,
**kwargs, # type: ignore[arg-type]
)
notifier = WaitNotifier(
config,
desktop=desktop,
write=written.append,
env={"TERM_PROGRAM": "WezTerm"},
)
return notifier, written
# --------------------------------------------------------------------------- #
# Terminal channel
# --------------------------------------------------------------------------- #
def test_detect_protocol_reads_the_environment() -> None:
assert detect_protocol({"TERM_PROGRAM": "iTerm.app"}) == "osc9"
assert detect_protocol({"TERM_PROGRAM": "Apple_Terminal"}) == "osc9"
assert detect_protocol({"TERM_PROGRAM": "WezTerm"}) == "osc777"
assert detect_protocol({"TERM_PROGRAM": "ghostty"}) == "osc777"
assert detect_protocol({"WT_SESSION": "1"}) == "osc777"
assert detect_protocol({"VTE_VERSION": "5000"}) == "osc777"
assert detect_protocol({"TERM": "rxvt-unicode"}) == "osc777"
# kitty speaks OSC 99 only, which this module does not implement.
assert detect_protocol({"KITTY_WINDOW_ID": "1"}) == "bell"
assert detect_protocol({"TERM": "dumb"}) == "bell"
def test_detect_protocol_honours_the_override() -> None:
assert detect_protocol({"TERM_PROGRAM": "WezTerm"}, "osc9") == "osc9"
assert detect_protocol({"TERM_PROGRAM": "iTerm.app"}, "bell") == "bell"
# An unknown override falls back to detection.
assert detect_protocol({"TERM_PROGRAM": "WezTerm"}, "osc99") == "osc777"
def test_detect_multiplexer() -> None:
assert detect_multiplexer({"TMUX": "/tmp/tmux-1000/default,1,0"}) == "tmux"
assert detect_multiplexer({"STY": "1234.pts-0.host"}) == "screen"
assert detect_multiplexer({}) == ""
def test_terminal_sequence_osc777_and_osc9() -> None:
assert terminal_sequence("osc777", "T", "M") == "\x1b]777;notify;T;M\x07"
assert terminal_sequence("osc9", "T", "M") == "\x1b]9;T: M\x07"
assert terminal_sequence("bell", "T", "M") == "\a"
def test_terminal_sequence_is_wrapped_for_tmux() -> None:
seq = terminal_sequence("osc777", "T", "M", multiplexer="tmux")
assert seq.startswith("\x1bPtmux;\x1b")
assert seq.endswith("\x1b\\")
# The inner ESCs are doubled so tmux forwards them verbatim.
assert "\x1b\x1b]777" in seq
def test_terminal_sequence_strips_control_characters() -> None:
seq = terminal_sequence("osc777", "a\x07b\nc", "d\te")
assert "\x07" not in seq.removesuffix("\x07")
assert "ab c" in seq
assert "\n" not in seq
# --------------------------------------------------------------------------- #
# Grace, throttle, channels
# --------------------------------------------------------------------------- #
def test_nothing_is_shown_before_the_grace_period() -> None:
notifier, written = _notifier(grace_seconds=0.05)
with notifier(PromptWait("confirm", "Allow?")):
pass
time.sleep(0.1)
assert written == []
def test_a_waited_prompt_is_announced() -> None:
notifier, written = _notifier(grace_seconds=0.01)
with notifier(PromptWait("confirm", "Allow?")) as handle:
deadline = time.monotonic() + 1.0
while not written and time.monotonic() < deadline:
time.sleep(0.01)
assert handle.supports_actions is False # no desktop channel here
assert written
assert "777" in written[0]
assert "Allow?" in written[0]
def test_terminal_channel_can_be_pinned() -> None:
desktop = _FakeDesktop()
notifier, written = _notifier(desktop=desktop, channels=["terminal"])
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert desktop.requests == []
assert written
def test_desktop_channel_can_be_pinned() -> None:
desktop = _FakeDesktop()
notifier, written = _notifier(desktop=desktop, channels=["desktop"])
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert len(desktop.requests) == 1
assert written == []
def test_terminal_fallback_when_the_desktop_refuses() -> None:
desktop = _FakeDesktop(fail=True)
notifier, written = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert written
def test_a_burst_is_throttled() -> None:
notifier, written = _notifier(grace_seconds=0.0, min_interval_seconds=60.0)
for _ in range(2):
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert len(written) == 1
def test_nothing_is_shown_on_a_non_interactive_prompt() -> None:
from plyngent.prompting import NonInteractiveBackend
notifier, written = _notifier()
configure_prompting(backend=NonInteractiveBackend())
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert written == []
def test_disabled_notifications_send_nothing() -> None:
notifier, written = _notifier(grace_seconds=0.0, enabled=False)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert written == []
# --------------------------------------------------------------------------- #
# Answers
# --------------------------------------------------------------------------- #
def test_a_confirmation_offers_three_buttons() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert handle.supports_actions is True
request = desktop.requests[0]
assert [key for key, _ in request.buttons] == [ALLOW, DENY, REVIEW]
assert request.reply_field is False
def test_a_waiting_prompt_is_answerable_before_it_is_announced() -> None:
"""The offer is decided when the wait starts, not when the notification shows.
A notification only appears after the grace period, so a prompt that learned
about its answers from ``notify_desktop`` would already be blocked on stdin.
"""
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop, grace_seconds=60.0)
with notifier(PromptWait("confirm", "Allow?")) as handle:
assert handle.supports_actions is True
assert desktop.requests == [] # not announced yet
def test_a_question_is_answerable_before_it_is_announced() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop, grace_seconds=60.0)
with notifier(PromptWait("ask", "Which branch?")) as handle:
assert handle.supports_actions is True
assert desktop.requests == []
def test_no_answer_is_expected_without_the_capability() -> None:
desktop = _FakeDesktop(capabilities=frozenset({"buttons"}))
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("ask", "Which branch?")) as handle:
assert handle.supports_actions is False
def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("ask", "Which branch?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.reply_field is True
assert request.buttons == ()
def test_a_limit_prompt_carries_no_answers() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("limit", "Round limit reached")) as handle:
handle._notify()
assert handle.supports_actions is False
assert desktop.requests[0].buttons == ()
assert desktop.requests[0].reply_field is False
def test_actions_can_be_switched_off() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop, actions=False)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert handle.supports_actions is False
assert desktop.requests[0].buttons == ()
def test_platform_without_buttons_gets_a_text_notification() -> None:
desktop = _FakeDesktop(capabilities=frozenset())
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert handle.supports_actions is False
assert desktop.requests[0].buttons == ()
def test_a_button_answer_reaches_the_prompt() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_answer is not None
request.on_answer(ALLOW)
assert handle.take() == ALLOW
assert handle.take() is None
def test_a_reply_reaches_the_prompt() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("ask", "Which branch?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_reply is not None
request.on_reply("feature/x")
assert handle.take() == "feature/x"
def test_review_keeps_waiting_and_dismisses() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_answer is not None
request.on_answer(REVIEW)
assert handle.take() is None
assert desktop.cleared == ["id-1"]
def test_an_answer_after_the_prompt_is_ignored() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
request = desktop.requests[0]
assert request.on_answer is not None
request.on_answer(ALLOW)
assert handle.take() is None
def test_the_notification_is_dismissed_when_the_prompt_returns() -> None:
desktop = _FakeDesktop()
notifier, _ = _notifier(desktop=desktop)
with notifier(PromptWait("confirm", "Allow?")) as handle:
handle._notify()
assert handle.identifier == "id-1"
assert handle.identifier is None
assert desktop.cleared == ["id-1"]
# --------------------------------------------------------------------------- #
# Installation
# --------------------------------------------------------------------------- #
def test_install_notifier_sets_the_hook() -> None:
notifier = install_notifier(NotifyConfig())
assert notifier is not None
assert get_prompt_wait_hook() is not None
def test_install_notifier_can_be_disabled() -> None:
assert install_notifier(NotifyConfig(enabled=False)) is None
assert get_prompt_wait_hook() is None
def test_reason_stays_out_unless_asked_for() -> None:
notifier, _ = _notifier()
wait = PromptWait("confirm", "Allow?", "argv: rm -rf /")
assert notifier._message(wait) == "Allow?"
notifier, _ = _notifier(grace_seconds=0.0, include_reason=True)
assert "rm -rf" in notifier._message(wait)
def test_temporary_backend_is_transparent_to_the_notifier() -> None:
"""The notifier is only a hook: without installation nothing is shown."""
notifier, written = _notifier()
with temporary_backend(_InteractiveBackend()), notifier(PromptWait("confirm", "Allow?")):
pass
assert written == []