a22fbba340
The resize recovery treated the first SIGWINCH of a session as a width change (no prior width to compare against), running the Ctrl+L-style viewport clear + _OUTPUT_HISTORY replay. The 2J clear preserves scrollback, so everything in the deque printed a second copy below the still-visible original. After --continue/--resume the deque holds the whole "Previous Conversation" recap plus the first live exchange, so a benign resize signal (GNOME Terminal tab bar appearing, monitor-scale change, focus events) duplicated the entire conversation. Seed the width baseline when the resize hook is installed, and replay only on an observed width change. The baseline is read from app.output — get_app() at install time is still the DummyApplication whose DummyOutput reports a fake 80 columns, which would turn the first real signal back into a phantom width change. A real initial maximize or restore still differs from the seeded width and is still recovered (#49120 behavior preserved; verified in a pty harness both ways). Fixes #65293
261 lines
10 KiB
Python
261 lines
10 KiB
Python
"""Tests for CLI redraw helpers used to recover from terminal buffer drift.
|
|
|
|
Covers:
|
|
- _force_full_redraw (#8688 cmux tab switch, /redraw, Ctrl+L)
|
|
- the resize handler we install over prompt_toolkit's _on_resize (#5474)
|
|
|
|
Both behaviors are exercised against fake prompt_toolkit renderer/output
|
|
objects — we're asserting the escape sequences the CLI sends, not that
|
|
the terminal physically repainted.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
import cli as cli_mod
|
|
from cli import HermesCLI
|
|
|
|
|
|
@pytest.fixture
|
|
def bare_cli():
|
|
"""A HermesCLI with no __init__ — we only exercise the redraw helper."""
|
|
cli = object.__new__(HermesCLI)
|
|
return cli
|
|
|
|
|
|
class TestForceFullRedraw:
|
|
def test_no_app_is_safe(self, bare_cli):
|
|
# _force_full_redraw must be a no-op when the TUI isn't running.
|
|
bare_cli._app = None
|
|
bare_cli._force_full_redraw() # must not raise
|
|
|
|
|
|
|
|
|
|
def test_resize_recovery_clears_viewport_on_width_change(self, bare_cli, monkeypatch):
|
|
"""A WIDTH change must wipe the visible viewport (CSI 2J) and replay.
|
|
|
|
On column shrink the terminal reflows the old full-width chrome into
|
|
extra rows that prompt_toolkit's stale-cursor erase cannot reach,
|
|
leaving a duplicated status bar (#19280/#5474 class). We route through
|
|
the same recovery as Ctrl+L: erase_screen (2J) + replay transcript.
|
|
It must be banner-safe — CSI 3J (write_raw) must NOT fire.
|
|
"""
|
|
app = MagicMock()
|
|
events = []
|
|
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
|
|
app.renderer.output.write_raw.side_effect = lambda *_: events.append("scrollback_wipe")
|
|
original_on_resize = lambda: events.append("original_resize")
|
|
|
|
bare_cli._status_bar_suppressed_after_resize = False
|
|
bare_cli._last_resize_width = 200
|
|
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90)
|
|
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
|
|
monkeypatch.setattr(cli_mod, "_replay_output_history", lambda: events.append("replay"))
|
|
|
|
bare_cli._recover_after_resize(app, original_on_resize)
|
|
|
|
# Viewport cleared and transcript replayed BEFORE prompt_toolkit's resize.
|
|
assert "erase" in events
|
|
assert "replay" in events
|
|
assert events.index("erase") < events.index("original_resize")
|
|
# Banner-safe: scrollback (CSI 3J) must never be wiped on a resize.
|
|
assert "scrollback_wipe" not in events
|
|
# New width recorded for the next comparison.
|
|
assert bare_cli._last_resize_width == 90
|
|
assert bare_cli._status_bar_suppressed_after_resize is True
|
|
|
|
|
|
def test_resize_recovery_is_debounced(self, bare_cli, monkeypatch):
|
|
timers = []
|
|
calls = []
|
|
|
|
class FakeTimer:
|
|
def __init__(self, delay, callback):
|
|
self.delay = delay
|
|
self.callback = callback
|
|
self.cancelled = False
|
|
self.daemon = False
|
|
timers.append(self)
|
|
|
|
def start(self):
|
|
calls.append(("start", self.delay))
|
|
|
|
def cancel(self):
|
|
self.cancelled = True
|
|
calls.append(("cancel", self.delay))
|
|
|
|
def fire(self):
|
|
self.callback()
|
|
|
|
app = MagicMock()
|
|
app.loop.call_soon_threadsafe.side_effect = lambda cb: cb()
|
|
monkeypatch.setattr(cli_mod.threading, "Timer", FakeTimer)
|
|
monkeypatch.setattr(
|
|
bare_cli,
|
|
"_recover_after_resize",
|
|
lambda _app, _orig: calls.append(("recover", _orig())),
|
|
)
|
|
|
|
original_one = lambda: "first"
|
|
original_two = lambda: "second"
|
|
|
|
bare_cli._schedule_resize_recovery(app, original_one, delay=0.25)
|
|
assert bare_cli._resize_recovery_pending is True
|
|
bare_cli._schedule_resize_recovery(app, original_two, delay=0.25)
|
|
|
|
assert len(timers) == 2
|
|
assert timers[0].cancelled is True
|
|
timers[0].fire()
|
|
assert ("recover", "first") not in calls
|
|
|
|
timers[1].fire()
|
|
assert ("recover", "second") in calls
|
|
assert bare_cli._resize_recovery_pending is False
|
|
|
|
def test_invalidate_is_suppressed_while_resize_recovery_is_pending(self, bare_cli):
|
|
app = MagicMock()
|
|
bare_cli._app = app
|
|
bare_cli._last_invalidate = 0.0
|
|
bare_cli._resize_recovery_pending = True
|
|
|
|
bare_cli._invalidate(min_interval=0)
|
|
|
|
app.invalidate.assert_not_called()
|
|
|
|
def test_swallows_renderer_exceptions(self, bare_cli):
|
|
# If the renderer blows up for any reason, the helper must not
|
|
# propagate — otherwise a stray Ctrl+L would crash the CLI.
|
|
app = MagicMock()
|
|
app.renderer.output.erase_screen.side_effect = RuntimeError("boom")
|
|
bare_cli._app = app
|
|
|
|
bare_cli._force_full_redraw() # must not raise
|
|
|
|
# invalidate() is still attempted after a renderer failure.
|
|
app.invalidate.assert_called_once()
|
|
|
|
def test_swallows_invalidate_exceptions(self, bare_cli):
|
|
app = MagicMock()
|
|
app.invalidate.side_effect = RuntimeError("boom")
|
|
bare_cli._app = app
|
|
|
|
bare_cli._force_full_redraw() # must not raise
|
|
|
|
|
|
class TestFirstSigwinchBaseline:
|
|
"""Bug #65293: the session's FIRST SIGWINCH used to be force-treated as a
|
|
width change (no prior width to compare against), so a benign resize
|
|
signal — GNOME Terminal tab bar appearing, monitor-scale change, focus
|
|
events — cleared the viewport and replayed ``_OUTPUT_HISTORY``. After a
|
|
resume that deque holds the whole "Previous Conversation" recap plus the
|
|
first live exchange, so everything reprinted as a duplicate. A replay
|
|
must require an OBSERVED width change against a recorded baseline.
|
|
"""
|
|
|
|
def test_first_sigwinch_with_unchanged_width_does_not_replay(
|
|
self, bare_cli, monkeypatch
|
|
):
|
|
app = MagicMock()
|
|
events = []
|
|
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
|
|
original_on_resize = lambda: events.append("original_resize")
|
|
|
|
bare_cli._status_bar_suppressed_after_resize = False
|
|
# No baseline recorded yet — the pre-fix code forced width_changed=True.
|
|
assert getattr(bare_cli, "_last_resize_width", None) is None
|
|
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 120)
|
|
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
|
|
monkeypatch.setattr(
|
|
cli_mod, "_replay_output_history", lambda: events.append("replay")
|
|
)
|
|
|
|
bare_cli._recover_after_resize(app, original_on_resize)
|
|
|
|
# Width did not change — no clear, no replay, straight to prompt_toolkit.
|
|
assert events == ["original_resize"]
|
|
app.renderer.output.erase_screen.assert_not_called()
|
|
# The signal still records the baseline for the next comparison.
|
|
assert bare_cli._last_resize_width == 120
|
|
|
|
def test_real_width_change_after_baseline_still_replays(
|
|
self, bare_cli, monkeypatch
|
|
):
|
|
"""The #49120 recovery (2J + replay) must still fire on a real change."""
|
|
app = MagicMock()
|
|
events = []
|
|
app.renderer.output.erase_screen.side_effect = lambda: events.append("erase")
|
|
original_on_resize = lambda: events.append("original_resize")
|
|
|
|
bare_cli._status_bar_suppressed_after_resize = False
|
|
bare_cli._last_resize_width = 120
|
|
monkeypatch.setattr(bare_cli, "_get_tui_terminal_width", lambda: 90)
|
|
monkeypatch.setattr(bare_cli, "_schedule_status_bar_unsuppress", lambda *_: None)
|
|
monkeypatch.setattr(
|
|
cli_mod, "_replay_output_history", lambda: events.append("replay")
|
|
)
|
|
|
|
bare_cli._recover_after_resize(app, original_on_resize)
|
|
|
|
assert "erase" in events and "replay" in events
|
|
assert bare_cli._last_resize_width == 90
|
|
|
|
def test_install_resize_recovery_seeds_width_baseline(self, bare_cli):
|
|
"""Hook installation records the CURRENT width as the baseline, so an
|
|
initial maximize/restore (a real change vs that baseline) is still
|
|
recovered while a same-size first signal is not.
|
|
|
|
The baseline must come from ``app.output`` — the same object the
|
|
running app measures on SIGWINCH — not from ``get_app()``, which
|
|
before ``app.run()`` is a DummyApplication reporting a fake 80 cols.
|
|
"""
|
|
app = MagicMock()
|
|
app.output.get_size.return_value.columns = 132
|
|
scheduled = []
|
|
bare_cli._schedule_resize_recovery = lambda *a, **k: scheduled.append(a)
|
|
|
|
original = app._on_resize
|
|
bare_cli._install_resize_recovery(app)
|
|
|
|
assert bare_cli._last_resize_width == 132
|
|
assert app._on_resize is not original # hook installed
|
|
app._on_resize() # simulated SIGWINCH → routes to the debouncer
|
|
assert len(scheduled) == 1
|
|
assert scheduled[0][0] is app
|
|
assert scheduled[0][1] is original
|
|
|
|
def test_install_resize_recovery_falls_back_to_shutil(
|
|
self, bare_cli, monkeypatch
|
|
):
|
|
"""A dead app.output probe falls back to shutil, never to the
|
|
DummyApplication's fake width."""
|
|
import os as os_mod
|
|
|
|
app = MagicMock()
|
|
app.output.get_size.side_effect = RuntimeError("not attached")
|
|
monkeypatch.setattr(
|
|
cli_mod.shutil,
|
|
"get_terminal_size",
|
|
lambda _default: os_mod.terminal_size((97, 40)),
|
|
)
|
|
|
|
bare_cli._install_resize_recovery(app)
|
|
|
|
assert bare_cli._last_resize_width == 97
|
|
|
|
def test_install_resize_recovery_survives_width_probe_failure(
|
|
self, bare_cli, monkeypatch
|
|
):
|
|
app = MagicMock()
|
|
app.output.get_size.side_effect = RuntimeError("not attached")
|
|
|
|
def _boom(_default):
|
|
raise RuntimeError("no tty")
|
|
|
|
monkeypatch.setattr(cli_mod.shutil, "get_terminal_size", _boom)
|
|
|
|
bare_cli._install_resize_recovery(app) # must not raise
|
|
|
|
assert getattr(bare_cli, "_last_resize_width", None) is None
|