From 52d308c24b13e4f020233b5518bd25299e72827e Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic <44276455+microsasa@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:40:07 -0700 Subject: [PATCH 1/6] test: add direct unit tests for _start_input_reader_thread (#1056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TestStartInputReaderThread class with five isolated tests: - daemon flag assertion (regression guard for issue #1012) - normal input stripped and queued - EOFError → _FALLBACK_EOF sentinel - KeyboardInterrupt → _FALLBACK_EOF sentinel (previously untested) - unexpected exception → _FALLBACK_EOF + warning logged Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/copilot_usage/test_cli.py | 89 +++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index 81a3abc..be827c2 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -24,11 +24,13 @@ from copilot_usage.cli import ( _build_session_index, _DateTimeOrDate, + _FALLBACK_EOF, _normalize_until, _ParsedDateArg, _print_version_header, _read_line_nonblocking, _show_session_by_index, + _start_input_reader_thread, _start_observer, _stop_observer, _validate_since_until, @@ -2169,6 +2171,93 @@ def test_returns_stripped_line(self) -> None: os.close(w_fd) +# --------------------------------------------------------------------------- +# _start_input_reader_thread unit tests (issue #1056) +# --------------------------------------------------------------------------- + + +class TestStartInputReaderThread: + """Direct unit tests for _start_input_reader_thread.""" + + def test_thread_is_daemon(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Spawned thread must be daemon so it cannot block process exit.""" + captured_daemon: list[bool] = [] + _real_thread_init = threading.Thread.__init__ + + def _spy_init(self_thread: threading.Thread, *args: Any, **kwargs: Any) -> None: + _real_thread_init(self_thread, *args, **kwargs) + if self_thread.name == "input-fallback": + captured_daemon.append(self_thread.daemon) + + monkeypatch.setattr(threading.Thread, "__init__", _spy_init) + monkeypatch.setattr( + "builtins.input", lambda: (_ for _ in ()).throw(EOFError()) + ) + + q = _start_input_reader_thread() + q.get(timeout=2.0) # drain sentinel so thread exits + + assert captured_daemon == [True], "input-fallback thread must be daemon=True" + + def test_normal_input_is_stripped_and_queued( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """User input is stripped before being placed on the queue.""" + inputs = iter([" hello ", EOFError()]) + + def _fake_input() -> str: + val = next(inputs) + if isinstance(val, BaseException): + raise val + return val # type: ignore[return-value] + + monkeypatch.setattr("builtins.input", _fake_input) + + q = _start_input_reader_thread() + assert q.get(timeout=2.0) == "hello" + assert q.get(timeout=2.0) == _FALLBACK_EOF + + def test_eoferror_puts_fallback_sentinel( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """EOFError from input() places _FALLBACK_EOF on the queue.""" + monkeypatch.setattr( + "builtins.input", lambda: (_ for _ in ()).throw(EOFError()) + ) + + q = _start_input_reader_thread() + assert q.get(timeout=2.0) == _FALLBACK_EOF + + def test_keyboard_interrupt_puts_fallback_sentinel( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """KeyboardInterrupt from input() places _FALLBACK_EOF on the queue.""" + monkeypatch.setattr( + "builtins.input", lambda: (_ for _ in ()).throw(KeyboardInterrupt()) + ) + + q = _start_input_reader_thread() + assert q.get(timeout=2.0) == _FALLBACK_EOF + + def test_unexpected_exception_puts_fallback_and_logs_warning( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Unexpected exception places _FALLBACK_EOF and logs a warning.""" + monkeypatch.setattr( + "builtins.input", lambda: (_ for _ in ()).throw(RuntimeError("boom")) + ) + + import copilot_usage.cli as cli_mod + + with patch.object(cli_mod.logger, "warning") as warn_spy: + q = _start_input_reader_thread() + sentinel = q.get(timeout=2.0) + + assert sentinel == _FALLBACK_EOF + warn_spy.assert_called_once() + assert "Unexpected stdin error" in warn_spy.call_args.args[0] + + # --------------------------------------------------------------------------- # Gap 3 — _interactive_loop stdin fallback (issue #258) # --------------------------------------------------------------------------- From e2b82209a544781a864eb5ebff792cd0379d81da Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic <44276455+microsasa@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:00:26 -0700 Subject: [PATCH 2/6] fix: resolve CI failures (ruff format / pyright) - Collapse two EOFError monkeypatch.setattr calls to single lines (exactly 88 chars, within ruff format line-length limit) - Remove unnecessary `# type: ignore[return-value]` (isinstance already narrows val to str) - Add `assert warn_spy.call_args is not None` guard before accessing .args to satisfy pyright reportOptionalMemberAccess Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/copilot_usage/test_cli.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index be827c2..79ef0c4 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -2190,9 +2190,7 @@ def _spy_init(self_thread: threading.Thread, *args: Any, **kwargs: Any) -> None: captured_daemon.append(self_thread.daemon) monkeypatch.setattr(threading.Thread, "__init__", _spy_init) - monkeypatch.setattr( - "builtins.input", lambda: (_ for _ in ()).throw(EOFError()) - ) + monkeypatch.setattr("builtins.input", lambda: (_ for _ in ()).throw(EOFError())) q = _start_input_reader_thread() q.get(timeout=2.0) # drain sentinel so thread exits @@ -2209,7 +2207,7 @@ def _fake_input() -> str: val = next(inputs) if isinstance(val, BaseException): raise val - return val # type: ignore[return-value] + return val monkeypatch.setattr("builtins.input", _fake_input) @@ -2221,9 +2219,7 @@ def test_eoferror_puts_fallback_sentinel( self, monkeypatch: pytest.MonkeyPatch ) -> None: """EOFError from input() places _FALLBACK_EOF on the queue.""" - monkeypatch.setattr( - "builtins.input", lambda: (_ for _ in ()).throw(EOFError()) - ) + monkeypatch.setattr("builtins.input", lambda: (_ for _ in ()).throw(EOFError())) q = _start_input_reader_thread() assert q.get(timeout=2.0) == _FALLBACK_EOF @@ -2255,6 +2251,7 @@ def test_unexpected_exception_puts_fallback_and_logs_warning( assert sentinel == _FALLBACK_EOF warn_spy.assert_called_once() + assert warn_spy.call_args is not None assert "Unexpected stdin error" in warn_spy.call_args.args[0] From 1923819b2295a9792b3dd301411e5e8ba7f44860 Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic Date: Wed, 22 Apr 2026 22:27:41 -0700 Subject: [PATCH 3/6] fix: ruff import sort on test_cli.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/copilot_usage/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index 79ef0c4..ac41a41 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -22,9 +22,9 @@ from copilot_usage import __version__ from copilot_usage.cli import ( + _FALLBACK_EOF, _build_session_index, _DateTimeOrDate, - _FALLBACK_EOF, _normalize_until, _ParsedDateArg, _print_version_header, From 1ec6c19422b67a95d263a9dff0280e92da211dcb Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic <44276455+microsasa@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:35:10 -0700 Subject: [PATCH 4/6] fix: address review comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/copilot_usage/test_cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index ac41a41..c3a6d5c 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -9,6 +9,7 @@ import re import threading import time +from collections.abc import Iterator from datetime import UTC, datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -2201,7 +2202,9 @@ def test_normal_input_is_stripped_and_queued( self, monkeypatch: pytest.MonkeyPatch ) -> None: """User input is stripped before being placed on the queue.""" - inputs = iter([" hello ", EOFError()]) + inputs: Iterator[str | BaseException] = iter( + [" hello ", EOFError()] + ) def _fake_input() -> str: val = next(inputs) From 602798fe9fa9b5c09bd65f2778fbafd8c8148478 Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic <44276455+microsasa@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:46:11 -0700 Subject: [PATCH 5/6] fix: collapse multi-line iter() to single line (ruff format) The annotated iter() expression fits within 88 chars (79 + 8 indent = 87), so ruff format expects it on a single line without a trailing comma. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/copilot_usage/test_cli.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index c3a6d5c..a261504 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -2202,9 +2202,7 @@ def test_normal_input_is_stripped_and_queued( self, monkeypatch: pytest.MonkeyPatch ) -> None: """User input is stripped before being placed on the queue.""" - inputs: Iterator[str | BaseException] = iter( - [" hello ", EOFError()] - ) + inputs: Iterator[str | BaseException] = iter([" hello ", EOFError()]) def _fake_input() -> str: val = next(inputs) From 3041b612a1c8e760350a5b2ac09f34ff87bddbfb Mon Sep 17 00:00:00 2001 From: Sasa Junuzovic <44276455+microsasa@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:52:47 -0700 Subject: [PATCH 6/6] fix: address review comments Refactor test_thread_is_daemon to patch copilot_usage.cli.threading.Thread instead of globally patching threading.Thread.__init__. This avoids reliance on the thread name for filtering and minimizes global side effects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/copilot_usage/test_cli.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/copilot_usage/test_cli.py b/tests/copilot_usage/test_cli.py index a261504..7d60303 100644 --- a/tests/copilot_usage/test_cli.py +++ b/tests/copilot_usage/test_cli.py @@ -2182,15 +2182,14 @@ class TestStartInputReaderThread: def test_thread_is_daemon(self, monkeypatch: pytest.MonkeyPatch) -> None: """Spawned thread must be daemon so it cannot block process exit.""" - captured_daemon: list[bool] = [] - _real_thread_init = threading.Thread.__init__ + captured_daemon: list[bool | None] = [] + _real_thread = threading.Thread - def _spy_init(self_thread: threading.Thread, *args: Any, **kwargs: Any) -> None: - _real_thread_init(self_thread, *args, **kwargs) - if self_thread.name == "input-fallback": - captured_daemon.append(self_thread.daemon) + def _spy_thread(*args: Any, **kwargs: Any) -> threading.Thread: + captured_daemon.append(kwargs.get("daemon")) + return _real_thread(*args, **kwargs) - monkeypatch.setattr(threading.Thread, "__init__", _spy_init) + monkeypatch.setattr("copilot_usage.cli.threading.Thread", _spy_thread) monkeypatch.setattr("builtins.input", lambda: (_ for _ in ()).throw(EOFError())) q = _start_input_reader_thread()