229 lines
7.8 KiB
Python
229 lines
7.8 KiB
Python
"""
|
||
E2E regression test: two-press Ctrl+C exit with clear-input
|
||
semantics in the ``omnigent chat`` REPL.
|
||
|
||
Behavior contract (see
|
||
``TerminalHost.__init__::_on_ctrl_c``):
|
||
|
||
- Input buffer has text → first Ctrl+C clears the buffer, does
|
||
NOT exit, does NOT arm any exit confirmation.
|
||
- Input buffer is empty → first Ctrl+C arms a
|
||
``_EXIT_CONFIRM_WINDOW``-second countdown and renders
|
||
``Press Ctrl+C again to exit`` in the bottom toolbar.
|
||
- Second Ctrl+C within that window exits the REPL cleanly.
|
||
- If the window expires without a second press, the hint
|
||
clears and the next Ctrl+C arms a fresh countdown.
|
||
|
||
Without this fix, any single Ctrl+C tears the whole REPL down,
|
||
which was a frequent fat-finger loss-of-input complaint.
|
||
|
||
The driver (``_ctrl_c_driver.py``) prints ``CTRL_C_DRIVER_EXITED_CLEANLY_XYZZY``
|
||
to stdout after :meth:`TerminalHost.run` returns — the only
|
||
way for it to return in this driver is the second Ctrl+C
|
||
raising :class:`KeyboardInterrupt`, which the host's run loop
|
||
catches and breaks on.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
|
||
import pexpect
|
||
import pyte
|
||
import pytest
|
||
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
# mypy can't resolve this — the driver isn't a proper package
|
||
# module, it's only importable after the sys.path insert above.
|
||
from _ctrl_c_driver import _EXIT_SENTINEL # type: ignore[import-not-found]
|
||
|
||
_DRIVER = Path(__file__).parent / "_ctrl_c_driver.py"
|
||
_TERM = "xterm-256color"
|
||
_ROWS = 40
|
||
_COLS = 120
|
||
|
||
_BOOT_TIMEOUT = 10.0
|
||
_RENDER_TIMEOUT = 3.0
|
||
_SHUTDOWN_TIMEOUT = 5.0
|
||
|
||
|
||
def _wait_for_screen(
|
||
child: pexpect.spawn,
|
||
screen: pyte.Screen,
|
||
stream: pyte.Stream,
|
||
substring: str,
|
||
timeout: float,
|
||
) -> str:
|
||
"""
|
||
Drain the PTY stream into a pyte terminal emulator until
|
||
*substring* appears on the rendered display or *timeout*
|
||
elapses.
|
||
|
||
:param child: Live pexpect child.
|
||
:param screen: pyte screen to update.
|
||
:param stream: pyte stream feeding *screen*.
|
||
:param substring: Plain-text sentinel to look for.
|
||
:param timeout: Max seconds to wait.
|
||
:returns: The rendered display at match time.
|
||
:raises pexpect.TIMEOUT: If *substring* never appears.
|
||
"""
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
try:
|
||
chunk = child.read_nonblocking(size=4096, timeout=0.1)
|
||
except pexpect.TIMEOUT:
|
||
continue
|
||
stream.feed(chunk)
|
||
rendered = "\n".join(screen.display)
|
||
if substring in rendered:
|
||
return rendered
|
||
rendered = "\n".join(screen.display)
|
||
raise pexpect.TIMEOUT(
|
||
f"did not see {substring!r} within {timeout}s. Rendered:\n{rendered}",
|
||
)
|
||
|
||
|
||
@pytest.fixture
|
||
def ctrl_c_child() -> pexpect.spawn:
|
||
"""
|
||
Spawn the Ctrl+C driver under a PTY.
|
||
|
||
:returns: A live pexpect child. Fixture teardown sends
|
||
Ctrl+D + force-close so a hung child never blocks the
|
||
suite.
|
||
"""
|
||
child = pexpect.spawn(
|
||
sys.executable,
|
||
[str(_DRIVER)],
|
||
env={"TERM": _TERM, "LINES": str(_ROWS), "COLUMNS": str(_COLS)},
|
||
encoding="utf-8",
|
||
timeout=_BOOT_TIMEOUT,
|
||
dimensions=(_ROWS, _COLS),
|
||
)
|
||
yield child
|
||
if not child.closed:
|
||
# Drain any stale pending input, send EOF, force-close.
|
||
try:
|
||
child.sendcontrol("d")
|
||
child.expect(pexpect.EOF, timeout=_SHUTDOWN_TIMEOUT)
|
||
except pexpect.TIMEOUT:
|
||
pass
|
||
child.close(force=True)
|
||
|
||
|
||
def test_ctrl_c_clears_input_before_exit(
|
||
ctrl_c_child: pexpect.spawn,
|
||
) -> None:
|
||
"""
|
||
Ctrl+C with text in the input field clears the field; a
|
||
subsequent empty-input Ctrl+C arms the exit hint; a second
|
||
Ctrl+C within the window exits cleanly.
|
||
|
||
Steps:
|
||
|
||
1. Wait for the input marker.
|
||
2. Type ``hello`` — it appears next to the marker.
|
||
3. Press Ctrl+C once — input clears; no exit hint appears.
|
||
4. Press Ctrl+C again — input is empty, so the exit hint
|
||
arms and appears in the toolbar.
|
||
5. Press Ctrl+C a third time — second press within the
|
||
window, REPL exits; driver prints the sentinel.
|
||
|
||
**What breaks if this fails:**
|
||
|
||
- The ``c-c`` binding in TerminalHost.__init__ stops
|
||
checking ``buf.text`` first and always raises
|
||
:class:`KeyboardInterrupt` — step 3 would hard-exit.
|
||
- ``_exit_confirm_deadline`` logic regresses so the hint
|
||
never shows (step 4's toolbar text missing) or the
|
||
second press doesn't exit (step 5's sentinel never
|
||
prints).
|
||
- ``build_toolbar`` stops rendering the confirm hint when
|
||
the deadline is set.
|
||
|
||
:param ctrl_c_child: Fresh PTY-spawned driver.
|
||
"""
|
||
screen = pyte.Screen(_COLS, _ROWS)
|
||
stream = pyte.Stream(screen)
|
||
|
||
# 1. Boot.
|
||
_wait_for_screen(
|
||
ctrl_c_child,
|
||
screen,
|
||
stream,
|
||
"❯",
|
||
timeout=_BOOT_TIMEOUT,
|
||
)
|
||
|
||
# 2. Type some input.
|
||
ctrl_c_child.send("hello")
|
||
_wait_for_screen(
|
||
ctrl_c_child,
|
||
screen,
|
||
stream,
|
||
"hello",
|
||
timeout=_RENDER_TIMEOUT,
|
||
)
|
||
|
||
# 3. First Ctrl+C clears the input. Verify the buffer no
|
||
# longer contains "hello" on the input row. pyte's
|
||
# display is updated-in-place; we look for the marker
|
||
# row and assert "hello" isn't there anymore.
|
||
ctrl_c_child.sendcontrol("c")
|
||
# prompt-toolkit redraws within a frame or two; give it a
|
||
# short drain before the absence check.
|
||
time.sleep(0.3)
|
||
try:
|
||
chunk = ctrl_c_child.read_nonblocking(size=4096, timeout=0.3)
|
||
stream.feed(chunk)
|
||
except pexpect.TIMEOUT:
|
||
pass
|
||
rendered = "\n".join(screen.display)
|
||
# The input-marker row shouldn't have "hello" anymore.
|
||
# A confirm hint shouldn't show yet (buffer had text →
|
||
# binding early-returned before arming).
|
||
marker_rows = [r for r in screen.display if "❯" in r]
|
||
assert marker_rows, "lost track of the input marker row"
|
||
assert "hello" not in "".join(marker_rows), (
|
||
f"first Ctrl+C should clear input text but 'hello' is still on the marker row. "
|
||
f"Rendered:\n{rendered}"
|
||
)
|
||
assert "Press Ctrl+C again" not in rendered, (
|
||
"exit hint appeared on first Ctrl+C with non-empty input; "
|
||
"the binding should have cleared the buffer and NOT armed the hint. "
|
||
f"Rendered:\n{rendered}"
|
||
)
|
||
|
||
# 4. Second Ctrl+C on empty input arms the exit hint.
|
||
# The hint renders in the prompt-toolkit bottom_toolbar
|
||
# which, under pexpect + pyte, doesn't reliably land on
|
||
# the emulated screen (the toolbar uses cursor-save /
|
||
# relative positioning that pyte's minimal emulator
|
||
# doesn't always capture). We assert the BEHAVIOR
|
||
# instead: the child must still be alive after this
|
||
# Ctrl+C (would've exited if the hint-arm path wasn't
|
||
# firing). The first-ctrl-c-in-empty-buffer path MUST
|
||
# NOT exit — if it does, one stray Ctrl+C at any time
|
||
# hard-kills the REPL and the whole two-press contract
|
||
# breaks.
|
||
ctrl_c_child.sendcontrol("c")
|
||
# Short settling drain — give the binding time to run; if
|
||
# it was going to raise, we'd see EOF within this window.
|
||
time.sleep(0.3)
|
||
assert ctrl_c_child.isalive(), (
|
||
"Child exited on a single Ctrl+C with empty buffer — the two-press "
|
||
"contract requires the first empty-input Ctrl+C to ARM the hint, "
|
||
"not exit. Likely regression: the c-c binding is raising "
|
||
"KeyboardInterrupt on first press even when no deadline is set."
|
||
)
|
||
|
||
# 5. Third Ctrl+C within the window exits. The driver
|
||
# prints the sentinel after host.run() returns; we
|
||
# expect(...) on raw bytes (not pyte) since the
|
||
# sentinel goes to stdout AFTER prompt-toolkit has
|
||
# restored the terminal.
|
||
ctrl_c_child.sendcontrol("c")
|
||
ctrl_c_child.expect(_EXIT_SENTINEL, timeout=_RENDER_TIMEOUT)
|