feat(certify): enhance test detection, running, and CI config checks

- Add language detection to condition test existence checks
- Improve C/C++ test running: try make test/check, fallback to ctest
- Run Python tests only if tests are detected to avoid false failures
- Update no-test-runner message for clarity
- Add support for Gitea workflows in CI config detection
- chore: ignore .venv in .gitignore
This commit is contained in:
Greg Gauthier 2026-04-03 11:34:02 +01:00
parent 26b9299d44
commit 7c5edce532
2 changed files with 101 additions and 51 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
.idea/ .idea/
.venv/
poetry.lock poetry.lock
**/__pycache__/ **/__pycache__/

View File

@ -295,11 +295,13 @@ def _is_visible(f: Path) -> bool:
def _check_tests_exist(path: Path) -> CheckResult: def _check_tests_exist(path: Path) -> CheckResult:
langs = _detect_languages(path)
def rglob_visible(pattern: str) -> list[Path]: def rglob_visible(pattern: str) -> list[Path]:
return [f for f in path.rglob(pattern) if _is_visible(f)] return [f for f in path.rglob(pattern) if _is_visible(f)]
# Python # Python
python_test = ( python_test = "python" in langs and (
(path / "tests").is_dir() (path / "tests").is_dir()
or (path / "test").is_dir() or (path / "test").is_dir()
or bool(rglob_visible("test_*.py")) or bool(rglob_visible("test_*.py"))
@ -307,7 +309,7 @@ def _check_tests_exist(path: Path) -> CheckResult:
or bool(rglob_visible("tests.py")) or bool(rglob_visible("tests.py"))
) )
# JavaScript / TypeScript # JavaScript / TypeScript
js_test = ( js_test = ("javascript" in langs or "typescript" in langs) and (
any( any(
f for f in path.rglob("*") f for f in path.rglob("*")
if f.is_file() and _is_visible(f) if f.is_file() and _is_visible(f)
@ -320,31 +322,34 @@ def _check_tests_exist(path: Path) -> CheckResult:
) )
) )
# Go # Go
go_test = bool(rglob_visible("*_test.go")) go_test = "go" in langs and bool(rglob_visible("*_test.go"))
# Rust — tests/ subdir or inline test modules # Rust — tests/ subdir or inline test modules
rust_test = bool( rust_test = "rust" in langs and (
any(
f for f in path.rglob("*.rs") f for f in path.rglob("*.rs")
if f.is_file() and _is_visible(f) and "tests" in f.parts if f.is_file() and _is_visible(f) and "tests" in f.parts
) or ( )
or (
(path / "tests").is_dir() (path / "tests").is_dir()
and any((path / "tests").rglob("*.rs")) and any((path / "tests").rglob("*.rs"))
) )
)
# Java # Java
java_test = ( java_test = "java" in langs and (
(path / "src" / "test").exists() (path / "src" / "test").exists()
or bool(rglob_visible("*Test.java")) or bool(rglob_visible("*Test.java"))
or bool(rglob_visible("*Tests.java")) or bool(rglob_visible("*Tests.java"))
or bool(rglob_visible("*Spec.java")) or bool(rglob_visible("*Spec.java"))
) )
# Ruby # Ruby
ruby_test = ( ruby_test = "ruby" in langs and (
(path / "spec").is_dir() (path / "spec").is_dir()
or bool(rglob_visible("*_spec.rb")) or bool(rglob_visible("*_spec.rb"))
or bool(rglob_visible("*_test.rb")) or bool(rglob_visible("*_test.rb"))
) )
# C / C++ — Makefile with a 'test' target, or conventional test file patterns # C / C++ — Makefile with a 'test' target, or conventional test file patterns
_makefiles = [path / name for name in ("Makefile", "GNUmakefile", "makefile")] _makefiles = [path / name for name in ("Makefile", "GNUmakefile", "makefile")]
c_test = ( c_test = ("c" in langs or "cpp" in langs) and (
any(m.exists() for m in _makefiles) any(m.exists() for m in _makefiles)
and ( and (
bool(rglob_visible("test_*.c")) bool(rglob_visible("test_*.c"))
@ -463,28 +468,65 @@ def _check_tests_pass(path: Path) -> CheckResult:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
failed_runners.append("Gradle: timed out") failed_runners.append("Gradle: timed out")
# --- C / C++ (make test) --- # --- C / C++ (make test → make check → ctest) ---
_makefiles = [path / name for name in ("Makefile", "GNUmakefile", "makefile")] _makefiles = [path / name for name in ("Makefile", "GNUmakefile", "makefile")]
_langs = _detect_languages(path) _langs = _detect_languages(path)
if ("c" in _langs or "cpp" in _langs) and any(m.exists() for m in _makefiles): if "c" in _langs or "cpp" in _langs:
_c_ran = False
# Try make targets first
if any(m.exists() for m in _makefiles):
for make_target in ("test", "check"):
try: try:
r = subprocess.run( r = subprocess.run(
["make", "test"], ["make", make_target],
cwd=path, capture_output=True, text=True, timeout=120, cwd=path, capture_output=True, text=True, timeout=120,
) )
if r.returncode == 0: if r.returncode == 0:
passed_runners.append("C/C++ (make test)") passed_runners.append(f"C/C++ (make {make_target})")
_c_ran = True
break
elif "No rule to make target" in (r.stdout + r.stderr): elif "No rule to make target" in (r.stdout + r.stderr):
pass # no test target — skip gracefully continue # target not found — try next
else:
snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
failed_runners.append(f"C/C++: {snippet}")
_c_ran = True
break
except FileNotFoundError:
break # make not installed — skip
except subprocess.TimeoutExpired:
failed_runners.append(f"C/C++: make {make_target} timed out")
_c_ran = True
break
# Fall back to ctest (CMake projects)
if not _c_ran and (path / "CMakeLists.txt").exists():
try:
r = subprocess.run(
["ctest", "--test-dir", "build", "--output-on-failure"],
cwd=path, capture_output=True, text=True, timeout=120,
)
output = r.stdout + r.stderr
if "No tests were found" in output:
pass # ctest found nothing — skip
elif r.returncode == 0:
passed_runners.append("C/C++ (ctest)")
else: else:
snippet = (r.stdout + r.stderr).strip().split("\n")[-1] snippet = (r.stdout + r.stderr).strip().split("\n")[-1]
failed_runners.append(f"C/C++: {snippet}") failed_runners.append(f"C/C++: {snippet}")
except FileNotFoundError: except FileNotFoundError:
pass # make not installed — skip pass # ctest not installed — skip
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
failed_runners.append("C/C++: make test timed out") failed_runners.append("C/C++: ctest timed out")
# --- Python (pytest, then unittest) --- # --- Python (pytest, then unittest) ---
_py_has_tests = "python" in _langs and (
(path / "tests").is_dir()
or (path / "test").is_dir()
or any(f for f in path.rglob("test_*.py") if _is_visible(f))
or any(f for f in path.rglob("*_test.py") if _is_visible(f))
or any(f for f in path.rglob("tests.py") if _is_visible(f))
)
if _py_has_tests:
for cmd in ( for cmd in (
["python", "-m", "pytest", "-x", "-q"], ["python", "-m", "pytest", "-x", "-q"],
["python", "-m", "unittest", "discover", "-s", "."], ["python", "-m", "unittest", "discover", "-s", "."],
@ -523,7 +565,7 @@ def _check_tests_pass(path: Path) -> CheckResult:
return CheckResult( return CheckResult(
name="Tests pass", name="Tests pass",
passed=False, passed=False,
message="Could not find a test runner. Neither pytest nor unittest obliged.", message="No test runner found. If tests exist, they're keeping a low profile.",
level=Level.SILVER, level=Level.SILVER,
) )
@ -583,7 +625,14 @@ def _check_ci_config_exists(path: Path) -> CheckResult:
path / ".travis.yml", path / ".travis.yml",
path / "azure-pipelines.yml", path / "azure-pipelines.yml",
] ]
found = any(p.exists() for p in ci_paths) gitea_workflows = path / ".gitea" / "workflows"
found = (
any(p.exists() for p in ci_paths)
or (
gitea_workflows.is_dir()
and any(gitea_workflows.glob("*.y*ml"))
)
)
return CheckResult( return CheckResult(
name="CI configuration exists", name="CI configuration exists",
passed=found, passed=found,