""" continuous_test_flow.py ======================= Prefect flow: runs all integrity test suites on a staggered, continuous schedule and publishes results to run_logs/test_results_latest.json (picked up by the TUI footer and MHS M6 sensor). Schedules (Nyquist-optimal — run at least 2× per expected detection window): data_integrity every 7 min — HZ schema + Arrow file freshness finance_fuzz every 20 min — Financial invariants, capital bounds signal_fill every 10 min — Signal path, latency, dedup degradation every 60 min — Kill/revive tests (destructive, slow) actor every 15 min — MHS, ACB, scan-bridge integration Stagger offset (minutes): data_integrity +0 finance_fuzz +2 signal_fill +4 degradation +6 (only on full-hour runs) actor +8 Register: python3 continuous_test_flow.py --register Run once (manual): python3 continuous_test_flow.py Run single suite: python3 continuous_test_flow.py --suite data_integrity """ import argparse import json import subprocess import sys import time from datetime import datetime, timezone from pathlib import Path from typing import Optional from prefect import flow, task, get_run_logger from prefect.client.schemas.schedules import CronSchedule as CS # ── Paths ─────────────────────────────────────────────────────────────────── _ROOT = Path(__file__).parent.parent # dolphinng5_predict _TESTS_DIR = Path(__file__).parent / "tests" _TUI_DIR = _ROOT / "Observability" / "TUI" _RESULTS = _ROOT / "run_logs" / "test_results_latest.json" _PYTHON = sys.executable # siloqy_env python sys.path.insert(0, str(_TUI_DIR)) try: from dolphin_tui_v3 import write_test_results _WTR_OK = True except Exception: _WTR_OK = False # ── Suite definitions ──────────────────────────────────────────────────────── # Each suite: (test_file, category, timeout_s, extra_pytest_args) SUITES = { "data_integrity": ( _TESTS_DIR / "test_data_integrity.py", "data_integrity", 120, ["-x", "--tb=short", "-q"], # fail-fast: first failure is enough ), "finance_fuzz": ( _TESTS_DIR / "test_finance_fuzz.py", "finance_fuzz", 180, ["--tb=short", "-q"], ), "signal_fill": ( _TESTS_DIR / "test_signal_to_fill.py", "signal_fill", 150, ["--tb=short", "-q"], ), "degradation": ( _TESTS_DIR / "test_degradational.py", "degradation", 300, ["--tb=short", "-q", "-m", "not slow"], # skip marked-slow E2E kills in light run ), "actor": ( _TESTS_DIR / "test_mhs_v3.py", "actor", 180, ["--tb=short", "-q", "-m", "not live_integration"], ), } # ── Helpers ────────────────────────────────────────────────────────────────── def _run_suite(name: str, test_file: Path, category: str, timeout: int, extra_args: list) -> dict: """ Run a pytest suite as a subprocess; return result dict for write_test_results. Captures pass/fail counts from pytest's JSON output (--json-report). Falls back to exit-code-only if json-report unavailable. """ json_out = Path(f"/tmp/dolphin_pytest_{name}.json") cmd = [ _PYTHON, "-m", "pytest", str(test_file), f"--category={category}", "--no-header", f"--timeout={timeout}", ] + extra_args # Try with json-report for precise counts cmd_jreport = cmd + [f"--json-report", f"--json-report-file={json_out}"] start = time.monotonic() try: proc = subprocess.run( cmd_jreport, capture_output=True, text=True, timeout=timeout + 30, cwd=str(_TESTS_DIR.parent), ) exit_code = proc.returncode except subprocess.TimeoutExpired: return {"passed": None, "total": None, "status": "FAIL", "note": f"timeout after {timeout}s"} except Exception as e: return {"passed": None, "total": None, "status": "FAIL", "note": str(e)} elapsed = time.monotonic() - start # Parse json-report if available passed = failed = total = None if json_out.exists(): try: jr = json.loads(json_out.read_text()) summary = jr.get("summary", {}) passed = summary.get("passed", 0) failed = summary.get("failed", 0) + summary.get("error", 0) total = passed + failed + summary.get("skipped", 0) except Exception: pass finally: try: json_out.unlink() except Exception: pass if passed is None: # Fallback: parse stdout for "X passed, Y failed" out = proc.stdout + proc.stderr import re m = re.search(r"(\d+) passed", out) if m: passed = int(m.group(1)) m = re.search(r"(\d+) failed", out) if m: failed = int(m.group(1)) total = (passed or 0) + (failed or 0) if total == 0 and exit_code == 0: passed, failed, total = 0, 0, 0 # no tests collected status = "PASS" if exit_code == 0 and (failed or 0) == 0 else "FAIL" if total == 0: status = "N/A" return { "passed": passed, "total": total, "status": status, "elapsed_s": round(elapsed, 1), } def _push(results: dict): """Write results dict to run_logs + TUI footer.""" if _WTR_OK: try: write_test_results(results) return except Exception: pass # Direct write fallback try: existing = json.loads(_RESULTS.read_text()) if _RESULTS.exists() else {} except Exception: existing = {} existing["_run_at"] = datetime.now(timezone.utc).isoformat() existing.update(results) _RESULTS.parent.mkdir(parents=True, exist_ok=True) _RESULTS.write_text(json.dumps(existing, indent=2)) # ── Prefect tasks ───────────────────────────────────────────────────────────── @task(name="run_data_integrity", retries=1, retry_delay_seconds=30, timeout_seconds=150) def task_data_integrity(): log = get_run_logger() name, (f, cat, t, args) = "data_integrity", SUITES["data_integrity"] log.info(f"▶ {name}") r = _run_suite(name, f, cat, t, args) _push({name: r}) log.info(f" {name}: {r['status']} {r.get('passed')}/{r.get('total')} ({r.get('elapsed_s')}s)") return r @task(name="run_finance_fuzz", retries=1, retry_delay_seconds=30, timeout_seconds=210) def task_finance_fuzz(): log = get_run_logger() name, (f, cat, t, args) = "finance_fuzz", SUITES["finance_fuzz"] log.info(f"▶ {name}") r = _run_suite(name, f, cat, t, args) _push({name: r}) log.info(f" {name}: {r['status']} {r.get('passed')}/{r.get('total')} ({r.get('elapsed_s')}s)") return r @task(name="run_signal_fill", retries=1, retry_delay_seconds=30, timeout_seconds=180) def task_signal_fill(): log = get_run_logger() name, (f, cat, t, args) = "signal_fill", SUITES["signal_fill"] log.info(f"▶ {name}") r = _run_suite(name, f, cat, t, args) _push({name: r}) log.info(f" {name}: {r['status']} {r.get('passed')}/{r.get('total')} ({r.get('elapsed_s')}s)") return r @task(name="run_degradation", retries=0, timeout_seconds=360) def task_degradation(): log = get_run_logger() name, (f, cat, t, args) = "degradation", SUITES["degradation"] log.info(f"▶ {name}") r = _run_suite(name, f, cat, t, args) _push({name: r}) log.info(f" {name}: {r['status']} {r.get('passed')}/{r.get('total')} ({r.get('elapsed_s')}s)") return r @task(name="run_actor", retries=1, retry_delay_seconds=30, timeout_seconds=210) def task_actor(): log = get_run_logger() name, (f, cat, t, args) = "actor", SUITES["actor"] log.info(f"▶ {name}") r = _run_suite(name, f, cat, t, args) _push({name: r}) log.info(f" {name}: {r['status']} {r.get('passed')}/{r.get('total')} ({r.get('elapsed_s')}s)") return r # ── Light flow: runs every 7 minutes (data + signal stagger) ───────────────── @flow(name="dolphin-tests-light", log_prints=True) def light_test_flow(suite: Optional[str] = None): """ Fast/frequent suites — data_integrity (0s) and signal_fill (+30s stagger). Scheduled every 7 minutes. """ log = get_run_logger() log.info("=== Light test flow ===") if suite == "data_integrity" or suite is None: task_data_integrity() if suite == "signal_fill" or suite is None: time.sleep(30) # stagger to avoid bursting HZ simultaneously task_signal_fill() # ── Medium flow: runs every 20 minutes (finance_fuzz + actor) ──────────────── @flow(name="dolphin-tests-medium", log_prints=True) def medium_test_flow(suite: Optional[str] = None): """ Medium-cadence suites — finance_fuzz (0s) and actor (+60s stagger). Scheduled every 20 minutes. """ log = get_run_logger() log.info("=== Medium test flow ===") if suite == "finance_fuzz" or suite is None: task_finance_fuzz() if suite == "actor" or suite is None: time.sleep(60) task_actor() # ── Heavy flow: runs every 60 minutes (degradation only) ───────────────────── @flow(name="dolphin-tests-heavy", log_prints=True) def heavy_test_flow(): """ Destructive/slow suites — degradation (kill/revive E2E). Scheduled every 60 minutes. """ log = get_run_logger() log.info("=== Heavy test flow ===") task_degradation() # ── Full suite flow: runs every 60 minutes at offset +8 min ────────────────── @flow(name="dolphin-tests-full", log_prints=True) def full_test_flow(): """All suites sequentially — used as nightly or on-demand full sweep.""" log = get_run_logger() log.info("=== Full test flow ===") task_data_integrity() time.sleep(15) task_finance_fuzz() time.sleep(15) task_signal_fill() time.sleep(15) task_actor() time.sleep(15) task_degradation() # ── CLI ─────────────────────────────────────────────────────────────────────── if __name__ == "__main__": import os os.environ.setdefault("PREFECT_API_URL", "http://localhost:4200/api") parser = argparse.ArgumentParser() parser.add_argument("--register", action="store_true", help="Register all deployments with Prefect") parser.add_argument("--suite", default=None, choices=list(SUITES.keys()) + ["full"], help="Run a single suite locally without Prefect") args = parser.parse_args() if args.register: # Light: every 7 minutes light_test_flow.to_deployment( name="dolphin-tests-light", schedule=CS(cron="*/7 * * * *", timezone="UTC"), work_pool_name="dolphin", tags=["integrity", "light"], ).apply() # Medium: every 20 minutes, offset +2 min medium_test_flow.to_deployment( name="dolphin-tests-medium", schedule=CS(cron="2-59/20 * * * *", timezone="UTC"), work_pool_name="dolphin", tags=["integrity", "medium"], ).apply() # Heavy: every 60 minutes, offset +6 min heavy_test_flow.to_deployment( name="dolphin-tests-heavy", schedule=CS(cron="6 * * * *", timezone="UTC"), work_pool_name="dolphin", tags=["integrity", "heavy"], ).apply() print("Registered: dolphin-tests-light (*/7), dolphin-tests-medium (2,22,42), dolphin-tests-heavy (:06)") elif args.suite == "full": full_test_flow() elif args.suite: # Run single suite directly name = args.suite f, cat, t, extra = SUITES[name] result = _run_suite(name, f, cat, t, extra) _push({name: result}) print(f"{name}: {result}") else: # Default: run light + medium inline (manual check) light_test_flow() medium_test_flow()