orchestrata_ultimate.gateway.test_runner

Reference › orchestrata_ultimate › gateway › test_runner

test_runner provides TestRunner, which runs pytest as a subprocess on behalf of the gateway and streams progress to clients (SPEC-016 REQ-016-167). server constructs it with a broadcast function and a graph repository, and the test_metrics handler exposes it through RPC. See gateway.

run accepts a run id, an optional test path and extra arguments. Only one run is allowed at a time; a second call returns an error dictionary. It builds a verbose pytest command, broadcasts a started event, and reads the subprocess output line by line. _parse_line, _record_test_result and _record_summary_counts update running counts (collected, completed, passed, failed, errors, skipped) and the current test, and every line is emitted as a progress event through _emit. When the process ends the status becomes complete for a zero exit code and failed otherwise, a final event with the totals and failed tests is emitted, and _store_result saves the result to the graph. If the process outlives the wait timeout, the runner kills and reaps it. is_running reports whether a run is active.

At a glance

Source src/orchestrata_ultimate/gateway/test_runner.py
Lines of code 357
Classes 1
Functions 0
Imports (internal) orchestrata_ultimate.core.base.traces
Imported by orchestrata_ultimate.gateway.server
Tests that import it tests/gateway/handlers/test_test_metrics_handler.py, tests/gateway/test_test_runner.py, tests/gateway/test_test_runner_streaming.py

Class diagram

Class diagram orchestrata_ultimate.gateway.test_runner

Dependency diagram

Dependency diagram orchestrata_ultimate.gateway.test_runner

API

Classes

TestRunner

Decorators: traces(spec='016', story='US-007', reqs=['REQ-016-166']) · Line 40

Runs pytest asynchronously and broadcasts progress.

Method Signature Summary
__init__ __init__(broadcast_fn: Any, graph_repository: Any = None, project_root: str = '.', wait_timeout_seconds: float = DEFAULT_WAIT_TIMEOUT_SECONDS) -> None Initialise the runner.
is_running property is_running() -> bool True if a test run is currently in progress.
run async run(run_id: str, test_path: str | None = None, extra_args: list[str] | None = None) -> dict[str, Any] Start a pytest run and stream progress.
_parse_line _parse_line(line: str, state: dict[str, Any]) -> None Parse a single line of pytest output and update state.
_record_test_result staticmethod _record_test_result(test_name: str, outcome: str, state: dict[str, Any]) -> None Update state for a single per-test result line.
_record_summary_counts staticmethod _record_summary_counts(line: str, state: dict[str, Any]) -> None Update state from a pytest summary line (passed/failed/error/skipped).
_emit async _emit(run_id: str, payload: dict[str, Any]) -> None Broadcast a tests.progress event.
_store_result async _store_result(state: dict[str, Any]) -> bool Store the final test run result in the operational knowledge graph.

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/gateway/test_runner.py

"""Async pytest runner with real-time progress broadcasting."""

import asyncio
import json
import logging
import os
import re
import sys
from datetime import UTC, datetime
from typing import Any

from orchestrata_ultimate.core.base.traces import traces

logger = logging.getLogger(__name__)

# Resolve the interpreter running THIS process (i.e. the project's venv
# python) rather than trusting a bare "python" on PATH -- PATH need not
# include the venv's bin/ (the venv is not "activated" just because this
# process was launched with .venv/bin/python), so a bare "python" can
# silently resolve to an unrelated system interpreter lacking the project's
# pytest plugins (e.g. pytest-timeout), making every subprocess run fail.
_PYTEST_PYTHON = sys.executable or "python"

# Pytest output line parsers
_PYTEST_COLLECTED = re.compile(r"collected\s+(\d+)\s+item")
_PYTEST_PASSED = re.compile(r"(\d+)\s+passed")
_PYTEST_FAILED = re.compile(r"(\d+)\s+failed")
_PYTEST_ERROR = re.compile(r"(\d+)\s+error")
_PYTEST_SKIPPED = re.compile(r"(\d+)\s+skipped")
_PYTEST_TEST_NAME = re.compile(r"([\w/._-]+::[\w_]+)\s+(PASSED|FAILED|ERROR|SKIPPED)")

# Default ceiling on how long we wait for the pytest subprocess to exit after
# its stdout has been fully drained. Configurable per-instance (see __init__)
# so deployments can tune it and tests can exercise the timeout path without
# a real 10-minute wait.
DEFAULT_WAIT_TIMEOUT_SECONDS = 600.0


