Files
haikdcandGitHub 9c95342704 Haik/feat/test runner (#86)
* [haik]: ckpt, added in the test runner skelton i made in another repo -> still gotta make tweaks to fold it onto the current repo

* [haik]: restructure backend test layout: move 37 test files from backend/tests/ into backend/tests/test_cases/, add backend/tests/run.sh one-command launcher that auto-provisions both runner and test venvs with stamp-based caching, update runner config.json to point test_paths at test_cases/ and fix venv_python path, revise runner README with run.sh usage and clearer setup instructions, and gitignore the .runner-venv directory

* [haik]: refactor: reorganize backend/tests/test_cases from flat structure into domain subdirectories — moved 36 test files into auth/, browser/, labeling/, service/, settings/, and web_search/ for better discoverability and grouping

* [haik]: overhaul test picker UI and add post-run rerun loop: replace checkbox glyphs with fzf-style row recolouring (coral=full, lighter coral=partial) and a right-pinned selection dot, add config-driven icon tiers (nerd/emoji/unicode/ascii) with per-glyph graceful degradation, replace the modal -k keyword screen with an inline live-filtering search bar that prunes the tree on every keystroke, add warm Anthropic-dark coral theme, toolbar flag chips replacing the old status line, and a floating help badge overlay; add rerun_prompt.py with inline Textual pill prompt (rerun all/failed/passed/exit) shown after each TTY run, wire it into main.py as a post-run loop; change run_tests to return (exit_code, RunSummary) tracking collected/passed/failed node IDs via new Dashboard methods; add icons field to config.py and config.json (set to nerd), document icon tiers and Nerd Font setup in README, set Hack Nerd Font in .vscode/settings.json, add .runner-venv to linter excludes
2026-06-14 07:02:30 -07:00

172 lines
5.3 KiB
Python

"""Subprocess entry point that runs pytest in the *test* venv.
Spawned by the parent runner as::
<venv_python> -m tests.runner._worker <event_fd> <options_json>
with ``cwd`` set to the repo root (PEP 420 makes ``tests.runner`` importable
without an ``__init__.py``, the same trick discovery uses). It registers a thin
pytest plugin that frames every collection/run event as JSON onto ``event_fd``,
optionally streams ``-s`` output through a gutter shim onto the same FD, and (for
``--cov``) measures coverage and emits the computed rows. The parent owns all
Rich rendering — this process imports neither rich nor textual.
"""
from __future__ import annotations
import io
import json
import os
import sys
from io import StringIO
import pytest
from tests.runner import events
class _GutterStream:
"""stdout/stderr shim used in ``-s`` mode.
Buffers writes and forwards each completed line as an OUTPUT event so the
parent can rail it under the current test's header. Mirrors the old
in-process shim, but emits framed events instead of printing.
"""
def __init__(self, fd: int) -> None:
self._fd = fd
self._buf = ""
def write(self, s) -> int:
if isinstance(s, bytes):
s = s.decode("utf-8", "replace")
self._buf += s
while "\n" in self._buf:
line, self._buf = self._buf.split("\n", 1)
events.emit(self._fd, {"type": events.OUTPUT, "line": line})
return len(s)
def flush(self) -> None:
if self._buf:
events.emit(self._fd, {"type": events.OUTPUT, "line": self._buf})
self._buf = ""
def isatty(self) -> bool:
return False
def writable(self) -> bool:
return True
def fileno(self): # some libraries probe this; signal "not a real fd"
raise io.UnsupportedOperation("fileno")
class _EventEmitter:
"""pytest plugin: forward collection/run hooks as framed events."""
def __init__(self, fd: int) -> None:
self._fd = fd
def pytest_collection_finish(self, session) -> None:
events.emit(
self._fd,
{"type": events.COLLECTION, "items": [item.nodeid for item in session.items]},
)
def pytest_runtest_logstart(self, nodeid, location) -> None:
events.emit(self._fd, {"type": events.LOGSTART, "nodeid": nodeid})
def pytest_runtest_logreport(self, report) -> None:
events.emit(
self._fd,
{
"type": events.LOGREPORT,
"nodeid": report.nodeid,
"when": report.when,
"passed": bool(report.passed),
"failed": bool(report.failed),
"skipped": bool(report.skipped),
"duration": float(getattr(report, "duration", 0.0) or 0.0),
"capstdout": report.capstdout or "",
"capstderr": report.capstderr or "",
"longreprtext": report.longreprtext or "",
},
)
def pytest_runtest_logfinish(self, nodeid, location) -> None:
events.emit(self._fd, {"type": events.LOGFINISH, "nodeid": nodeid})
def _emit_coverage(fd: int, cov, coverage_source: list[str], repo_root: str) -> None:
"""Compute the per-file coverage table and emit it (rendering is the parent's job)."""
try:
cov.stop()
cov.save()
total = cov.report(file=StringIO())
except Exception as exc: # pragma: no cover - defensive
events.emit(fd, {"type": events.COVERAGE, "error": str(exc)})
return
needles = [f"{os.sep}{name}{os.sep}" for name in coverage_source]
data = cov.get_data()
rows = []
for path in data.measured_files():
if not any(n in path for n in needles):
continue
try:
_, statements, _, missing, _ = cov.analysis2(path)
except Exception:
continue
n = len(statements)
if n == 0:
continue
miss = len(missing)
pct = (n - miss) / n * 100
rel = os.path.relpath(path, repo_root)
rows.append([rel, n, miss, pct])
events.emit(fd, {"type": events.COVERAGE, "rows": rows, "total": float(total)})
def main(argv: list[str]) -> int:
fd = int(argv[0])
opts = json.loads(argv[1])
pytest_args = opts.get("pytest_args", [])
no_capture = bool(opts.get("no_capture"))
cov = bool(opts.get("cov"))
coverage_source = opts.get("coverage_source") or []
repo_root = opts.get("repo_root") or os.getcwd()
cov_obj = None
if cov:
import coverage
cov_obj = coverage.Coverage(source=coverage_source)
cov_obj.start()
emitter = _EventEmitter(fd)
args = ["-o", "addopts=", "-p", "no:terminal", *pytest_args]
saved_out, saved_err = sys.stdout, sys.stderr
if no_capture:
sys.stdout = _GutterStream(fd)
sys.stderr = _GutterStream(fd)
try:
code = int(pytest.main(args, plugins=[emitter]))
finally:
if no_capture:
sys.stdout.flush()
sys.stderr.flush()
sys.stdout, sys.stderr = saved_out, saved_err
if cov_obj is not None:
_emit_coverage(fd, cov_obj, coverage_source, repo_root)
events.emit(fd, {"type": events.DONE, "code": code})
return code
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))