"""Tests for the Quality Gate modules. Tests for: - ci_automation_gate.py: linting, function length, auto-fix, counters - task_gate.py: pre/post task gate logic, lane checking, filter tags Refs: #629 """ import json import os import sys import tempfile from pathlib import Path from unittest.mock import patch, MagicMock # Add scripts/ to path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) from ci_automation_gate import QualityGate # =========================================================================== # CI AUTOMATION GATE TESTS # =========================================================================== # -- helpers --------------------------------------------------------------- def _write_file(dirpath, relpath, content): """Write a file in a temp directory and return its Path.""" p = Path(dirpath) / relpath p.parent.mkdir(parents=True, exist_ok=True) p.write_text(content) return p def _run_gate_on_file(dirpath, relpath, content, fix=False): """Write a file, run QualityGate on it, return the gate instance.""" p = _write_file(dirpath, relpath, content) gate = QualityGate(fix=fix) gate.check_file(p) return gate # -- trailing whitespace --------------------------------------------------- def test_trailing_whitespace_warns(): """Lines with trailing whitespace should produce a warning.""" with tempfile.TemporaryDirectory() as tmp: gate = _run_gate_on_file(tmp, "test.py", "x = 1 \ny = 2\n") assert gate.warnings >= 1, "Expected warning for trailing whitespace" def test_trailing_whitespace_fixes(): """With fix=True, trailing whitespace should be removed.""" with tempfile.TemporaryDirectory() as tmp: p = _write_file(tmp, "test.py", "x = 1 \ny = 2\n") gate = QualityGate(fix=True) gate.check_file(p) fixed = p.read_text() assert "x = 1 \n" not in fixed, "Trailing whitespace should be removed" assert fixed == "x = 1\ny = 2\n" def test_clean_file_no_warnings(): """A clean file should produce no warnings.""" with tempfile.TemporaryDirectory() as tmp: gate = _run_gate_on_file(tmp, "test.py", "x = 1\ny = 2\n") assert gate.warnings == 0 assert gate.failures == 0 # -- missing final newline ------------------------------------------------- def test_missing_final_newline_warns(): """File without trailing newline should warn.""" with tempfile.TemporaryDirectory() as tmp: gate = _run_gate_on_file(tmp, "test.py", "x = 1") assert gate.warnings >= 1, "Expected warning for missing final newline" def test_missing_final_newline_fixed(): """With fix=True, missing final newline should be added.""" with tempfile.TemporaryDirectory() as tmp: p = _write_file(tmp, "test.py", "x = 1") gate = QualityGate(fix=True) gate.check_file(p) fixed = p.read_text() assert fixed.endswith("\n"), "Fixed file should end with newline" # -- function length (JS/TS) ----------------------------------------------- def test_short_function_passes(): """A short JS function should not warn or fail.""" with tempfile.TemporaryDirectory() as tmp: code = "function hello() {\n return 1;\n}\n" gate = _run_gate_on_file(tmp, "test.js", code) assert gate.failures == 0 assert gate.warnings == 0 def test_medium_function_warns(): """JS function over 20 lines should warn.""" body = "\n".join(f" console.log({i});" for i in range(22)) code = f"function big() {{\n{body}\n}}\n" with tempfile.TemporaryDirectory() as tmp: gate = _run_gate_on_file(tmp, "test.js", code) assert gate.warnings >= 1, "Expected warning for function over 20 lines" def test_long_function_fails(): """JS function over 50 lines should fail.""" body = "\n".join(f" console.log({i});" for i in range(52)) code = f"function huge() {{\n{body}\n}}\n" with tempfile.TemporaryDirectory() as tmp: gate = _run_gate_on_file(tmp, "test.js", code) assert gate.failures >= 1, "Expected failure for function over 50 lines" def test_python_function_length_not_checked(): """Python functions should not be checked by the JS regex.""" body = "\n".join(f" print({i})" for i in range(60)) code = f"def huge():\n{body}\n" with tempfile.TemporaryDirectory() as tmp: gate = _run_gate_on_file(tmp, "test.py", code) assert gate.failures == 0, "Python functions should not trigger JS length check" # -- file type filtering --------------------------------------------------- def test_non_code_file_skipped(): """Non-code files (.md, .json, .txt) should be skipped.""" with tempfile.TemporaryDirectory() as tmp: gate = _run_gate_on_file(tmp, "README.md", "# Title \ntrailing ws\n") assert gate.warnings == 0, "Markdown files should be skipped" assert gate.failures == 0 def test_typescript_checked(): """TypeScript files should be checked.""" with tempfile.TemporaryDirectory() as tmp: gate = _run_gate_on_file(tmp, "test.ts", "x = 1 \n") assert gate.warnings >= 1, "TypeScript files should be checked" # -- directory traversal --------------------------------------------------- def test_run_scans_directory(): """Gate.run() should scan all files in a directory tree.""" with tempfile.TemporaryDirectory() as tmp: _write_file(tmp, "clean.py", "x = 1\n") _write_file(tmp, "dirty.js", "x = 1 \n") _write_file(tmp, "sub/nested.ts", "y = 2 \n") gate = QualityGate() gate.run(tmp) assert gate.warnings >= 2, "Should find trailing whitespace in both dirty files" def test_run_skips_node_modules(): """Gate.run() should skip node_modules directories.""" with tempfile.TemporaryDirectory() as tmp: _write_file(tmp, "clean.py", "x = 1\n") _write_file(tmp, "node_modules/pkg/index.js", "x = 1 \n") gate = QualityGate() gate.run(tmp) assert gate.warnings == 0, "node_modules should be skipped" def test_run_skips_git_dir(): """Gate.run() should skip .git directories.""" with tempfile.TemporaryDirectory() as tmp: _write_file(tmp, "clean.py", "x = 1\n") _write_file(tmp, ".git/hooks/pre-commit", "x = 1 \n") gate = QualityGate() gate.run(tmp) assert gate.warnings == 0, ".git should be skipped" # -- exit code ------------------------------------------------------------- def test_failures_cause_exit_code_1(): """Gate with failures should exit with code 1.""" import subprocess with tempfile.TemporaryDirectory() as tmp: body = "\n".join(f" console.log({i});" for i in range(52)) _write_file(tmp, "huge.js", f"function f() {{\n{body}\n}}\n") r = subprocess.run( [sys.executable, str(Path(__file__).resolve().parent.parent / "scripts" / "ci_automation_gate.py"), tmp], capture_output=True, text=True ) assert r.returncode == 1, f"Expected exit 1, got {r.returncode}" def test_clean_directory_exits_0(): """Gate on clean directory should exit 0.""" import subprocess with tempfile.TemporaryDirectory() as tmp: _write_file(tmp, "clean.py", "x = 1\ny = 2\n") r = subprocess.run( [sys.executable, str(Path(__file__).resolve().parent.parent / "scripts" / "ci_automation_gate.py"), tmp], capture_output=True, text=True ) assert r.returncode == 0, f"Expected exit 0, got {r.returncode}" # =========================================================================== # TASK GATE TESTS # =========================================================================== # Import task_gate functions directly — test the pure logic from task_gate import check_agent_lane, FILTER_TAGS, AGENT_USERNAMES # -- filter tags ----------------------------------------------------------- def test_epic_tag_filtered(): """Issues with [EPIC] tag should be filtered.""" title = "[EPIC] Build the thing" for tag in FILTER_TAGS: tag_clean = tag.upper().replace("[", "").replace("]", "") if tag_clean in title.upper(): return # Found assert False, "EPIC tag should be detected by FILTER_TAGS" def test_permanent_tag_filtered(): """Issues with [DO NOT CLOSE] tag should be filtered.""" title = "[DO NOT CLOSE] Keep this open forever" title_upper = title.upper() matched = any( tag.upper().replace("[", "").replace("]", "") in title_upper for tag in FILTER_TAGS ) assert matched, "[DO NOT CLOSE] should be filtered" def test_normal_title_not_filtered(): """Normal issue titles should not be filtered.""" title = "Fix the login bug in auth.py" title_upper = title.upper() matched = any( tag.upper().replace("[", "").replace("]", "") in title_upper for tag in FILTER_TAGS ) assert not matched, "Normal title should not be filtered" def test_morning_report_filtered(): """[MORNING REPORT] issues should be filtered.""" title = "[MORNING REPORT] Fleet status 2026-04-13" title_upper = title.upper() matched = any( tag.upper().replace("[", "").replace("]", "") in title_upper for tag in FILTER_TAGS ) assert matched, "[MORNING REPORT] should be filtered" # -- agent lane checker ---------------------------------------------------- def test_lane_check_no_config(): """With no lane config, lane check should pass.""" ok, msg = check_agent_lane("groq", "Fix bug", [], {}) assert ok assert "No lane config" in msg def test_lane_check_agent_not_in_config(): """Agent not in lane config should pass.""" lanes = {"ezra": ["docs"]} ok, msg = check_agent_lane("groq", "Fix bug", [], lanes) assert ok assert "No lanes defined" in msg def test_lane_check_agent_in_config(): """Agent in lane config should return their lanes.""" lanes = {"groq": ["code", "infra"]} ok, msg = check_agent_lane("groq", "Fix bug", [], lanes) assert ok assert "groq" in msg assert "code" in msg # -- agent usernames ------------------------------------------------------- def test_known_agents_in_usernames(): """Core agent usernames should be registered.""" assert "groq" in AGENT_USERNAMES assert "ezra" in AGENT_USERNAMES assert "bezalel" in AGENT_USERNAMES assert "timmy" in AGENT_USERNAMES assert "codex-agent" in AGENT_USERNAMES # -- pre-task gate (mocked API) ------------------------------------------- def test_pre_task_gate_issue_not_found(): """Pre-task gate should fail if issue doesn't exist.""" from task_gate import pre_task_gate with patch("task_gate.gitea_get", return_value=None): passed, msgs = pre_task_gate("timmy-config", 99999, "groq") assert not passed assert any("not found" in m for m in msgs) def test_pre_task_gate_filter_tag_blocks(): """Pre-task gate should block filtered issues.""" from task_gate import pre_task_gate mock_issue = { "title": "[EPIC] Big thing", "assignees": [], "labels": [], } def mock_gitea_get(path): if "issues/100" in path: return mock_issue if "branches" in path: return [] if "pulls" in path: return [] return None with patch("task_gate.gitea_get", side_effect=mock_gitea_get): passed, msgs = pre_task_gate("timmy-config", 100, "groq") assert not passed assert any("filter" in m.lower() for m in msgs) def test_pre_task_gate_assigned_agent_blocks(): """Pre-task gate should block issues assigned to other agents.""" from task_gate import pre_task_gate mock_issue = { "title": "Fix bug", "assignees": [{"login": "ezra"}], "labels": [], } def mock_gitea_get(path): if "issues/100" in path: return mock_issue if "branches" in path: return [] if "pulls" in path: return [] return None with patch("task_gate.gitea_get", side_effect=mock_gitea_get): passed, msgs = pre_task_gate("timmy-config", 100, "groq") assert not passed assert any("Already assigned" in m for m in msgs) def test_pre_task_gate_existing_pr_blocks(): """Pre-task gate should block issues with existing PRs.""" from task_gate import pre_task_gate mock_issue = { "title": "Fix bug", "assignees": [], "labels": [], } mock_prs = [{"number": 50, "title": "Fix for #100", "body": "Closes #100"}] def mock_gitea_get(path): if "issues/100" in path: return mock_issue if "branches" in path: return [] if "pulls" in path: return mock_prs return None with patch("task_gate.gitea_get", side_effect=mock_gitea_get): passed, msgs = pre_task_gate("timmy-config", 100, "groq") assert not passed assert any("Open PR" in m for m in msgs) def test_pre_task_gate_clean_passes(): """Pre-task gate should pass for clean issues.""" from task_gate import pre_task_gate def mock_gitea_get(path): if "issues/100" in path: return {"title": "Fix bug", "assignees": [], "labels": []} if "branches" in path: return [] if "pulls" in path: return [] return None with patch("task_gate.gitea_get", side_effect=mock_gitea_get): passed, msgs = pre_task_gate("timmy-config", 100, "groq") assert passed # -- post-task gate (mocked API) ------------------------------------------ def test_post_task_gate_missing_branch(): """Post-task gate should fail if branch doesn't exist.""" from task_gate import post_task_gate with patch("task_gate.gitea_get", return_value=None): passed, msgs = post_task_gate("timmy-config", 100, "groq", "groq/fix-100") assert not passed assert any("does not exist" in m for m in msgs) def test_post_task_gate_no_agent_prefix_warns(): """Post-task gate should warn if branch doesn't start with agent name.""" from task_gate import post_task_gate def mock_gitea_get(path): if "branches/fix-100" in path: return {"name": "fix-100"} if "compare" in path: return {"commits": [{"id": "abc"}], "diff_files": ["file.py"]} if "pulls" in path: return [] return None with patch("task_gate.gitea_get", side_effect=mock_gitea_get): passed, msgs = post_task_gate("timmy-config", 100, "groq", "fix-100") assert passed # Warning, not failure assert any("doesn't start with agent" in m or "convention" in m for m in msgs) def test_post_task_gate_no_commits_fails(): """Post-task gate should fail if branch has no commits ahead of main.""" from task_gate import post_task_gate def mock_gitea_get(path): if "branches/" in path: return {"name": "groq/fix-100"} if "compare" in path: return {"commits": [], "diff_files": []} if "pulls" in path: return [] return None with patch("task_gate.gitea_get", side_effect=mock_gitea_get): passed, msgs = post_task_gate("timmy-config", 100, "groq", "groq/fix-100") assert not passed assert any("no commits" in m.lower() for m in msgs) # =========================================================================== # INTEGRATION: gate on real script files # =========================================================================== def test_ci_gate_on_actual_task_gate(): """Run QualityGate on task_gate.py itself — should pass.""" gate_path = Path(__file__).resolve().parent.parent / "scripts" / "task_gate.py" if gate_path.exists(): gate = QualityGate() gate.check_file(gate_path) assert gate.failures == 0, f"task_gate.py should pass quality gate, got {gate.failures} failures" def test_ci_gate_on_actual_ci_automation_gate(): """Run QualityGate on ci_automation_gate.py itself — should pass.""" gate_path = Path(__file__).resolve().parent.parent / "scripts" / "ci_automation_gate.py" if gate_path.exists(): gate = QualityGate() gate.check_file(gate_path) assert gate.failures == 0, f"ci_automation_gate.py should pass quality gate, got {gate.failures} failures"