@traces(spec="016", story="US-007", reqs=["REQ-016-166"])
class TestRunner:
    """Runs pytest asynchronously and broadcasts progress.

    Emits ``tests.progress`` events with:
    - ``status``: "started" | "collecting" | "running" | "complete" | "error"
    - ``run_id``: unique identifier
    - ``collected``: total tests discovered
    - ``completed``: tests finished so far
    - ``passed`` / ``failed`` / ``errors`` / ``skipped``: counters
    - ``current_test``: name of test currently running
    - ``output``: last line of pytest output
    """

    def __init__(
        self,
        broadcast_fn: Any,
        graph_repository: Any = None,
        project_root: str = ".",
        wait_timeout_seconds: float = DEFAULT_WAIT_TIMEOUT_SECONDS,
    ) -> None:
        """Initialise the runner.

        Args:
            broadcast_fn: Async callable ``(topic, payload) -> int``
                for pushing events to subscribers.
            graph_repository: Optional operational-graph repository for
                storing results.
            project_root: Directory to run pytest from.
            wait_timeout_seconds: How long to wait for the pytest subprocess
                to exit after its stdout stream ends, before giving up and
                killing it. Defaults to 10 minutes.
        """
        self._broadcast = broadcast_fn
        self._graph_repository = graph_repository
        self._project_root = project_root
        self._wait_timeout_seconds = wait_timeout_seconds
        self._running = False
        self._current_run_id: str | None = None

    @property
    @traces(spec="016", story="US-007", reqs=["REQ-016-168"])
    def is_running(self) -> bool:
        """True if a test run is currently in progress."""
        return self._running

    @traces(spec="016", story="US-007", reqs=["REQ-016-167"])
    async def run(
        self,
        run_id: str,
        test_path: str | None = None,
        extra_args: list[str] | None = None,
    ) -> dict[str, Any]:
        """Start a pytest run and stream progress.

        Args:
            run_id: Unique identifier for this run.
            test_path: Optional path/filter to pass to pytest.
            extra_args: Additional pytest arguments.

        Returns:
            Final result dict.
        """
        if self._running:
            return {"error": "Test run already in progress"}

        self._running = True
        self._current_run_id = run_id

        start_time = datetime.now(UTC)
        cmd = [
            _PYTEST_PYTHON,
            "-m",
            "pytest",
            "-v",
            "-s",  # unbuffered output for real-time progress
            "--timeout=120",
            "--tb=short",
            "--color=no",
        ]
        if extra_args:
            cmd.extend(extra_args)
        if test_path:
            cmd.append(test_path)
        else:
            cmd.append("tests/")

        await self._emit(
            run_id,
            {
                "status": "started",
                "command": " ".join(cmd),
                "timestamp": start_time.isoformat(),
            },
        )

        state: dict[str, Any] = {
            "run_id": run_id,
            "status": "running",
            "collected": 0,
            "completed": 0,
            "passed": 0,
            "failed": 0,
            "errors": 0,
            "skipped": 0,
            "failed_tests": [],
            "output_lines": [],
        }

        proc: asyncio.subprocess.Process | None = None
        try:
            proc = await asyncio.create_subprocess_exec(
                *cmd,
                cwd=self._project_root,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.STDOUT,
                env={**os.environ, "PYTHONUNBUFFERED": "1"},
            )

            assert proc.stdout is not None
            async for raw_line in proc.stdout:
                line = raw_line.decode("utf-8", errors="replace").rstrip()
                state["output_lines"].append(line)
                self._parse_line(line, state)
                await self._emit(
                    run_id,
                    {
                        "status": state["status"],
                        "collected": state["collected"],
                        "completed": state["completed"],
                        "passed": state["passed"],
                        "failed": state["failed"],
                        "errors": state["errors"],
                        "skipped": state["skipped"],
                        "current_test": state.get("current_test"),
                        "output": line,
                        "timestamp": datetime.now(UTC).isoformat(),
                    },
                )

            returncode = await asyncio.wait_for(
                proc.wait(), timeout=self._wait_timeout_seconds
            )
            duration = (datetime.now(UTC) - start_time).total_seconds()

            state["status"] = "complete" if returncode == 0 else "failed"
            state["duration_seconds"] = duration
            state["returncode"] = returncode

            await self._emit(
                run_id,
                {
                    "status": state["status"],
                    "collected": state["collected"],
                    "completed": state["completed"],
                    "passed": state["passed"],
                    "failed": state["failed"],
                    "errors": state["errors"],
                    "skipped": state["skipped"],
                    "failed_tests": state["failed_tests"],
                    "duration_seconds": duration,
                    "returncode": returncode,
                    "timestamp": datetime.now(UTC).isoformat(),
                },
            )

            await self._store_result(state)
            return state

        except TimeoutError:
            # The subprocess outlived our wait budget: kill and reap it so we
            # don't leak the process and its stdio file descriptors. proc is
            # always bound here -- the only timeout is on proc.wait() above,
            # which cannot run before proc is assigned -- but we guard
            # defensively since static analysis can't see that.
            if proc is not None:
                try:
                    proc.kill()
                except ProcessLookupError:
                    pass  # already exited
                await proc.wait()
            state["status"] = "error"
            state["error"] = f"Test run timed out after {self._wait_timeout_seconds:g}s"
            await self._emit(
                run_id, {**state, "timestamp": datetime.now(UTC).isoformat()}
            )
            return state
        except Exception as exc:
            logger.exception("Test run failed")
            state["status"] = "error"
            state["error"] = str(exc)
            await self._emit(
                run_id, {**state, "timestamp": datetime.now(UTC).isoformat()}
            )
            return state
        finally:
            self._running = False
            self._current_run_id = None

    @traces(spec="016", story="US-007", reqs=["REQ-016-166"])
    def _parse_line(self, line: str, state: dict[str, Any]) -> None:
        """Parse a single line of pytest output and update state."""
        # Detect collection count
        m = _PYTEST_COLLECTED.search(line)
        if m:
            state["collected"] = int(m.group(1))
            state["status"] = "collecting"
            return

        # Detect individual test result
        m = _PYTEST_TEST_NAME.search(line)
        if m:
            self._record_test_result(m.group(1), m.group(2), state)
            return

        # Detect summary counts (fallback)
        self._record_summary_counts(line, state)

    @staticmethod
    @traces(spec="016", story="US-007", reqs=["REQ-016-166"])
    def _record_test_result(
        test_name: str, outcome: str, state: dict[str, Any]
    ) -> None:
        """Update state for a single per-test result line."""
        state["current_test"] = test_name
        state["completed"] += 1
        state["status"] = "running"
        if outcome == "PASSED":
            state["passed"] += 1
        elif outcome == "FAILED":
            state["failed"] += 1
            state["failed_tests"].append(test_name)
        elif outcome == "ERROR":
            state["errors"] += 1
            state["failed_tests"].append(test_name)
        elif outcome == "SKIPPED":
            state["skipped"] += 1

    @staticmethod
    @traces(spec="016", story="US-007", reqs=["REQ-016-166"])
    def _record_summary_counts(line: str, state: dict[str, Any]) -> None:
        """Update state from a pytest summary line (passed/failed/error/skipped)."""
        m = _PYTEST_PASSED.search(line)
        if m:
            state["passed"] = int(m.group(1))
        m = _PYTEST_FAILED.search(line)
        if m:
            state["failed"] = int(m.group(1))
        m = _PYTEST_ERROR.search(line)
        if m:
            state["errors"] = int(m.group(1))
        m = _PYTEST_SKIPPED.search(line)
        if m:
            state["skipped"] = int(m.group(1))

    @traces(spec="016", story="US-007", reqs=["REQ-016-166"])
    async def _emit(self, run_id: str, payload: dict[str, Any]) -> None:
        """Broadcast a tests.progress event."""
        try:
            await self._broadcast("tests.progress", {"run_id": run_id, **payload})
        except Exception:
            logger.exception("Failed to broadcast test progress")

    async def _store_result(self, state: dict[str, Any]) -> bool:
        """Store the final test run result in the operational knowledge graph.

        Returns:
            True if the write landed, False if there is no graph repository
            configured (nothing was stored) or the write itself failed.
            Non-fatal either way — storing the result is a side-channel,
            never a reason to break the caller.
        """
        if not self._graph_repository:
            # No repository configured: nothing was stored, distinct from a
            # write that was attempted and failed below.
            return False
        try:
            # MERGE on `id`, TestRun's canonical merge key (canonical_schema
            # CANONICAL_MERGE_KEYS). This was CREATE-without-`id` until
            # 2026-08-07: every gateway run left a keyless node, so
            # `scripts/canonical_conformance.py` reported real drift
            # ("TestRun: N node(s) lacking their canonical key") and a retried
            # run_id created a duplicate instead of updating. The sibling
            # writer, knowledge_graph/test_results_store.save_run, already
            # MERGEs on `id`; this one is now consistent with it. `run_id` is
            # kept as a plain property — the metrics handler reads it.
            query = """
            MERGE (t:TestRun {id: $run_id})
            SET t += {
                run_id: $run_id,
                date: datetime(),
                test_count: $test_count,
                pass_rate: $pass_rate,
                coverage: $coverage,
                duration: $duration,
                failed_tests: $failed_tests,
                status: $status
            }
            """
            total = state.get("completed", 0)
            passed = state.get("passed", 0)
            pass_rate = (passed / total * 100) if total > 0 else 0.0
            await self._graph_repository.execute_write(
                query,
                {
                    "run_id": state["run_id"],
                    "test_count": total,
                    "pass_rate": pass_rate,
                    "coverage": 0.0,  # TODO: integrate coverage.py
                    "duration": state.get("duration_seconds", 0),
                    "failed_tests": json.dumps(state.get("failed_tests", [])),
                    "status": state.get("status", "unknown"),
                },
            )
            logger.info("Stored test run %s in the graph", state["run_id"])
            return True
        except Exception:
            logger.exception("Failed to store test result in the graph")
            return False