Files
leocrm/scripts/ai_run_tests.py
T

520 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""AI Test Runner — Unified test execution and reporting for LeoCRM.
Runs backend (pytest), frontend (vitest), and E2E (playwright) tests,
produces a structured JSON or CSV report, and exits with code 0 (all pass)
or 1 (any failures).
Usage:
python scripts/ai_run_tests.py [options]
Options:
--skip-backend Skip backend tests
--skip-frontend Skip frontend tests
--skip-e2e Skip E2E tests (default: skipped unless --run-e2e)
--run-e2e Run E2E tests (opt-in)
--output FORMAT Output format: json (default) or csv
--verbose Show full test output
--report-file PATH Write report to file instead of stdout
"""
from __future__ import annotations
import argparse
import csv
import io
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
# ─── Constants ───
PROJECT_ROOT = Path(__file__).resolve().parent.parent
FRONTEND_DIR = PROJECT_ROOT / "frontend"
BACKEND_DIR = PROJECT_ROOT
# ─── Data Structures ───
class TestSuiteResult:
"""Result of a single test suite (backend, frontend, e2e)."""
def __init__(self, suite: str):
self.suite = suite
self.total = 0
self.passed = 0
self.failed = 0
self.skipped = 0
self.duration = 0.0
self.failures: list[dict[str, str]] = []
self.errors: list[dict[str, str]] = []
self.raw_output = ""
self.exit_code = 0
self.command = ""
def to_dict(self) -> dict[str, Any]:
return {
"suite": self.suite,
"total": self.total,
"passed": self.passed,
"failed": self.failed,
"skipped": self.skipped,
"duration": round(self.duration, 2),
"exit_code": self.exit_code,
"command": self.command,
"failures": self.failures,
"errors": self.errors,
}
@property
def all_passed(self) -> bool:
return self.failed == 0 and self.exit_code == 0
# ─── Test Runners ───
def run_backend_tests(verbose: bool = False) -> TestSuiteResult:
"""Run pytest with --tb=short and JSON report."""
result = TestSuiteResult("backend")
report_file = BACKEND_DIR / ".test_report_backend.json"
cmd = [
sys.executable,
"-m",
"pytest",
"tests/",
"--tb=short",
f"--json-report-file={report_file}",
"-q",
]
if verbose:
cmd.append("-v")
result.command = " ".join(cmd)
start = time.time()
try:
proc = subprocess.run(
cmd,
cwd=str(BACKEND_DIR),
capture_output=True,
text=True,
timeout=600,
)
result.exit_code = proc.returncode
result.raw_output = proc.stdout + proc.stderr
except subprocess.TimeoutExpired:
result.exit_code = 124
result.raw_output = "Backend tests timed out after 600s"
result.errors.append({"test": "__timeout__", "error": "Test suite timed out"})
except FileNotFoundError:
result.exit_code = 127
result.raw_output = "pytest not found"
result.errors.append({"test": "__setup__", "error": "pytest not installed"})
result.duration = time.time() - start
# Parse JSON report if available
if report_file.exists():
try:
with open(report_file) as f:
data = json.load(f)
result.total = data.get("summary", {}).get("total", 0)
result.passed = data.get("summary", {}).get("passed", 0)
result.failed = data.get("summary", {}).get("failed", 0)
result.skipped = data.get("summary", {}).get("skipped", 0)
# Extract failure details
for test in data.get("tests", []):
if test.get("outcome") == "failed":
result.failures.append({
"test": test.get("nodeid", "unknown"),
"error": test.get("call", {}).get("longrepr", "No error details"),
})
except (json.JSONDecodeError, KeyError):
pass
finally:
report_file.unlink(missing_ok=True)
else:
# Fallback: parse from output
_parse_pytest_output(result)
return result
def run_frontend_tests(verbose: bool = False) -> TestSuiteResult:
"""Run vitest with JSON output."""
result = TestSuiteResult("frontend")
report_file = FRONTEND_DIR / ".test_report_frontend.json"
cmd = ["npx", "vitest", "run", "src/__tests__/", "--reporter=json"]
if verbose:
cmd.append("--reporter=verbose")
result.command = " ".join(cmd)
start = time.time()
try:
proc = subprocess.run(
cmd,
cwd=str(FRONTEND_DIR),
capture_output=True,
text=True,
timeout=300,
)
result.exit_code = proc.returncode
result.raw_output = proc.stdout + proc.stderr
except subprocess.TimeoutExpired:
result.exit_code = 124
result.raw_output = "Frontend tests timed out after 300s"
result.errors.append({"test": "__timeout__", "error": "Test suite timed out"})
except FileNotFoundError:
result.exit_code = 127
result.raw_output = "npx/vitest not found"
result.errors.append({"test": "__setup__", "error": "vitest not installed"})
result.duration = time.time() - start
# Try to parse JSON from stdout (vitest --reporter=json outputs to stdout)
_parse_vitest_output(result)
# Clean up report file if exists
report_file.unlink(missing_ok=True)
return result
def run_e2e_tests(verbose: bool = False) -> TestSuiteResult:
"""Run playwright E2E tests."""
result = TestSuiteResult("e2e")
cmd = ["npx", "playwright", "test", "--reporter=json"]
if verbose:
cmd.append("--reporter=list")
result.command = " ".join(cmd)
start = time.time()
try:
proc = subprocess.run(
cmd,
cwd=str(FRONTEND_DIR),
capture_output=True,
text=True,
timeout=600,
)
result.exit_code = proc.returncode
result.raw_output = proc.stdout + proc.stderr
except subprocess.TimeoutExpired:
result.exit_code = 124
result.raw_output = "E2E tests timed out after 600s"
result.errors.append({"test": "__timeout__", "error": "Test suite timed out"})
except FileNotFoundError:
result.exit_code = 127
result.raw_output = "playwright not found"
result.errors.append({"test": "__setup__", "error": "playwright not installed"})
result.duration = time.time() - start
# Parse playwright JSON output
_parse_playwright_output(result)
return result
# ─── Output Parsers ───
def _parse_pytest_output(result: TestSuiteResult) -> None:
"""Fallback parser for pytest text output."""
output = result.raw_output
for line in output.splitlines():
line = line.strip()
# Look for summary line like: "5 passed, 2 failed, 1 skipped in 10.5s"
if "passed" in line or "failed" in line or "skipped" in line:
parts = line.split()
for part in parts:
if part.isdigit():
if "passed" in line and "passed" in parts[parts.index(part) + 1:]:
result.passed = int(part)
elif "failed" in line and "failed" in parts[parts.index(part) + 1:]:
result.failed = int(part)
elif "skipped" in line and "skipped" in parts[parts.index(part) + 1:]:
result.skipped = int(part)
result.total = result.passed + result.failed + result.skipped
def _parse_vitest_output(result: TestSuiteResult) -> None:
"""Parse vitest JSON output from stdout."""
output = result.raw_output
# Try to find JSON block in output
json_start = output.find("{")
if json_start == -1:
# Fallback: parse from summary line
for line in output.splitlines():
if "Tests" in line and ("passed" in line or "failed" in line):
# e.g. "Tests 76 passed (76)" or "Tests 2 failed | 74 passed (76)"
parts = line.split()
for i, part in enumerate(parts):
if part.isdigit() and i + 1 < len(parts):
next_word = parts[i + 1]
if next_word == "passed":
result.passed = int(part)
elif next_word == "failed":
result.failed = int(part)
elif next_word == "skipped":
result.skipped = int(part)
result.total = result.passed + result.failed + result.skipped
return
# Try to extract valid JSON
try:
# Find the last valid JSON object in the output
json_str = output[json_start:]
# Find matching closing brace
brace_count = 0
end_idx = 0
for i, char in enumerate(json_str):
if char == "{":
brace_count += 1
elif char == "}":
brace_count -= 1
if brace_count == 0:
end_idx = i + 1
break
if end_idx > 0:
data = json.loads(json_str[:end_idx])
result.total = data.get("numTotalTests", 0)
result.passed = data.get("numPassedTests", 0)
result.failed = data.get("numFailedTests", 0)
result.skipped = data.get("numPendingTests", 0)
# Extract failure details
for test_file in data.get("testResults", []):
for assertion in test_file.get("assertionResults", []):
if assertion.get("status") == "failed":
result.failures.append({
"test": assertion.get("fullName", "unknown"),
"error": assertion.get("failureMessages", ["No error details"])[0],
})
except (json.JSONDecodeError, KeyError, IndexError):
# Fallback to text parsing
for line in output.splitlines():
if "Tests" in line and ("passed" in line or "failed" in line):
parts = line.split()
for i, part in enumerate(parts):
if part.isdigit() and i + 1 < len(parts):
next_word = parts[i + 1]
if next_word == "passed":
result.passed = int(part)
elif next_word == "failed":
result.failed = int(part)
elif next_word == "skipped":
result.skipped = int(part)
result.total = result.passed + result.failed + result.skipped
def _parse_playwright_output(result: TestSuiteResult) -> None:
"""Parse playwright JSON output."""
output = result.raw_output
json_start = output.find("{")
if json_start == -1:
return
try:
json_str = output[json_start:]
brace_count = 0
end_idx = 0
for i, char in enumerate(json_str):
if char == "{":
brace_count += 1
elif char == "}":
brace_count -= 1
if brace_count == 0:
end_idx = i + 1
break
if end_idx > 0:
data = json.loads(json_str[:end_idx])
stats = data.get("stats", {})
result.total = stats.get("total", 0)
result.passed = stats.get("passed", 0)
result.failed = stats.get("failed", 0)
result.skipped = stats.get("skipped", 0)
# Extract failure details from suites
for suite in data.get("suites", []):
for spec in suite.get("specs", []):
if spec.get("ok") is False:
for test in spec.get("tests", []):
if test.get("status") == "failed":
result.failures.append({
"test": spec.get("title", "unknown"),
"error": test.get("error", {}).get("message", "No error details"),
})
except (json.JSONDecodeError, KeyError, IndexError):
pass
# ─── Report Generation ───
def generate_json_report(results: list[TestSuiteResult]) -> dict[str, Any]:
"""Generate unified JSON report."""
total_all = sum(r.total for r in results)
passed_all = sum(r.passed for r in results)
failed_all = sum(r.failed for r in results)
skipped_all = sum(r.skipped for r in results)
duration_all = sum(r.duration for r in results)
all_failures: list[dict[str, str]] = []
for r in results:
for f in r.failures:
all_failures.append({"suite": r.suite, **f})
return {
"summary": {
"total": total_all,
"passed": passed_all,
"failed": failed_all,
"skipped": skipped_all,
"duration": round(duration_all, 2),
"all_passed": failed_all == 0,
},
"suites": [r.to_dict() for r in results],
"failures": all_failures,
}
def generate_csv_report(results: list[TestSuiteResult]) -> str:
"""Generate CSV report."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["suite", "total", "passed", "failed", "skipped", "duration", "exit_code"])
for r in results:
writer.writerow([
r.suite, r.total, r.passed, r.failed, r.skipped,
round(r.duration, 2), r.exit_code,
])
# Add failures section
writer.writerow([])
writer.writerow(["FAILURES"])
writer.writerow(["suite", "test", "error"])
for r in results:
for f in r.failures:
writer.writerow([r.suite, f.get("test", ""), f.get("error", "")])
return output.getvalue()
def print_console_summary(results: list[TestSuiteResult]) -> None:
"""Print a human-readable summary to console."""
print("\n" + "=" * 60)
print(" AI TEST RUNNER — SUMMARY")
print("=" * 60)
for r in results:
status = "✓ PASS" if r.all_passed else "✗ FAIL"
print(f"\n [{status}] {r.suite.upper()}")
print(f" Tests: {r.total} total, {r.passed} passed, {r.failed} failed, {r.skipped} skipped")
print(f" Duration: {r.duration:.1f}s")
if r.failures:
print(f" Failures ({len(r.failures)}):")
for f in r.failures[:5]:
test_name = f.get("test", "unknown")
if len(test_name) > 70:
test_name = test_name[:67] + "..."
print(f" - {test_name}")
if len(r.failures) > 5:
print(f" ... and {len(r.failures) - 5} more")
total_all = sum(r.total for r in results)
passed_all = sum(r.passed for r in results)
failed_all = sum(r.failed for r in results)
duration_all = sum(r.duration for r in results)
print("\n" + "-" * 60)
overall = "ALL PASSED" if failed_all == 0 else f"{failed_all} FAILURES"
print(f" OVERALL: {overall}")
print(f" Total: {passed_all}/{total_all} tests passed in {duration_all:.1f}s")
print("=" * 60 + "\n")
# ─── Main ───
def main() -> int:
parser = argparse.ArgumentParser(
description="AI Test Runner — Unified test execution and reporting for LeoCRM"
)
parser.add_argument("--skip-backend", action="store_true", help="Skip backend tests")
parser.add_argument("--skip-frontend", action="store_true", help="Skip frontend tests")
parser.add_argument("--skip-e2e", action="store_true", default=True, help="Skip E2E tests (default)")
parser.add_argument("--run-e2e", action="store_true", help="Run E2E tests (opt-in)")
parser.add_argument("--output", choices=["json", "csv"], default="json", help="Output format")
parser.add_argument("--verbose", action="store_true", help="Show full test output")
parser.add_argument("--report-file", type=str, default=None, help="Write report to file")
args = parser.parse_args()
results: list[TestSuiteResult] = []
# Run backend tests
if not args.skip_backend:
print("\n▶ Running backend tests (pytest)...")
backend_result = run_backend_tests(verbose=args.verbose)
results.append(backend_result)
if args.verbose and backend_result.raw_output:
print(backend_result.raw_output[:5000])
# Run frontend tests
if not args.skip_frontend:
print("\n▶ Running frontend tests (vitest)...")
frontend_result = run_frontend_tests(verbose=args.verbose)
results.append(frontend_result)
if args.verbose and frontend_result.raw_output:
print(frontend_result.raw_output[:5000])
# Run E2E tests (opt-in)
if args.run_e2e and not args.skip_e2e:
print("\n▶ Running E2E tests (playwright)...")
e2e_result = run_e2e_tests(verbose=args.verbose)
results.append(e2e_result)
if args.verbose and e2e_result.raw_output:
print(e2e_result.raw_output[:5000])
if not results:
print("\n⚠ No test suites were run. Use --skip-* flags to control which suites run.")
return 0
# Print console summary
print_console_summary(results)
# Generate report
if args.output == "json":
report = generate_json_report(results)
report_str = json.dumps(report, indent=2, ensure_ascii=False)
else:
report_str = generate_csv_report(results)
# Output report
if args.report_file:
Path(args.report_file).write_text(report_str, encoding="utf-8")
print(f" Report written to: {args.report_file}")
else:
print(report_str)
# Exit code: 0 if all passed, 1 if any failures
any_failed = any(not r.all_passed for r in results)
return 1 if any_failed else 0
if __name__ == "__main__":
sys.exit(main())