From 7619407b6399af876e95a2b74cfceea0231b2451 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 21 Feb 2026 16:36:56 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20self-tdd=20watchdog=20=E2=80=94?= =?UTF-8?q?=20continuous=20test=20runner=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `src/self_tdd/watchdog.py` with a `_run_tests()` function that shells out to pytest and a `watch` command that polls on a configurable interval, printing green on recovery and full short-traceback output on regression. No files are modified and no commits are made automatically. Usage: self-tdd watch # default 60s interval self-tdd watch -i 15 # poll every 15s Also adds 6 unit tests and wires the `self-tdd` entry point + `src/self_tdd` wheel include into pyproject.toml. https://claude.ai/code/session_01DMjQ5qMZ8iHeyix1j3GS7c --- pyproject.toml | 3 +- src/self_tdd/__init__.py | 0 src/self_tdd/watchdog.py | 71 ++++++++++++++++++++++++++++++++++++++++ tests/test_watchdog.py | 54 ++++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 src/self_tdd/__init__.py create mode 100644 src/self_tdd/watchdog.py create mode 100644 tests/test_watchdog.py diff --git a/pyproject.toml b/pyproject.toml index f659f1b3..c2fce351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,10 +31,11 @@ dev = [ [project.scripts] timmy = "timmy.cli:main" +self-tdd = "self_tdd.watchdog:main" [tool.hatch.build.targets.wheel] sources = {"src" = ""} -include = ["src/timmy", "src/dashboard", "src/config.py"] +include = ["src/timmy", "src/dashboard", "src/config.py", "src/self_tdd"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/self_tdd/__init__.py b/src/self_tdd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/self_tdd/watchdog.py b/src/self_tdd/watchdog.py new file mode 100644 index 00000000..c231882c --- /dev/null +++ b/src/self_tdd/watchdog.py @@ -0,0 +1,71 @@ +"""Self-TDD Watchdog — polls pytest on a schedule and reports regressions. + +Run in a terminal alongside your normal dev work: + + self-tdd watch + self-tdd watch --interval 30 + +The watchdog runs silently while tests pass. When a regression appears it +prints the full short-traceback output so you can see exactly what broke. +No files are modified; no commits are made. Ctrl-C to stop. +""" + +import subprocess +import sys +import time +from datetime import datetime +from pathlib import Path + +import typer + +# Project root is three levels up from src/self_tdd/watchdog.py +PROJECT_ROOT = Path(__file__).parent.parent.parent + +app = typer.Typer(help="Self-TDD watchdog — continuous test runner") + + +def _run_tests() -> tuple[bool, str]: + """Run the test suite and return (passed, combined_output).""" + result = subprocess.run( + [sys.executable, "-m", "pytest", "tests/", "-q", "--tb=short"], + capture_output=True, + text=True, + cwd=PROJECT_ROOT, + timeout=60, + ) + return result.returncode == 0, (result.stdout + result.stderr).strip() + + +@app.command() +def watch( + interval: int = typer.Option(60, "--interval", "-i", help="Seconds between test runs"), +) -> None: + """Poll pytest continuously and print regressions as they appear.""" + typer.echo(f"Self-TDD watchdog started — polling every {interval}s. Ctrl-C to stop.") + last_passed: bool | None = None + + try: + while True: + passed, output = _run_tests() + stamp = datetime.now().strftime("%H:%M:%S") + + if passed: + if last_passed is not True: + typer.secho(f"[{stamp}] All tests passing.", fg=typer.colors.GREEN) + else: + typer.secho(f"[{stamp}] Regression detected:", fg=typer.colors.RED) + typer.echo(output) + + last_passed = passed + time.sleep(interval) + + except KeyboardInterrupt: + typer.echo("\nWatchdog stopped.") + + +def main() -> None: + app() + + +if __name__ == "__main__": + main() diff --git a/tests/test_watchdog.py b/tests/test_watchdog.py new file mode 100644 index 00000000..934209c4 --- /dev/null +++ b/tests/test_watchdog.py @@ -0,0 +1,54 @@ +from unittest.mock import MagicMock, patch + +from self_tdd.watchdog import _run_tests + + +def _mock_result(returncode: int, stdout: str = "", stderr: str = "") -> MagicMock: + m = MagicMock() + m.returncode = returncode + m.stdout = stdout + m.stderr = stderr + return m + + +def test_run_tests_returns_true_when_suite_passes(): + with patch("self_tdd.watchdog.subprocess.run", return_value=_mock_result(0, "5 passed")): + passed, _ = _run_tests() + assert passed is True + + +def test_run_tests_returns_false_when_suite_fails(): + with patch("self_tdd.watchdog.subprocess.run", return_value=_mock_result(1, "1 failed")): + passed, _ = _run_tests() + assert passed is False + + +def test_run_tests_output_includes_stdout(): + with patch("self_tdd.watchdog.subprocess.run", return_value=_mock_result(0, stdout="5 passed")): + _, output = _run_tests() + assert "5 passed" in output + + +def test_run_tests_output_combines_stdout_and_stderr(): + with patch( + "self_tdd.watchdog.subprocess.run", + return_value=_mock_result(1, stdout="FAILED test_foo", stderr="ImportError: no module named bar"), + ): + _, output = _run_tests() + assert "FAILED test_foo" in output + assert "ImportError" in output + + +def test_run_tests_invokes_pytest_with_correct_flags(): + with patch("self_tdd.watchdog.subprocess.run", return_value=_mock_result(0)) as mock_run: + _run_tests() + cmd = mock_run.call_args[0][0] + assert "pytest" in cmd + assert "tests/" in cmd + assert "--tb=short" in cmd + + +def test_run_tests_uses_60s_timeout(): + with patch("self_tdd.watchdog.subprocess.run", return_value=_mock_result(0)) as mock_run: + _run_tests() + assert mock_run.call_args.kwargs["timeout"] == 60