120 lines
3.6 KiB
Python
Executable File
120 lines
3.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Run each offline integration-test module in an isolated process group."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "libexec"))
|
|
|
|
from mmo_util import filtered_environment, terminate_process_group # noqa: E402
|
|
|
|
DEFAULT_MODULES = (
|
|
"tests.test_catalog_profiles",
|
|
"tests.test_runtime",
|
|
"tests.test_runtime_advanced",
|
|
"tests.test_tui_metadata",
|
|
"tests.test_install_eval",
|
|
"tests.test_cli_ux",
|
|
"tests.test_release",
|
|
)
|
|
_CURRENT_PROCESS: subprocess.Popen[str] | None = None
|
|
|
|
|
|
def _terminate_group(process: subprocess.Popen[str]) -> None:
|
|
terminate_process_group(process.pid, grace_seconds=3.0)
|
|
try:
|
|
process.wait(timeout=2)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
|
|
def _handle_termination(_signum: int, _frame: object) -> None:
|
|
if _CURRENT_PROCESS is not None:
|
|
_terminate_group(_CURRENT_PROCESS)
|
|
raise KeyboardInterrupt
|
|
|
|
|
|
def run_module(module: str, timeout: float) -> tuple[bool, float]:
|
|
global _CURRENT_PROCESS
|
|
environment = filtered_environment(
|
|
extra={
|
|
"PYTHONDONTWRITEBYTECODE": "1",
|
|
"PYTHONWARNINGS": "error::ResourceWarning",
|
|
"PYTHONPATH": os.pathsep.join((str(ROOT / "libexec"), str(ROOT / "tests"))),
|
|
}
|
|
)
|
|
command = [sys.executable, "-m", "unittest", "-v", module]
|
|
print(f"\n=== {module} ===", flush=True)
|
|
started = time.monotonic()
|
|
process = subprocess.Popen(
|
|
command,
|
|
cwd=ROOT,
|
|
env=environment,
|
|
text=True,
|
|
start_new_session=True,
|
|
)
|
|
_CURRENT_PROCESS = process
|
|
try:
|
|
try:
|
|
return_code = process.wait(timeout=timeout)
|
|
except subprocess.TimeoutExpired:
|
|
print(
|
|
f"ERROR: {module} exceeded {timeout:g} seconds; terminating its process group",
|
|
file=sys.stderr,
|
|
flush=True,
|
|
)
|
|
return False, time.monotonic() - started
|
|
finally:
|
|
# A test process can exit while leaving a child in its isolated group.
|
|
# Retire that group on success, timeout, interruption, and wait errors.
|
|
_terminate_group(process)
|
|
_CURRENT_PROCESS = None
|
|
return return_code == 0, time.monotonic() - started
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Run Codex MMO offline tests with per-module process isolation"
|
|
)
|
|
parser.add_argument("modules", nargs="*", default=list(DEFAULT_MODULES))
|
|
parser.add_argument("--timeout", type=float, default=300.0)
|
|
args = parser.parse_args()
|
|
if args.timeout <= 0:
|
|
parser.error("--timeout must be positive")
|
|
|
|
signal.signal(signal.SIGTERM, _handle_termination)
|
|
signal.signal(signal.SIGINT, _handle_termination)
|
|
|
|
failures: list[str] = []
|
|
durations: dict[str, float] = {}
|
|
for module in args.modules:
|
|
passed, duration = run_module(module, args.timeout)
|
|
durations[module] = duration
|
|
if not passed:
|
|
failures.append(module)
|
|
|
|
print("\n=== isolated suite summary ===")
|
|
for module, duration in durations.items():
|
|
status = "PASS" if module not in failures else "FAIL"
|
|
print(f"{status:4} {duration:8.2f}s {module}")
|
|
if failures:
|
|
print(
|
|
"failed modules: " + ", ".join(failures),
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
print(f"all {len(durations)} modules passed")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|