""" Tests for CI validation pipeline (#289). Proves the repo-native validation catches broken shell, Python, JSON, YAML, and cron files before they reach main. """ import json import subprocess import sys import tempfile from pathlib import Path import pytest REPO_ROOT = Path(__file__).parent.parent class TestShellValidation: def test_bash_n_catches_syntax_error(self): """bash -n must reject a script with unmatched fi.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f: f.write("#!/bin/bash\nif true; then\n echo ok\nfi\nfi\n") f.flush() result = subprocess.run( ["bash", "-n", f.name], capture_output=True, text=True, ) Path(f.name).unlink() assert result.returncode != 0, "bash -n should fail on unmatched fi" def test_bash_n_accepts_valid_script(self): """bash -n must accept a well-formed script.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f: f.write("#!/bin/bash\nset -euo pipefail\necho hello\n") f.flush() result = subprocess.run( ["bash", "-n", f.name], capture_output=True, text=True, ) Path(f.name).unlink() assert result.returncode == 0, f"bash -n should pass: {result.stderr}" class TestPythonValidation: def test_py_compile_catches_syntax_error(self): """python3 -m py_compile must reject invalid Python.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: f.write("def foo():\n pass\n invalid_indent\n") f.flush() result = subprocess.run( [sys.executable, "-m", "py_compile", f.name], capture_output=True, text=True, ) Path(f.name).unlink() assert result.returncode != 0, "py_compile should fail on bad indent" def test_py_compile_accepts_valid_python(self): """python3 -m py_compile must accept well-formed Python.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: f.write("def hello():\n return 'world'\n") f.flush() result = subprocess.run( [sys.executable, "-m", "py_compile", f.name], capture_output=True, text=True, ) Path(f.name).unlink() assert result.returncode == 0, f"py_compile should pass: {result.stderr}" class TestJsonValidation: def test_json_tool_catches_trailing_comma(self): """python3 -m json.tool must reject invalid JSON.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write('{"a": 1,}') f.flush() result = subprocess.run( [sys.executable, "-m", "json.tool", f.name], capture_output=True, text=True, ) Path(f.name).unlink() assert result.returncode != 0, "json.tool should fail on trailing comma" def test_json_tool_accepts_valid_json(self): """python3 -m json.tool must accept well-formed JSON.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: f.write('{"a": 1, "b": [true, null]}') f.flush() result = subprocess.run( [sys.executable, "-m", "json.tool", f.name], capture_output=True, text=True, ) Path(f.name).unlink() assert result.returncode == 0, f"json.tool should pass: {result.stderr}" class TestYamlValidation: def test_yaml_safe_load_catches_bad_indent(self): """yaml.safe_load must reject invalid YAML.""" import yaml bad = "key:\n sub: 1\n bad_indent: 2\n" with pytest.raises(yaml.YAMLError): yaml.safe_load(bad) def test_yaml_safe_load_accepts_valid_yaml(self): """yaml.safe_load must accept well-formed YAML.""" import yaml good = "key:\n sub: 1\n" data = yaml.safe_load(good) assert data == {"key": {"sub": 1}} class TestCronValidation: def test_cron_jobs_json_schema(self): """cron/jobs.json must be valid JSON with required top-level keys.""" jobs_path = REPO_ROOT / "cron" / "jobs.json" assert jobs_path.exists(), "cron/jobs.json must exist" with open(jobs_path) as f: data = json.load(f) assert "jobs" in data, "cron/jobs.json must have 'jobs' key" assert isinstance(data["jobs"], list), "jobs must be a list" def test_cron_crontab_syntax(self): """All .crontab files must have at least 6 fields per active line.""" crontabs = list(REPO_ROOT.glob("cron/**/*.crontab")) if not crontabs: return for path in crontabs: with open(path) as f: for line_num, line in enumerate(f, 1): line = line.strip() if not line or line.startswith("#"): continue fields = len(line.split()) assert fields >= 6, f"{path}:{line_num} has only {fields} fields: {line}" class TestRepoNativeValidation: def test_all_shell_scripts_parse(self): """Every .sh file in the repo must pass bash -n.""" scripts = list(REPO_ROOT.rglob("*.sh")) assert len(scripts) > 0, "repo must contain shell scripts" failures = [] for path in scripts: if ".git" in str(path): continue result = subprocess.run( ["bash", "-n", str(path)], capture_output=True, text=True, ) if result.returncode != 0: failures.append(f"{path}: {result.stderr.strip()}") assert not failures, f"bash -n failures: {failures}" def test_all_python_scripts_compile(self): """Every .py file in the repo must pass py_compile.""" scripts = list(REPO_ROOT.rglob("*.py")) assert len(scripts) > 0, "repo must contain Python files" failures = [] for path in scripts: if ".git" in str(path): continue result = subprocess.run( [sys.executable, "-m", "py_compile", str(path)], capture_output=True, text=True, ) if result.returncode != 0: failures.append(f"{path}: {result.stderr.strip()}") assert not failures, f"py_compile failures: {failures}" def test_all_json_files_parse(self): """Every .json file in the repo must load as JSON.""" files = list(REPO_ROOT.rglob("*.json")) assert len(files) > 0, "repo must contain JSON files" failures = [] for path in files: if ".git" in str(path): continue try: with open(path) as f: json.load(f) except json.JSONDecodeError as e: failures.append(f"{path}: {e}") assert not failures, f"JSON parse failures: {failures}" def test_all_yaml_files_parse(self): """Every .yaml/.yml file (except .gitea/workflows) must load as YAML.""" import yaml files = list(REPO_ROOT.rglob("*.yaml")) + list(REPO_ROOT.rglob("*.yml")) files = [p for p in files if ".gitea/workflows" not in str(p)] assert len(files) > 0, "repo must contain YAML files" failures = [] for path in files: if ".git" in str(path): continue try: with open(path) as f: yaml.safe_load(f) except yaml.YAMLError as e: failures.append(f"{path}: {e}") assert not failures, f"YAML parse failures: {failures}" if __name__ == "__main__": import pytest pytest.main([__file__, "-v"])