1
0

feat: add self-tdd watchdog — continuous test runner CLI

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
This commit is contained in:
Claude
2026-02-21 16:36:56 +00:00
parent 982c42ba45
commit 7619407b63
4 changed files with 127 additions and 1 deletions

View File

@@ -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"]

0
src/self_tdd/__init__.py Normal file
View File

71
src/self_tdd/watchdog.py Normal file
View File

@@ -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()

54
tests/test_watchdog.py Normal file
View File

@@ -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