feat(tester): add client journey testing standard
This commit is contained in:
@@ -18,6 +18,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from journey import CLIENT_SUITES, client_suite, contract_errors, preflight as journey_preflight
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
@@ -781,6 +783,8 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
||||
continue
|
||||
if args.tag and case.get("tag") != args.tag:
|
||||
continue
|
||||
if args.suite and client_suite(case) != args.suite:
|
||||
continue
|
||||
if args.driver and case.get("driver") != args.driver:
|
||||
continue
|
||||
automation = str(case.get("automation") or "automated")
|
||||
@@ -810,6 +814,11 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
||||
|
||||
selected, together = expand_run_together(meta, cases, selected, skipped, max_i, automation_filter)
|
||||
|
||||
journey_gate = journey_preflight(selected, sessions=meta.get("sessions") or {})
|
||||
if journey_gate["status"] == "blocked":
|
||||
print(json.dumps({"preflight": journey_gate, "journey_preflight": journey_gate}, ensure_ascii=False, indent=2))
|
||||
return 2
|
||||
|
||||
preflight = mobile_preflight(
|
||||
root,
|
||||
meta,
|
||||
@@ -840,6 +849,7 @@ def cmd_select(args: argparse.Namespace, root: Path) -> int:
|
||||
"vars": vars_map,
|
||||
"artifacts": meta.get("artifacts") or {},
|
||||
"preflight": preflight,
|
||||
"journey_preflight": journey_gate,
|
||||
"skipped": skipped,
|
||||
"run_together": together,
|
||||
"status_probe": status_probes,
|
||||
@@ -922,6 +932,17 @@ def validate(root: Path) -> list[str]:
|
||||
if not isinstance(sessions, dict):
|
||||
errors.append("catalog.sessions 必须是 mapping")
|
||||
sessions = {}
|
||||
client_suites = meta.get("client_suites")
|
||||
if client_suites is not None:
|
||||
if not isinstance(client_suites, dict):
|
||||
errors.append("catalog.client_suites 必须是 mapping")
|
||||
else:
|
||||
unknown = sorted(set(client_suites) - set(CLIENT_SUITES))
|
||||
if unknown:
|
||||
errors.append("catalog.client_suites 含非法套件: " + ", ".join(unknown))
|
||||
missing_suites = sorted(set(CLIENT_SUITES) - set(client_suites))
|
||||
if missing_suites:
|
||||
errors.append("catalog.client_suites 缺少标准套件: " + ", ".join(missing_suites))
|
||||
artifacts = meta.get("artifacts")
|
||||
if artifacts is not None:
|
||||
if not isinstance(artifacts, dict):
|
||||
@@ -998,6 +1019,7 @@ def validate(root: Path) -> list[str]:
|
||||
errors.append(f"{where}: category={cat} 与文件 category={file_cat} 不一致")
|
||||
if cat and cat_ids and cat not in cat_ids:
|
||||
errors.append(f"{where}: category={cat} 不在 catalog.categories")
|
||||
errors.extend(contract_errors(case, sessions=sessions))
|
||||
if case.get("lifecycle", "active") not in LIFECYCLES:
|
||||
errors.append(f"{where}: 非法 lifecycle")
|
||||
if case.get("tag") not in TAGS:
|
||||
@@ -1275,6 +1297,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p_sel.add_argument("--category")
|
||||
p_sel.add_argument("--id", action="append", dest="ids", default=[])
|
||||
p_sel.add_argument("--tag", choices=TAGS)
|
||||
p_sel.add_argument("--suite", "--client-suite", dest="suite", choices=CLIENT_SUITES,
|
||||
help="客户端旅程套件")
|
||||
p_sel.add_argument("--max-risk")
|
||||
p_sel.add_argument("--driver", choices=DRIVERS)
|
||||
p_sel.add_argument(
|
||||
|
||||
@@ -22,6 +22,8 @@ except ImportError: # pragma: no cover
|
||||
FIELDS = (
|
||||
"category",
|
||||
"id",
|
||||
"client_suite",
|
||||
"platform",
|
||||
"maps_to",
|
||||
"title",
|
||||
"what",
|
||||
@@ -160,6 +162,8 @@ def export_cases_csv(root: Path, out: Path | None = None) -> Path:
|
||||
{
|
||||
"category": case.get("category") or "",
|
||||
"id": cid,
|
||||
"client_suite": case.get("client_suite") or case.get("suite") or "",
|
||||
"platform": one_line(case.get("platform")),
|
||||
"maps_to": case.get("maps_to") or "",
|
||||
"title": case.get("title") or "",
|
||||
"what": one_line(case.get("what")),
|
||||
|
||||
@@ -25,6 +25,8 @@ EXECUTED_YES = frozenset({"pass", "fail", "environment-fail"})
|
||||
EXECUTED_NO = frozenset({"skipped", "not-run"})
|
||||
FIELDS = (
|
||||
"id",
|
||||
"client_suite",
|
||||
"platform",
|
||||
"result",
|
||||
"executed",
|
||||
"evidence",
|
||||
@@ -132,6 +134,8 @@ def export_run_csv(
|
||||
writer.writerow(
|
||||
{
|
||||
"id": cid,
|
||||
"client_suite": case.get("client_suite") or case.get("suite") or "",
|
||||
"platform": one_line(case.get("platform")),
|
||||
"result": result,
|
||||
"executed": executed_cell(result),
|
||||
"evidence": one_line(item.get("evidence")),
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user