From 145e1711ec04461d133d5faa379c112af059a2af Mon Sep 17 00:00:00 2001 From: Roman Pszonka Date: Sun, 12 Apr 2026 17:14:31 +0100 Subject: [PATCH 1/7] Add Allure reporting integration and HTTP exchange logging - Introduced Allure reporting configuration in default.yaml and added allure-python-commons as a dependency. - Implemented AllureScenarioReporter for recording scenario results and HTTP exchanges. - Enhanced BaseBlenderAPIClient and BaseOpenSkyAPIClient to log HTTP requests and responses. - Added API endpoints for generating and retrieving Allure reports in the server router. - Updated ScenarioEditor and ScenarioInfoPanel components to support Allure report generation in the web editor. --- config/default.yaml | 4 + pyproject.toml | 1 + .../clients/flight_blender/base_client.py | 15 + .../core/clients/opensky/base_client.py | 22 ++ .../core/execution/config_models.py | 8 + .../core/execution/execution.py | 18 ++ .../core/execution/scenario_runner.py | 3 + .../core/reporting/allure_reporter.py | 285 ++++++++++++++++++ .../core/reporting/http_collector.py | 131 ++++++++ .../core/reporting/reporting_models.py | 4 +- src/openutm_verification/server/main.py | 9 + src/openutm_verification/server/router.py | 69 +++++ uv.lock | 15 + web-editor/src/components/ScenarioEditor.tsx | 38 +++ .../ScenarioEditor/ScenarioInfoPanel.tsx | 25 +- 15 files changed, 645 insertions(+), 2 deletions(-) create mode 100644 src/openutm_verification/core/reporting/allure_reporter.py create mode 100644 src/openutm_verification/core/reporting/http_collector.py diff --git a/config/default.yaml b/config/default.yaml index 6316fc61..c558803e 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -98,3 +98,7 @@ reporting: name: "Local Flight Blender Dev Instance" version: "v0.12.0" notes: "Running against local Docker Compose setup." + # Allure reporting (use `allure generate reports/allure-results` for HTML) + allure: + enabled: true + results_dir: "reports/allure-results" diff --git a/pyproject.toml b/pyproject.toml index 1df95ffc..b359ce45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "websockets>=12.0", "markdown>=3.10", "uas-standards==4.2.0", + "allure-python-commons>=2.15.0", "fastapi>=0.121.3", "uvicorn[standard]>=0.38.0", # includes watchfiles for efficient reload "bluesky-simulator==1.1.0", diff --git a/src/openutm_verification/core/clients/flight_blender/base_client.py b/src/openutm_verification/core/clients/flight_blender/base_client.py index 1314da10..8fcab21d 100644 --- a/src/openutm_verification/core/clients/flight_blender/base_client.py +++ b/src/openutm_verification/core/clients/flight_blender/base_client.py @@ -1,7 +1,10 @@ +import time + import httpx from loguru import logger from websockets.asyncio.client import ClientConnection, connect +from openutm_verification.core.reporting.http_collector import HttpCollector from openutm_verification.models import FlightBlenderError @@ -33,17 +36,29 @@ async def _request( silent_status: list[int] | None = None, ) -> httpx.Response: url = f"{self.base_url}{endpoint}" + start = time.time() + response: httpx.Response | None = None try: response = await self.client.request(method, url, json=json) if not (silent_status and response.status_code in silent_status): response.raise_for_status() return response except httpx.HTTPStatusError as e: + response = e.response logger.error(f"HTTP error occurred: {e.response.status_code} - {e.response.text}") raise FlightBlenderError(f"Request failed: {e.response.status_code}") from e except httpx.RequestError as e: logger.error(f"Request error occurred: {e}") raise FlightBlenderError("Request failed") from e + finally: + HttpCollector.record_from_httpx( + method=method, + url=url, + request_headers=dict(self.client.headers), + request_body=json, + response=response, + start=start, + ) async def get(self, endpoint: str, silent_status: list[int] | None = None) -> httpx.Response: return await self._request("GET", endpoint, silent_status=silent_status) diff --git a/src/openutm_verification/core/clients/opensky/base_client.py b/src/openutm_verification/core/clients/opensky/base_client.py index 43f1545b..8f26a92d 100644 --- a/src/openutm_verification/core/clients/opensky/base_client.py +++ b/src/openutm_verification/core/clients/opensky/base_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import time from typing import TYPE_CHECKING import httpx @@ -8,6 +9,7 @@ from openutm_verification.auth.oauth2 import OAuth2Client from openutm_verification.core.execution.config_models import get_settings +from openutm_verification.core.reporting.http_collector import HttpCollector if TYPE_CHECKING: from openutm_verification.core.execution.config_models import OpenSkyConfig @@ -66,13 +68,33 @@ async def _request( headers["Authorization"] = f"Bearer {await self.oauth_client.get_access_token()}" logger.debug(f"Making {method} request to {url}") + start = time.time() response = await self.client.request(method, url, params=params, headers=headers) if response.status_code == 401 and config.opensky.auth.type == "oauth2": + # Record the failed attempt + HttpCollector.record_from_httpx( + method=method, + url=url, + request_headers={**dict(self.client.headers), **headers}, + request_body=params, + response=response, + start=start, + ) logger.warning("Token expired, retrying with new token...") headers["Authorization"] = f"Bearer {await self.oauth_client.get_access_token()}" + start = time.time() response = await self.client.request(method, url, params=params, headers=headers) + HttpCollector.record_from_httpx( + method=method, + url=url, + request_headers={**dict(self.client.headers), **headers}, + request_body=params, + response=response, + start=start, + ) + if not (silent_status and response.status_code in silent_status): response.raise_for_status() return response diff --git a/src/openutm_verification/core/execution/config_models.py b/src/openutm_verification/core/execution/config_models.py index 6db33dc6..9b747907 100644 --- a/src/openutm_verification/core/execution/config_models.py +++ b/src/openutm_verification/core/execution/config_models.py @@ -72,6 +72,13 @@ class DeploymentDetails(StrictBaseModel): notes: str = "" +class AllureConfig(StrictBaseModel): + """Configuration for Allure reporting.""" + + enabled: bool = False + results_dir: str = "reports/allure-results" + + class ReportingConfig(StrictBaseModel): """Configuration for generating reports.""" @@ -79,6 +86,7 @@ class ReportingConfig(StrictBaseModel): output_dir: str = "reports" formats: list[str] = Field(default_factory=lambda: ["json", "html", "log"]) deployment_details: DeploymentDetails = Field(default_factory=DeploymentDetails) + allure: AllureConfig = Field(default_factory=AllureConfig) class DataFiles(StrictBaseModel): diff --git a/src/openutm_verification/core/execution/execution.py b/src/openutm_verification/core/execution/execution.py index 53d7ed98..ae2b11c7 100644 --- a/src/openutm_verification/core/execution/execution.py +++ b/src/openutm_verification/core/execution/execution.py @@ -23,6 +23,7 @@ from openutm_verification.core.execution.dependencies import scenarios from openutm_verification.core.execution.dependency_resolution import CONTEXT, call_with_dependencies from openutm_verification.core.execution.scenario_loader import load_yaml_scenario_definition +from openutm_verification.core.reporting.allure_reporter import AllureScenarioReporter from openutm_verification.core.reporting.reporting import _sanitize_config, create_report_data, generate_reports from openutm_verification.core.reporting.reporting_models import ( ScenarioResult, @@ -71,6 +72,12 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path, sessi # Import Python scenarios to populate registry _import_python_scenarios() + # Initialise Allure reporter if enabled + allure_reporter: AllureScenarioReporter | None = None + if config.reporting.allure.enabled: + allure_reporter = AllureScenarioReporter(config.reporting.allure.results_dir) + logger.info(f"Allure reporting enabled → {config.reporting.allure.results_dir}") + scenario_results = [] for scenario_id in scenarios(): try: @@ -136,6 +143,12 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path, sessi scenario_results.append(result) logger.info(f"Scenario {scenario_id} finished with status: {result.status}") + # Record scenario in Allure + if allure_reporter: + allure_reporter.start_scenario(scenario_id, result.suite_name) + allure_reporter.record_steps(result.steps) + allure_reporter.end_scenario(result) + end_time_obj = datetime.now(timezone.utc) docs_dir = get_docs_directory() @@ -151,4 +164,9 @@ async def run_verification_scenarios(config: AppConfig, config_path: Path, sessi logger.info(f"Verification run complete with overall status: {report_data.overall_status}") generate_reports(report_data, config.reporting) + + if allure_reporter: + allure_reporter.close() + logger.info(f"Allure results written to {config.reporting.allure.results_dir}") + return report_data.summary.failed diff --git a/src/openutm_verification/core/execution/scenario_runner.py b/src/openutm_verification/core/execution/scenario_runner.py index 2f44cb94..0ef99193 100644 --- a/src/openutm_verification/core/execution/scenario_runner.py +++ b/src/openutm_verification/core/execution/scenario_runner.py @@ -23,6 +23,7 @@ from openutm_verification.core.clients.opensky.base_client import OpenSkyError from openutm_verification.core.execution.dependency_resolution import DEPENDENCIES from openutm_verification.core.flight_phase import FlightPhase +from openutm_verification.core.reporting.http_collector import HttpCollector from openutm_verification.core.reporting.reporting_models import ( ScenarioResult, Status, @@ -303,6 +304,7 @@ def log_filter(record): handler_id = logger.add(lambda msg: captured_logs.append(msg), filter=log_filter, format="{time:HH:mm:ss} | {level} | {message}") + HttpCollector.init() step_result: StepResult[Any] | None = None try: with logger.contextualize(step_execution_id=step_execution_id): @@ -321,6 +323,7 @@ def log_filter(record): logger.remove(handler_id) if step_result: step_result.logs = captured_logs + step_result.http_exchanges = HttpCollector.drain() return step_result diff --git a/src/openutm_verification/core/reporting/allure_reporter.py b/src/openutm_verification/core/reporting/allure_reporter.py new file mode 100644 index 00000000..fc03fc0b --- /dev/null +++ b/src/openutm_verification/core/reporting/allure_reporter.py @@ -0,0 +1,285 @@ +""" +Allure reporter that writes scenario results as Allure test-case JSON. + +Uses ``allure-python-commons`` lifecycle API (v2.x) to produce results +consumable by Allure CLI v3 (``allure generate``). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from allure_commons._allure import plugin_manager +from allure_commons.lifecycle import AllureLifecycle +from allure_commons.logger import AllureFileLogger +from allure_commons.model2 import ( + Label, + Parameter, + StatusDetails, +) +from allure_commons.model2 import ( + Status as AllureStatus, +) +from allure_commons.types import AttachmentType, LabelType +from allure_commons.utils import now +from loguru import logger + +if TYPE_CHECKING: + from openutm_verification.core.reporting.http_collector import HttpExchange + from openutm_verification.core.reporting.reporting_models import ( + ScenarioResult, + Status, + StepResult, + ) + + +def _map_status(status: Status) -> AllureStatus: + """Map our Status enum to Allure's Status.""" + from openutm_verification.core.reporting.reporting_models import Status as OurStatus + + return { + OurStatus.PASS: AllureStatus.PASSED, + OurStatus.FAIL: AllureStatus.FAILED, + OurStatus.SKIP: AllureStatus.SKIPPED, + OurStatus.RUNNING: AllureStatus.BROKEN, + OurStatus.WAITING: AllureStatus.BROKEN, + }.get(status, AllureStatus.UNKNOWN) + + +def _ms(seconds: float) -> int: + """Convert seconds → milliseconds (int).""" + return int(seconds * 1000) + + +# Matches loop-iteration IDs like "submit_telemetry[0]", "group[3]" +_LOOP_ID_RE = re.compile(r"^(.+)\[(\d+)\]$") + + +def _is_loop_iteration(step_id: str | None) -> bool: + return bool(step_id and _LOOP_ID_RE.match(step_id)) + + +def _loop_base_id(step_id: str) -> str: + m = _LOOP_ID_RE.match(step_id) + return m.group(1) if m else step_id + + +def _loop_index(step_id: str) -> int: + m = _LOOP_ID_RE.match(step_id) + return int(m.group(2)) if m else 0 + + +class AllureScenarioReporter: + """Write Allure results for each scenario execution.""" + + def __init__(self, results_dir: str | Path) -> None: + self._results_dir = Path(results_dir) + self._results_dir.mkdir(parents=True, exist_ok=True) + + self._file_logger = AllureFileLogger(str(self._results_dir)) + plugin_manager.register(self._file_logger, "allure_scenario_file_logger") + + self._lifecycle = AllureLifecycle() + # Track current test case UUID for step nesting + self._current_test_uuid: str | None = None + + # ── Public API ──────────────────────────────────────────────── + + def start_scenario(self, name: str, suite_name: str | None = None) -> None: + """Begin a new Allure test case for the given scenario.""" + test_uuid = str(uuid4()) + self._current_test_uuid = test_uuid + + with self._lifecycle.schedule_test_case(uuid=test_uuid) as test_result: + test_result.name = name + test_result.fullName = f"{suite_name}.{name}" if suite_name else name + test_result.start = now() + test_result.labels = [ + Label(name=LabelType.FRAMEWORK, value="openutm-verification"), + Label(name=LabelType.LANGUAGE, value="python"), + ] + if suite_name: + test_result.labels.append(Label(name=LabelType.SUITE, value=suite_name)) + + def record_steps(self, steps: list[StepResult[Any]]) -> None: + """Record all steps, grouping consecutive loop iterations as substeps.""" + if self._current_test_uuid is None: + logger.warning("record_steps called without an active scenario") + return + + i = 0 + while i < len(steps): + step = steps[i] + + # Check if this is the start of a loop iteration run + if _is_loop_iteration(step.id): + base_id = _loop_base_id(step.id) + # Collect all consecutive iterations with the same base ID + group: list[StepResult[Any]] = [step] + j = i + 1 + while j < len(steps) and steps[j].id and _loop_base_id(steps[j].id) == base_id: + group.append(steps[j]) + j += 1 + self._record_loop_step(step.name, group) + i = j + else: + self._record_single_step(step, parent_uuid=self._current_test_uuid) + i += 1 + + def record_step( + self, + step_result: StepResult[Any], + http_exchanges: list[HttpExchange] | None = None, + ) -> None: + """Record a single step (backwards-compatible entry point).""" + if self._current_test_uuid is None: + logger.warning("record_step called without an active scenario") + return + self._record_single_step(step_result, parent_uuid=self._current_test_uuid, http_exchanges=http_exchanges) + + def end_scenario(self, scenario_result: ScenarioResult) -> None: + """Finalise the Allure test case for the scenario.""" + if self._current_test_uuid is None: + return + + with self._lifecycle.update_test_case(uuid=self._current_test_uuid) as test_result: + test_result.status = _map_status(scenario_result.status) + test_result.stop = now() + + if scenario_result.error_message: + test_result.statusDetails = StatusDetails(message=scenario_result.error_message) + + self._lifecycle.write_test_case(uuid=self._current_test_uuid) + self._current_test_uuid = None + + def close(self) -> None: + """Unregister the file logger plugin.""" + try: + plugin_manager.unregister(name="allure_scenario_file_logger") + except Exception: + pass + + # ── Internal ────────────────────────────────────────────────── + + def _record_loop_step(self, step_name: str, iterations: list[StepResult[Any]]) -> None: + """Create a parent step with one substep per iteration.""" + parent_uuid = str(uuid4()) + + # Determine aggregate status: FAIL if any failed, else PASS + from openutm_verification.core.reporting.reporting_models import Status as OurStatus + + has_failure = any(s.status == OurStatus.FAIL for s in iterations) + aggregate_status = OurStatus.FAIL if has_failure else OurStatus.PASS + total_duration = sum(s.duration for s in iterations) + + with self._lifecycle.start_step(parent_uuid=self._current_test_uuid, uuid=parent_uuid) as parent_step: + parent_step.name = f"{step_name} ({len(iterations)} iterations)" + parent_step.start = now() - _ms(total_duration) + parent_step.status = _map_status(aggregate_status) + parent_step.parameters = [Parameter(name="iterations", value=str(len(iterations)))] + + # Record each iteration as a substep + for idx, iteration_result in enumerate(iterations): + self._record_single_step( + iteration_result, + parent_uuid=parent_uuid, + label=f"[{idx}] {step_name}", + ) + + self._lifecycle.stop_step(uuid=parent_uuid) + + def _record_single_step( + self, + step_result: StepResult[Any], + *, + parent_uuid: str, + http_exchanges: list[HttpExchange] | None = None, + label: str | None = None, + ) -> None: + """Record one step with attachments in order: request, response, logs.""" + step_uuid = str(uuid4()) + with self._lifecycle.start_step(parent_uuid=parent_uuid, uuid=step_uuid) as step: + step.name = label or step_result.name + step.start = now() - _ms(step_result.duration) + step.status = _map_status(step_result.status) + + if step_result.error_message: + step.statusDetails = StatusDetails(message=step_result.error_message) + + if step_result.id: + step.parameters = [Parameter(name="id", value=step_result.id)] + + # Each HTTP exchange becomes a substep with request/response attachments + exchanges = http_exchanges or step_result.http_exchanges + for i, exchange in enumerate(exchanges or []): + idx = f"[{i + 1}] " if len(exchanges or []) > 1 else "" + ex_uuid = str(uuid4()) + ex_status = AllureStatus.PASSED if exchange.response_status and exchange.response_status < 400 else AllureStatus.FAILED + + with self._lifecycle.start_step(parent_uuid=step_uuid, uuid=ex_uuid) as ex_step: + ex_step.name = f"{idx}{exchange.method} {exchange.url} → {exchange.response_status}" + ex_step.start = now() - int(exchange.duration_ms) + ex_step.status = ex_status + + self._attach_json( + ex_uuid, + "Request", + { + "method": exchange.method, + "url": exchange.url, + "headers": exchange.request_headers, + "body": exchange.request_body, + }, + ) + + self._attach_json( + ex_uuid, + "Response", + { + "status": exchange.response_status, + "headers": exchange.response_headers, + "body": exchange.response_body, + "duration_ms": round(exchange.duration_ms, 2), + "error": exchange.error, + }, + ) + + self._lifecycle.stop_step(uuid=ex_uuid) + + if step_result.result is not None: + self._attach_json(step_uuid, "Step Result", step_result.result) + + if step_result.logs: + self._attach_text(step_uuid, "Logs", "\n".join(step_result.logs)) + + self._lifecycle.stop_step(uuid=step_uuid) + + def _attach_json(self, parent_uuid: str, name: str, data: Any) -> None: + """Attach a JSON blob to a step or test case.""" + try: + body = json.dumps(data, indent=2, default=str, ensure_ascii=False) + except (TypeError, ValueError): + body = str(data) + self._lifecycle.attach_data( + uuid=str(uuid4()), + body=body, + name=name, + attachment_type=AttachmentType.JSON, + extension="json", + parent_uuid=parent_uuid, + ) + + def _attach_text(self, parent_uuid: str, name: str, text: str) -> None: + """Attach plain text to a step or test case.""" + self._lifecycle.attach_data( + uuid=str(uuid4()), + body=text, + name=name, + attachment_type=AttachmentType.TEXT, + extension="txt", + parent_uuid=parent_uuid, + ) diff --git a/src/openutm_verification/core/reporting/http_collector.py b/src/openutm_verification/core/reporting/http_collector.py new file mode 100644 index 00000000..61e0ad09 --- /dev/null +++ b/src/openutm_verification/core/reporting/http_collector.py @@ -0,0 +1,131 @@ +""" +Context-variable based HTTP exchange collector. + +Captures HTTP request/response data during scenario step execution so it can +be attached to Allure reports. Uses the same ``contextvars`` pattern as +``_scenario_state`` in :mod:`scenario_runner`. +""" + +from __future__ import annotations + +import contextvars +import re +import time +from dataclasses import dataclass, field +from typing import Any + +_MAX_BODY_SIZE = 100_000 # truncate bodies larger than ~100 KB + + +@dataclass(frozen=True) +class HttpExchange: + """A single HTTP request/response pair.""" + + method: str + url: str + request_headers: dict[str, str] + request_body: Any # JSON-serialisable payload or None + response_status: int | None + response_headers: dict[str, str] + response_body: str | None + duration_ms: float + error: str | None = None + + +# ── Sanitisation helpers ───────────────────────────────────────────── + +_BEARER_RE = re.compile(r"(Bearer\s+)\S+", re.IGNORECASE) +_SENSITIVE_HEADERS = frozenset({"authorization", "cookie", "set-cookie", "x-api-key"}) + + +def _sanitise_headers(headers: dict[str, str]) -> dict[str, str]: + """Mask sensitive header values.""" + out: dict[str, str] = {} + for k, v in headers.items(): + if k.lower() in _SENSITIVE_HEADERS: + out[k] = _BEARER_RE.sub(r"\1***", v) if "bearer" in v.lower() else "***" + else: + out[k] = v + return out + + +def _truncate(body: str | None) -> str | None: + if body is None: + return None + if len(body) > _MAX_BODY_SIZE: + return body[:_MAX_BODY_SIZE] + f"\n... [truncated, total {len(body)} chars]" + return body + + +# ── Collector state ────────────────────────────────────────────────── + + +@dataclass +class _CollectorState: + exchanges: list[HttpExchange] = field(default_factory=list) + + +_collector_state: contextvars.ContextVar[_CollectorState | None] = contextvars.ContextVar("_collector_state", default=None) + + +class HttpCollector: + """Collect :class:`HttpExchange` instances for the current async context.""" + + @staticmethod + def init() -> None: + """Start a fresh collection scope (call once per step).""" + _collector_state.set(_CollectorState()) + + @staticmethod + def record(exchange: HttpExchange) -> None: + """Append an exchange to the current scope (no-op if not initialised).""" + state = _collector_state.get() + if state is not None: + state.exchanges.append(exchange) + + @staticmethod + def drain() -> list[HttpExchange]: + """Return all collected exchanges and reset the scope.""" + state = _collector_state.get() + if state is None: + return [] + exchanges = state.exchanges + _collector_state.set(None) + return exchanges + + # Convenience: build + record from raw httpx objects ─────────────── + + @staticmethod + def record_from_httpx( + *, + method: str, + url: str, + request_headers: dict[str, str], + request_body: Any, + response: Any | None, + start: float, + error: str | None = None, + ) -> None: + """Build an :class:`HttpExchange` from httpx-style data and record it.""" + duration_ms = (time.time() - start) * 1000 + resp_status = getattr(response, "status_code", None) if response else None + resp_headers = dict(response.headers) if response and hasattr(response, "headers") else {} + resp_body: str | None = None + if response is not None: + try: + resp_body = response.text + except Exception: + resp_body = "" + + exchange = HttpExchange( + method=method, + url=url, + request_headers=_sanitise_headers(request_headers), + request_body=request_body, + response_status=resp_status, + response_headers=_sanitise_headers(resp_headers), + response_body=_truncate(resp_body), + duration_ms=duration_ms, + error=error, + ) + HttpCollector.record(exchange) diff --git a/src/openutm_verification/core/reporting/reporting_models.py b/src/openutm_verification/core/reporting/reporting_models.py index 5833ef30..945601d0 100644 --- a/src/openutm_verification/core/reporting/reporting_models.py +++ b/src/openutm_verification/core/reporting/reporting_models.py @@ -6,11 +6,12 @@ from enum import StrEnum from typing import Any, Generic, TypeVar -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from uas_standards.astm.f3411.v22a.api import RIDAircraftState from openutm_verification.core.execution.config_models import DeploymentDetails from openutm_verification.core.flight_phase import FlightPhase +from openutm_verification.core.reporting.http_collector import HttpExchange from openutm_verification.simulator.models.declaration_models import ( FlightDeclaration, FlightDeclarationViaOperationalIntent, @@ -44,6 +45,7 @@ class StepResult(BaseModel, Generic[T]): result: T = None # type: ignore error_message: str | None = None logs: list[str] = [] + http_exchanges: list[HttpExchange] = Field(default_factory=list, exclude=True) class ScenarioResult(BaseModel): diff --git a/src/openutm_verification/server/main.py b/src/openutm_verification/server/main.py index ab08514c..e18a63f3 100644 --- a/src/openutm_verification/server/main.py +++ b/src/openutm_verification/server/main.py @@ -24,6 +24,7 @@ FlightBlenderConfig, ) from openutm_verification.core.execution.definitions import ScenarioDefinition +from openutm_verification.core.reporting.allure_reporter import AllureScenarioReporter from openutm_verification.core.reporting.reporting import create_report_data, generate_reports from openutm_verification.core.reporting.reporting_models import ( ScenarioResult, @@ -358,6 +359,14 @@ async def generate_report_endpoint(request: GenerateReportRequest, runner: Sessi config.reporting, ) + # Generate Allure report if enabled + if config.reporting.allure.enabled: + allure_reporter = AllureScenarioReporter(config.reporting.allure.results_dir) + allure_reporter.start_scenario(request.scenario_name) + allure_reporter.record_steps(steps) + allure_reporter.end_scenario(scenario_result) + allure_reporter.close() + # Get the actual report directory for the response report_id = runner.current_timestamp_str or run_id return {"status": "success", "report_id": report_id} diff --git a/src/openutm_verification/server/router.py b/src/openutm_verification/server/router.py index df57866a..65cdd0eb 100644 --- a/src/openutm_verification/server/router.py +++ b/src/openutm_verification/server/router.py @@ -1,3 +1,5 @@ +import asyncio +import shutil from pathlib import Path from typing import Any, Type, TypeVar @@ -172,3 +174,70 @@ async def get_latest_report(request: Request, scenario: str | None = None): url = f"/reports/{relative_path}" return RedirectResponse(url=url) + + +@scenario_router.post("/api/allure/generate") +async def generate_allure_report(request: Request): + """Run ``allure generate`` to build HTML from allure-results.""" + runner = request.app.state.runner + allure_cfg = runner.config.reporting.allure + + if not allure_cfg.enabled: + raise HTTPException(status_code=400, detail="Allure reporting is not enabled in config") + + results_dir = Path(allure_cfg.results_dir) + if not results_dir.exists() or not any(results_dir.iterdir()): + raise HTTPException(status_code=404, detail="No Allure results found. Run a scenario first.") + + output_dir = results_dir.parent / "allure-report" + + # Clean previous report so stale files don't linger + if output_dir.exists(): + shutil.rmtree(output_dir) + + # Try allure CLI first, fall back to npx (no global install needed) + allure_cmd = shutil.which("allure") + if allure_cmd: + cmd = [allure_cmd, "generate", str(results_dir), "--output", str(output_dir)] + else: + cmd = ["npx", "allure", "generate", str(results_dir), "--output", str(output_dir)] + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60) + except FileNotFoundError as exc: + raise HTTPException( + status_code=500, + detail="Neither 'allure' nor 'npx' found. Install Allure CLI: brew install allure", + ) from exc + except asyncio.TimeoutError as exc: + raise HTTPException(status_code=500, detail="Allure generate timed out after 60s") from exc + + if proc.returncode != 0: + detail = stderr.decode(errors="replace").strip() or stdout.decode(errors="replace").strip() + raise HTTPException(status_code=500, detail=f"allure generate failed: {detail}") + + return {"status": "success", "report_url": "/api/allure/report"} + + +@scenario_router.get("/api/allure/report") +async def get_allure_report(request: Request): + """Redirect to the generated Allure HTML report.""" + runner = request.app.state.runner + allure_cfg = runner.config.reporting.allure + report_index = Path(allure_cfg.results_dir).parent / "allure-report" / "index.html" + + if not report_index.exists(): + raise HTTPException( + status_code=404, + detail="Allure report not generated yet. Call POST /api/allure/generate first.", + ) + + # Served via the /reports static mount + output_dir = Path(runner.config.reporting.output_dir) + relative_path = report_index.relative_to(output_dir) + return RedirectResponse(url=f"/reports/{relative_path}") diff --git a/uv.lock b/uv.lock index 9484fc69..3681c066 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,19 @@ resolution-markers = [ "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[[package]] +name = "allure-python-commons" +version = "2.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/93/609cf76e204567cb618b59208f34468bbd434f34fcdce3d193d8f927abcd/allure_python_commons-2.15.3.tar.gz", hash = "sha256:b42a96d6076fb323c9e43645dfb84c0574f6bad0a0e005d92564015cd172d564", size = 15208, upload-time = "2025-12-30T05:21:46.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/2e/a823ab87aa8ed064d7efc817d79eb5f013465514f8d74487f1519849049f/allure_python_commons-2.15.3-py3-none-any.whl", hash = "sha256:50e9b346d8a060c84af8d19f221bd9da6e1aa0002a4e7f770e151167365219d0", size = 16212, upload-time = "2025-12-30T05:21:45.861Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -1467,6 +1480,7 @@ name = "openutm-verification" version = "0.2.0" source = { editable = "." } dependencies = [ + { name = "allure-python-commons" }, { name = "arrow" }, { name = "bluesky-simulator" }, { name = "cam-track-gen" }, @@ -1528,6 +1542,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "allure-python-commons", specifier = ">=2.15.0" }, { name = "arrow", specifier = "==1.3.0" }, { name = "bluesky-simulator", specifier = "==1.1.0" }, { name = "cam-track-gen", git = "https://github.com/openutm-labs/Canadian-Airspace-Models.git" }, diff --git a/web-editor/src/components/ScenarioEditor.tsx b/web-editor/src/components/ScenarioEditor.tsx index dcb1a3d7..04752447 100644 --- a/web-editor/src/components/ScenarioEditor.tsx +++ b/web-editor/src/components/ScenarioEditor.tsx @@ -818,6 +818,43 @@ const ScenarioEditorContent = () => { } }, [currentScenarioName]); + const handleOpenAllureReport = useCallback(async () => { + const newWindow = window.open('', '_blank'); + + if (!newWindow) { + setReportError({ title: "Popup Blocked", message: "Please allow popups for this site to view reports." }); + return; + } + + newWindow.document.title = "Generating Allure Report..."; + newWindow.document.body.innerHTML = '
Generating Allure report…
'; + + try { + const genRes = await fetch('/api/allure/generate', { method: 'POST' }); + if (!genRes.ok) { + newWindow.close(); + let message = "Failed to generate Allure report."; + try { + const errorData = await genRes.json(); + if (errorData.detail) message = errorData.detail; + } catch { /* ignore */ } + setReportError({ title: "Allure Generation Failed", message }); + return; + } + + const res = await fetch('/api/allure/report'); + if (res.ok) { + newWindow.location.href = res.url; + } else { + newWindow.close(); + setReportError({ title: "Allure Report Not Found", message: "Report was generated but could not be opened." }); + } + } catch { + newWindow?.close(); + setReportError({ title: "Connection Error", message: "Failed to connect to the server." }); + } + }, []); + const handleRun = useCallback(async () => { // Clear previous results/errors from the UI immediately setNodes((nds) => nds.map(node => ({ @@ -1241,6 +1278,7 @@ const ScenarioEditorContent = () => { setIsDirty(true); }} onOpenReport={handleOpenReport} + onOpenAllureReport={handleOpenAllureReport} /> )} diff --git a/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx b/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx index 995c9a8d..e2a0fdd8 100644 --- a/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx +++ b/web-editor/src/components/ScenarioEditor/ScenarioInfoPanel.tsx @@ -12,6 +12,7 @@ interface ScenarioInfoPanelProps { onUpdateName: (name: string) => void; onUpdateDescription: (description: string) => void; onOpenReport: () => void; + onOpenAllureReport?: () => void; onClose?: () => void; } @@ -20,7 +21,7 @@ const DEFAULT_DOCS_HEIGHT = Math.floor(window.innerHeight * 0.45); const MIN_DOCS_HEIGHT = 80; const MAX_DOCS_HEIGHT_RATIO = 0.8; -export const ScenarioInfoPanel = ({ name, description, onUpdateName, onUpdateDescription, onOpenReport, onClose }: ScenarioInfoPanelProps) => { +export const ScenarioInfoPanel = ({ name, description, onUpdateName, onUpdateDescription, onOpenReport, onOpenAllureReport, onClose }: ScenarioInfoPanelProps) => { const { sidebarWidth: width, isResizing: isWidthResizing, startResizing: startWidthResize } = useSidebarResize(DEFAULT_WIDTH, 300, 800); const [docsHeight, setDocsHeight] = useState(DEFAULT_DOCS_HEIGHT); @@ -130,6 +131,28 @@ export const ScenarioInfoPanel = ({ name, description, onUpdateName, onUpdateDes Report )} + {name && onOpenAllureReport && ( + + )} {onClose && (