#!/usr/bin/env python3 """Shared execution policy for tester's integration-first runs. This module intentionally has no browser, mobile, deployment, or API driver. Those adapters belong to the caller. It only controls the phase gate and the stable five-state testcase result propagation so a caller cannot create a journey session before preflight and integration have passed. """ from __future__ import annotations from collections.abc import Callable, Iterable, Mapping from typing import Any from failure_feedback import collect_issues RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run") PHASES = ("preflight", "integration", "journey") def classify_integration(*, ok: bool, service_reachable: bool = True) -> str: """Separate a contract assertion failure from an unavailable service.""" if not service_reachable: return "environment-fail" return "pass" if ok else "fail" def _case_id(case: Mapping[str, Any] | str) -> str: if isinstance(case, Mapping): return str(case.get("id") or "") return str(case) def _not_run( cases: Iterable[Mapping[str, Any] | str], reason: str, *, blocked_by: Iterable[str] = (), ) -> list[dict[str, Any]]: rows: list[dict[str, str]] = [] blockers = [str(item) for item in blocked_by if str(item)] for case in cases: row: dict[str, Any] = {"id": _case_id(case), "result": "not-run", "notes": reason} if blockers: row["blocked_by"] = blockers if isinstance(case, Mapping): row["test_type"] = str(case.get("test_type") or "") row["suite"] = str( case.get("client_suite") or case.get("integration_suite") or case.get("suite") or "" ) rows.append(row) return rows def _dependencies( case: Mapping[str, Any] | str, *, default: Iterable[str] = (), ) -> list[str]: if not isinstance(case, Mapping): return [str(item) for item in default] declared = case.get("depends_on", case.get("dependencies")) if declared is None: return [str(item) for item in default] if isinstance(declared, str): return [declared] if isinstance(declared, Iterable): return [str(item) for item in declared if str(item)] raise ValueError(f"depends_on 必须是字符串或列表: {_case_id(case)}") def _failed_dependencies(dependencies: Iterable[str], results: Mapping[str, Mapping[str, Any]]) -> list[str]: return [ dependency for dependency in dependencies if str((results.get(dependency) or {}).get("result")) in {"fail", "environment-fail", "not-run"} ] def _issue_structures(results: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: issues = collect_issues(results) return {"defects": issues, "test_issues": [dict(issue) for issue in issues]} def run_integration_first( *, preflight_status: str, integration_cases: Iterable[Mapping[str, Any] | str], journey_cases: Iterable[Mapping[str, Any] | str], execute_integration: Callable[[Mapping[str, Any] | str], str | Mapping[str, Any]], create_journey_session: Callable[[], Any], execute_journey: Callable[[Any, Mapping[str, Any] | str], str | Mapping[str, Any]], ) -> dict[str, Any]: """Execute integration cases before creating any journey session. ``preflight_status`` must be the status emitted by test-preflight. A blocked/environment-fail gate records only ``not-run`` testcase rows and never invokes either adapter. An integration failure stops the phase and propagates ``not-run`` to all journey cases. """ integration = list(integration_cases) journey = list(journey_cases) if preflight_status != "ready": reason = f"test-preflight={preflight_status},未创建客户端 session" result_rows = _not_run(integration, reason, blocked_by=("test-preflight",)) + _not_run(journey, reason, blocked_by=("test-preflight",)) return { "status": preflight_status, "phase": "preflight", "session_created": False, "results": result_rows, **_issue_structures(result_rows), } results: list[dict[str, Any]] = [] results_by_id: dict[str, dict[str, Any]] = {} for case in integration: case_id = _case_id(case) dependencies = _dependencies(case) blocked_by = _failed_dependencies(dependencies, results_by_id) if blocked_by: rows = _not_run( [case], "依赖的 integration 失败,未执行", blocked_by=blocked_by, ) results.extend(rows) results_by_id[case_id] = rows[0] continue outcome = execute_integration(case) row = dict(outcome) if isinstance(outcome, Mapping) else {"result": outcome} row["id"] = _case_id(case) if isinstance(case, Mapping): row.setdefault("test_type", str(case.get("test_type") or "integration")) row.setdefault("suite", str(case.get("integration_suite") or case.get("suite") or "")) result = str(row.get("result") or "not-run") if result not in RESULTS: raise ValueError(f"未知 integration 结果: {row['id']}={result}") row["result"] = result results.append(row) results_by_id[case_id] = row integration_ids = [_case_id(case) for case in integration] has_runnable_journey = any( not _failed_dependencies(_dependencies(case, default=integration_ids), results_by_id) for case in journey ) session = create_journey_session() if has_runnable_journey else None for case in journey: dependencies = _dependencies(case, default=integration_ids) blocked_by = _failed_dependencies(dependencies, results_by_id) if blocked_by: row = _not_run( [case], "依赖的用例失败,journey 未执行", blocked_by=blocked_by, )[0] results.append(row) results_by_id[_case_id(case)] = row continue outcome = execute_journey(session, case) row = dict(outcome) if isinstance(outcome, Mapping) else {"result": outcome} row["id"] = _case_id(case) if isinstance(case, Mapping): row.setdefault("test_type", str(case.get("test_type") or "journey")) row.setdefault("suite", str(case.get("client_suite") or case.get("suite") or "")) result = str(row.get("result") or "not-run") if result not in RESULTS: raise ValueError(f"未知 journey 结果: {row['id']}={result}") row["result"] = result results.append(row) results_by_id[_case_id(case)] = row status = "fail" if any(row["result"] == "fail" for row in results) else ( "environment-fail" if any(row["result"] == "environment-fail" for row in results) else "pass" ) return { "status": status, "phase": "journey" if journey else "integration", "session_created": has_runnable_journey, "results": results, **_issue_structures(results), }