520 lines
18 KiB
Python
520 lines
18 KiB
Python
|
|
#!/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())
|