190 lines
7.6 KiB
Python
190 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared client-journey contract and result lifecycle helpers.
|
|
|
|
The mobile/browser driver still owns the real UI interaction. This module
|
|
owns the parts that must be identical for every client catalog: contract
|
|
validation, preflight blocking, and result propagation. It deliberately
|
|
returns the existing tester result vocabulary instead of introducing a second
|
|
report format.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
|
|
CLIENT_SUITES = ("client-smoke", "client-regression", "safety")
|
|
PLATFORMS = ("android", "ios")
|
|
FAILURE_POLICIES = ("stop", "continue")
|
|
RESULTS = ("pass", "fail", "environment-fail", "skipped", "not-run")
|
|
|
|
|
|
def client_suite(case: Mapping[str, Any]) -> str:
|
|
"""Return the canonical client suite, accepting ``suite`` as a migration alias."""
|
|
value = case.get("client_suite")
|
|
alias = case.get("suite")
|
|
if value and alias and str(value) != str(alias):
|
|
return ""
|
|
return str(value or alias or "")
|
|
|
|
|
|
def normalize_platforms(value: Any) -> list[str]:
|
|
if isinstance(value, str):
|
|
values = [value]
|
|
elif isinstance(value, (list, tuple)):
|
|
values = list(value)
|
|
else:
|
|
return []
|
|
return [str(item).strip().lower() for item in values if str(item).strip()]
|
|
|
|
|
|
def _non_empty_list(value: Any) -> bool:
|
|
return isinstance(value, list) and bool(value) and all(str(item).strip() for item in value)
|
|
|
|
|
|
def _has_account(fixture: Mapping[str, Any]) -> bool:
|
|
account = fixture.get("account")
|
|
if isinstance(account, Mapping):
|
|
return bool(str(account.get("role") or account.get("username") or "").strip())
|
|
return bool(str(account or "").strip())
|
|
|
|
|
|
def contract_errors(case: Mapping[str, Any], sessions: Mapping[str, Any] | None = None) -> list[str]:
|
|
"""Validate a client journey without requiring app-specific field names.
|
|
|
|
A case becomes a client journey when it declares ``client_suite`` (or the
|
|
temporary ``suite`` alias). Legacy non-client cases keep the existing
|
|
schema. Once opted in, all lifecycle inputs are explicit so a missing
|
|
fixture/cleanup can stop the run before any case result is fabricated.
|
|
"""
|
|
suite = client_suite(case)
|
|
journey = case.get("journey")
|
|
if not suite and journey is None:
|
|
return []
|
|
errors: list[str] = []
|
|
cid = str(case.get("id") or "?")
|
|
where = f"{cid}: client journey"
|
|
if case.get("client_suite") and case.get("suite") and str(case["client_suite"]) != str(case["suite"]):
|
|
errors.append(f"{where}: client_suite 与 suite 不一致")
|
|
if suite not in CLIENT_SUITES:
|
|
errors.append(f"{where}: client_suite 必须是 {', '.join(CLIENT_SUITES)}")
|
|
|
|
if not isinstance(journey, Mapping):
|
|
errors.append(f"{where}: journey 必须是 mapping")
|
|
journey = {}
|
|
for key in ("goal", "entry_state", "final_state"):
|
|
if not str(journey.get(key) or "").strip():
|
|
errors.append(f"{where}: journey.{key} 必填")
|
|
if not _non_empty_list(journey.get("actions")):
|
|
errors.append(f"{where}: journey.actions 必须是非空列表")
|
|
if not _non_empty_list(journey.get("assertions")):
|
|
errors.append(f"{where}: journey.assertions 必须是非空列表")
|
|
policy = str(journey.get("failure_policy") or "")
|
|
if policy not in FAILURE_POLICIES:
|
|
errors.append(f"{where}: journey.failure_policy 必须是 stop 或 continue")
|
|
|
|
fixture = case.get("fixture")
|
|
if not isinstance(fixture, Mapping):
|
|
errors.append(f"{where}: fixture 缺失,必须在 preflight 前声明")
|
|
elif not _has_account(fixture):
|
|
errors.append(f"{where}: fixture.account 缺失,必须声明测试账号或角色")
|
|
|
|
cleanup = case.get("cleanup")
|
|
if not isinstance(cleanup, Mapping):
|
|
errors.append(f"{where}: cleanup 缺失,必须在 preflight 前声明")
|
|
else:
|
|
steps = cleanup.get("steps")
|
|
command = cleanup.get("run")
|
|
explicitly_noop = cleanup.get("required") is False and str(cleanup.get("reason") or "").strip()
|
|
if not explicitly_noop and not _non_empty_list(steps) and not str(command or "").strip():
|
|
errors.append(f"{where}: cleanup 必须有非空 steps/run;只读用例也要显式声明 reason")
|
|
|
|
platforms = normalize_platforms(case.get("platform"))
|
|
if not platforms:
|
|
errors.append(f"{where}: platform 必须声明 android/ios")
|
|
elif any(platform not in PLATFORMS for platform in platforms):
|
|
errors.append(f"{where}: platform 只能是 {', '.join(PLATFORMS)}")
|
|
elif len(platforms) != len(set(platforms)):
|
|
errors.append(f"{where}: platform 不能重复")
|
|
|
|
driver = case.get("driver")
|
|
if driver not in ("mobile", "browser"):
|
|
errors.append(f"{where}: 客户端旅程 driver 必须是 mobile 或 browser")
|
|
if driver and driver in ("mobile", "browser"):
|
|
session = case.get("session")
|
|
if not str(session or "").strip():
|
|
errors.append(f"{where}: session 必须声明可复用客户端会话")
|
|
elif sessions is not None and session not in sessions:
|
|
errors.append(f"{where}: session={session} 未在 catalog.sessions 声明")
|
|
if suite == "safety" and str(case.get("tag") or "") == "smoke":
|
|
errors.append(f"{where}: safety 不得标记为 smoke")
|
|
return errors
|
|
|
|
|
|
def preflight(cases: Iterable[Mapping[str, Any]], sessions: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
"""Return a contract preflight result; no per-case result is created on block."""
|
|
cases = list(cases)
|
|
errors: list[str] = []
|
|
for case in cases:
|
|
errors.extend(contract_errors(case, sessions=sessions))
|
|
return {
|
|
"required": bool(errors or any(client_suite(case) for case in cases)),
|
|
"status": "blocked" if errors else "ready",
|
|
"failure_class": "contract" if errors else None,
|
|
"errors": errors,
|
|
}
|
|
|
|
|
|
def classify_outcome(
|
|
*,
|
|
ok: bool,
|
|
environment_ready: bool = True,
|
|
phase: str = "journey",
|
|
) -> str:
|
|
"""Classify one observed phase using the tester's five result states.
|
|
|
|
A valid test account is part of the client journey: a failed login is a
|
|
product ``fail``. Only an unavailable environment is ``environment-fail``.
|
|
"""
|
|
if not environment_ready:
|
|
return "environment-fail"
|
|
if not ok:
|
|
return "fail"
|
|
return "pass"
|
|
|
|
|
|
def propagate_results(
|
|
case_ids: Iterable[str],
|
|
observed: Mapping[str, str],
|
|
*,
|
|
skipped: Mapping[str, str] | None = None,
|
|
continue_on_fail: bool = False,
|
|
policies: Mapping[str, str] | None = None,
|
|
) -> list[dict[str, str]]:
|
|
"""Build ordered result rows and mark cases after a blocking failure not-run.
|
|
|
|
``skipped`` is intentional selection (risk/lifecycle/assumption); it is
|
|
never converted to ``not-run``. A product or environment failure stops
|
|
subsequent cases unless the caller explicitly opts into continuation.
|
|
"""
|
|
skipped = skipped or {}
|
|
policies = policies or {}
|
|
rows: list[dict[str, str]] = []
|
|
blocked = False
|
|
for raw_id in case_ids:
|
|
case_id = str(raw_id)
|
|
if case_id in skipped:
|
|
rows.append({"id": case_id, "result": "skipped", "notes": skipped[case_id]})
|
|
continue
|
|
if blocked:
|
|
rows.append({"id": case_id, "result": "not-run", "notes": "前置用例失败,未轮到执行"})
|
|
continue
|
|
result = observed.get(case_id, "not-run")
|
|
if result not in RESULTS:
|
|
raise ValueError(f"未知结果: {case_id}={result}")
|
|
rows.append({"id": case_id, "result": result})
|
|
policy = policies.get(case_id)
|
|
if result in ("fail", "environment-fail") and not (continue_on_fail or policy == "continue"):
|
|
blocked = True
|
|
return rows
|