Files
.pouch/skills/test-preflight/scripts/preflight.py
T
laily adaa8e4edd feat: add test-preflight runner and deployer prepare adapters
agent-runtime executes fixed tester node operations over JSON stdin.
deployer prepare brings up the local test Compose service the same way.
test-preflight now passes the tester binding; tester select skips
unauthorized cases before creating a runner.
2026-10-19 09:28:50 +08:00

554 lines
36 KiB
Python

#!/usr/bin/env python3
"""Run the test-preflight verify/prepare contract."""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
import os
import platform as host_platform
import plistlib
import re
import shutil
import socket
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping
try:
import yaml
except ImportError as exc: # pragma: no cover
raise SystemExit("test-preflight 需要 PyYAML;请安装运行依赖后重试") from exc
PLATFORMS = {"android", "ios"}
STATUSES = {"ready", "blocked", "environment-fail"}
EVIDENCE_FIELDS = [
"check_id", "mode", "platform", "node", "target", "observed", "expected",
"status", "remediation", "timestamp",
]
SHA256 = re.compile(r"^[0-9a-fA-F]{64}$")
COMMIT = re.compile(r"^[0-9a-fA-F]{40}$")
def now() -> str:
return datetime.now(timezone.utc).isoformat()
def text(value: Any) -> str:
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, sort_keys=True)
return "" if value is None else str(value)
def status_from(value: Any, default: str = "environment-fail") -> str:
return value if value in STATUSES else default
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def command_text(command: list[str], *, payload: Mapping[str, Any] | None = None) -> tuple[int, str]:
try:
process = subprocess.run(
command,
input=(json.dumps(payload, ensure_ascii=False) if payload is not None else None),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
check=False,
)
except OSError as exc:
return 127, str(exc)
return process.returncode, process.stdout.strip()
class Evidence:
def __init__(self, mode: str) -> None:
self.mode = mode
self.started_at = now()
self.rows: list[dict[str, str]] = []
def add(
self,
check_id: str,
*,
platform: str = "all",
node: str = "coordinator",
target: str = "",
observed: Any = "",
expected: Any = "",
status: str,
remediation: str = "",
) -> None:
if status not in STATUSES:
raise ValueError(f"invalid evidence status: {status}")
self.rows.append({
"check_id": check_id,
"mode": self.mode,
"platform": platform,
"node": node,
"target": target,
"observed": text(observed),
"expected": text(expected),
"status": status,
"remediation": remediation,
"timestamp": now(),
})
def overall(self) -> str:
statuses = {row["status"] for row in self.rows}
if "environment-fail" in statuses:
return "environment-fail"
if "blocked" in statuses:
return "blocked"
return "ready"
def payload(self) -> dict[str, Any]:
return {
"schema_version": 1,
"mode": self.mode,
"status": self.overall(),
"started_at": self.started_at,
"finished_at": now(),
"summary": {state: sum(row["status"] == state for row in self.rows) for state in sorted(STATUSES)},
"checks": self.rows,
}
def load_config(path: Path) -> dict[str, Any]:
data = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("配置根必须是 mapping")
return data
def expand(value: str, config: Mapping[str, Any]) -> str:
return value.replace("{HOST}", text(config.get("host"))).replace("{PUBLIC_HOST}", text(config.get("public_host")))
def normalize_url(value: str) -> str:
return value if urllib.parse.urlsplit(value).scheme else "http://" + value.lstrip("/")
def argv(value: Any) -> list[str] | None:
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item for item in value):
return None
return value
def validate_shape(config: Mapping[str, Any], evidence: Evidence) -> bool:
ok = True
if config.get("schema_version") != 1:
evidence.add("config-schema", target="config", observed=config.get("schema_version"), expected="1", status="blocked", remediation="将 schema_version 设为 1")
ok = False
if not isinstance(config.get("project_root"), str) or not Path(text(config.get("project_root"))).is_absolute():
evidence.add("config-project-root", target="project_root", observed=config.get("project_root"), expected="absolute project_root", status="blocked", remediation="提供项目根绝对路径")
ok = False
if not isinstance(config.get("host"), str) or not config["host"].strip():
evidence.add("config-host", target="HOST", expected="non-empty host URL", status="blocked", remediation="提供 HOST")
ok = False
probes = config.get("service_probes")
if not isinstance(probes, list) or not probes:
evidence.add("config-service-probes", target="service_probes", expected="at least one probe", status="blocked", remediation="声明服务 URL 探针")
ok = False
else:
for index, probe in enumerate(probes):
if not isinstance(probe, dict) or not isinstance(probe.get("url"), str) or not probe["url"].strip():
evidence.add(f"config-service-probe-{index}", target=f"service_probes[{index}]", expected="url", status="blocked", remediation="为每个服务探针提供 URL")
ok = False
matrix = config.get("matrix")
if not isinstance(matrix, list) or not matrix:
evidence.add("config-matrix", target="matrix", expected="non-empty list", status="blocked", remediation="声明至少一个平台矩阵行")
return False
seen: set[str] = set()
for index, row in enumerate(matrix):
prefix = f"matrix-{index}"
if not isinstance(row, dict):
evidence.add(prefix, target=f"matrix[{index}]", expected="mapping", status="blocked", remediation="修正矩阵行")
ok = False
continue
platform_name = text(row.get("platform")).lower()
if platform_name not in PLATFORMS or platform_name in seen:
evidence.add(f"{prefix}-platform", platform=platform_name or "unknown", target=f"matrix[{index}].platform", expected="unique android or ios", status="blocked", remediation="为每个平台保留唯一矩阵行")
ok = False
seen.add(platform_name)
tester = row.get("tester")
if not isinstance(tester, dict) or not isinstance(tester.get("binding"), str) or not tester["binding"].startswith("tester."):
evidence.add(f"{prefix}-binding", platform=platform_name, target=f"matrix[{index}].tester.binding", expected="tester.<name>", status="blocked", remediation="由 agent-runtime 解析并传入 tester binding")
ok = False
if not isinstance(tester, dict) or not isinstance(tester.get("node"), str) or not tester["node"].strip():
evidence.add(f"{prefix}-node", platform=platform_name, target=f"matrix[{index}].tester.node", expected="actual tester node", status="blocked", remediation="提供实际 tester 节点名")
ok = False
artifact = row.get("artifact")
identity_key = "package" if platform_name == "android" else "bundle_id"
if not isinstance(artifact, dict) or not isinstance(artifact.get("path"), str) or not artifact["path"].strip():
evidence.add(f"{prefix}-artifact", platform=platform_name, target=f"matrix[{index}].artifact.path", expected="caller-provided artifact path", status="blocked", remediation="提供本轮 APK/IPA,不要让 preflight 猜产物")
ok = False
if not isinstance(artifact, dict) or not isinstance(artifact.get(identity_key), str) or not artifact[identity_key].strip():
evidence.add(f"{prefix}-identity", platform=platform_name, target=f"matrix[{index}].artifact.{identity_key}", expected="declared artifact identity", status="blocked", remediation=f"声明 artifact.{identity_key}")
ok = False
if isinstance(artifact, dict) and artifact.get("sha256") is not None and not SHA256.fullmatch(text(artifact.get("sha256"))):
evidence.add(f"{prefix}-sha256", platform=platform_name, target=f"matrix[{index}].artifact.sha256", expected="64 hex characters", status="blocked", remediation="提供正确 SHA-256 或删除字段")
ok = False
if isinstance(artifact, dict) and artifact.get("source_commit") is not None and not COMMIT.fullmatch(text(artifact.get("source_commit"))):
evidence.add(f"{prefix}-commit", platform=platform_name, target=f"matrix[{index}].artifact.source_commit", expected="40-character commit SHA", status="blocked", remediation="提供产物来源 commit SHA")
ok = False
return ok
def artifact_identity(path: Path, platform_name: str, artifact: Mapping[str, Any]) -> tuple[str | None, str]:
if platform_name == "ios":
try:
with zipfile.ZipFile(path) as archive:
names = [name for name in archive.namelist() if re.fullmatch(r"Payload/[^/]+\.app/Info\.plist", name)]
if len(names) != 1:
return None, "IPA 中没有唯一 Payload/*.app/Info.plist"
plist = plistlib.loads(archive.read(names[0]))
value = plist.get("CFBundleIdentifier")
return (value, "Info.plist CFBundleIdentifier") if isinstance(value, str) else (None, "Info.plist 缺少 CFBundleIdentifier")
except (OSError, zipfile.BadZipFile, plistlib.InvalidFileException) as exc:
return None, f"IPA 读取失败: {exc}"
command = argv(artifact.get("identity_command"))
if command:
command = [item.replace("{artifact}", str(path)) for item in command]
else:
tool = shutil.which("apkanalyzer") or shutil.which("aapt2") or shutil.which("aapt")
if not tool:
return None, "缺少 apkanalyzer/aapt2/aapt;无法读取 APK package identity"
command = [tool, "manifest", "application-id", str(path)] if tool.endswith("apkanalyzer") else [tool, "dump", "badging", str(path)]
code, output = command_text(command)
if code != 0:
return None, output[-500:] or f"identity command exited {code}"
match = re.search(r"(?:application-id|name)\s*[:=]\s*['\"]?([A-Za-z0-9_.]+)", output)
if not match:
match = re.search(r"package:\s+name='([^']+)'", output)
if not match:
match = re.fullmatch(r"\s*([A-Za-z0-9_.]+)\s*", output)
return (match.group(1), output[-500:]) if match else (None, "identity command output did not contain package")
def record_artifact(evidence: Evidence, row: Mapping[str, Any], platform_name: str, node: str, index: int) -> bool:
artifact = row.get("artifact") or {}
path = Path(text(artifact.get("path"))).expanduser()
prefix = f"{platform_name}-{index}-artifact"
if not path.is_file():
evidence.add(prefix, platform=platform_name, node=node, target=str(path), expected="existing caller-provided artifact", status="blocked", remediation="补齐 APK/IPA 路径;不要默认构建")
return False
actual_hash = sha256(path)
expected_hash = artifact.get("sha256")
if expected_hash and actual_hash.lower() != text(expected_hash).lower():
evidence.add(prefix + "-hash", platform=platform_name, node=node, target=str(path), observed=actual_hash, expected=expected_hash, status="blocked", remediation="确认产物路径或 SHA-256 与本轮构建身份一致")
return False
evidence.add(prefix + "-hash", platform=platform_name, node=node, target=str(path), observed=actual_hash, expected=expected_hash or "file SHA-256 recorded", status="ready")
identity, observed = artifact_identity(path, platform_name, artifact)
expected_identity = text(artifact.get("package" if platform_name == "android" else "bundle_id"))
if identity != expected_identity:
evidence.add(prefix + "-identity", platform=platform_name, node=node, target=str(path), observed=identity or observed, expected=expected_identity, status="environment-fail", remediation="使用与 catalog 身份一致的 APK/IPA,并重新校验产物")
return False
evidence.add(prefix + "-identity", platform=platform_name, node=node, target=str(path), observed=identity, expected=expected_identity, status="ready")
if artifact.get("source_commit"):
evidence.add(prefix + "-commit", platform=platform_name, node=node, target=str(path), observed=artifact.get("source_commit"), expected=artifact.get("source_commit"), status="ready")
return True
def local_platform_probe(platform_name: str) -> tuple[str, dict[str, dict[str, Any]]]:
if platform_name == "android":
adb = shutil.which("adb")
if not adb:
return "environment-fail", {"adb": {"status": "environment-fail", "observed": "missing", "expected": "Android SDK platform-tools", "remediation": "在 tester.android 节点安装并配置 adb"}}
code, output = command_text([adb, "devices", "-l"])
states = [line.split() for line in output.splitlines()[1:] if line.strip()]
device = next((parts[0] for parts in states if len(parts) > 1 and parts[1] == "device"), "")
bad = next((parts for parts in states if len(parts) > 1 and parts[1] in {"unauthorized", "offline"}), None)
if bad:
return "environment-fail", {"adb": {"status": "ready", "observed": output, "expected": "adb responds"}, "device": {"status": "environment-fail", "observed": " ".join(bad), "expected": "authorized device", "remediation": "解锁 Android 设备并接受 RSA 授权;offline 时重启 adb/设备"}}
if code != 0 or not device:
return "environment-fail", {"adb": {"status": "environment-fail", "observed": output, "expected": "one authorized device", "remediation": "连接并授权 Android device/emulator"}}
boot_code, boot = command_text([adb, "-s", device, "shell", "getprop", "sys.boot_completed"])
booted = boot_code == 0 and boot.strip() == "1"
return ("ready" if booted else "environment-fail", {
"adb": {"status": "ready", "observed": adb, "expected": "adb available"},
"device": {"status": "ready", "observed": device, "expected": "authorized device"},
"boot": {"status": "ready" if booted else "environment-fail", "observed": boot, "expected": "sys.boot_completed=1", "remediation": "等待 emulator boot complete 后重跑"},
})
if host_platform.system() != "Darwin":
return "environment-fail", {"macos": {"status": "environment-fail", "observed": host_platform.system(), "expected": "Darwin/macOS tester.ios node", "remediation": "将 tester.ios 指向可用 macOS,不要在 Linux 伪装 iOS"}}
xcrun = shutil.which("xcrun")
if not xcrun:
return "environment-fail", {"xcrun": {"status": "environment-fail", "observed": "missing", "expected": "xcrun and simctl", "remediation": "在 Mac 安装 Xcode Command Line Tools/Xcode"}}
code, output = command_text([xcrun, "simctl", "list", "devices", "available"])
booted = any("(Booted)" in line for line in output.splitlines())
return ("ready" if code == 0 and booted else "environment-fail", {
"macos": {"status": "ready", "observed": "Darwin", "expected": "Darwin/macOS"},
"xcrun": {"status": "ready" if code == 0 else "environment-fail", "observed": output[-500:], "expected": "simctl available", "remediation": "安装 Xcode 并接受许可"},
"boot": {"status": "ready" if booted else "environment-fail", "observed": "Booted" if booted else output[-300:], "expected": "Booted simulator", "remediation": "启动目标 Simulator 后重跑"},
})
def runner_call(runner: Mapping[str, Any], request: Mapping[str, Any]) -> tuple[str, str, dict[str, Any]]:
command = argv(runner.get("command"))
if not command:
return "blocked", "runner command missing", {}
code, output = command_text(command, payload=request)
if code != 0:
return "environment-fail", output[-800:], {}
try:
result = json.loads(output)
except json.JSONDecodeError:
return "environment-fail", output[-800:], {}
if not isinstance(result, dict):
return "environment-fail", "runner returned non-object", {}
return status_from(result.get("status")), text(result.get("observed", "")), result
def add_runner_checks(evidence: Evidence, result: Mapping[str, Any], *, prefix: str, platform_name: str, node: str, default_expected: str) -> str:
checks = result.get("checks")
if isinstance(checks, dict) and checks:
statuses: list[str] = []
for name, detail in checks.items():
detail = detail if isinstance(detail, dict) else {"observed": detail}
status = status_from(detail.get("status"), status_from(result.get("status")))
statuses.append(status)
evidence.add(f"{prefix}-{name}", platform=platform_name, node=node, target=text(detail.get("target", name)), observed=detail.get("observed", result.get("observed", "")), expected=detail.get("expected", default_expected), status=status, remediation=text(detail.get("remediation", "")))
return "environment-fail" if "environment-fail" in statuses else ("blocked" if "blocked" in statuses else "ready")
status = status_from(result.get("status"))
evidence.add(prefix, platform=platform_name, node=node, target=prefix, observed=result.get("observed", ""), expected=default_expected, status=status, remediation=text(result.get("remediation", "")))
return status
def require_runner_checks(evidence: Evidence, result: Mapping[str, Any], *, prefix: str, platform_name: str, node: str, required: set[str]) -> bool:
checks = result.get("checks")
missing = sorted(required - set(checks)) if isinstance(checks, dict) else sorted(required)
if not missing:
return True
evidence.add(prefix + "-contract", platform=platform_name, node=node, target="runner response", observed={"missing": missing}, expected=sorted(required), status="blocked", remediation="让 tester 节点 adapter 返回每个必需的 adb/boot、macOS/xcrun/simctl 或 DNS/TCP/HTTP 证据")
return False
def network_local(url: str) -> list[dict[str, Any]]:
parsed = urllib.parse.urlsplit(url)
host = parsed.hostname or ""
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
addresses = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
except OSError as exc:
return [{"name": "dns", "status": "environment-fail", "observed": str(exc), "expected": "resolvable hostname", "remediation": "检查 tester 节点 DNS/VPN/网络"}]
rows: list[dict[str, Any]] = [{"name": "dns", "status": "ready", "observed": sorted({item[4][0] for item in addresses}), "expected": "resolvable hostname"}]
try:
with socket.create_connection((host, port), timeout=5):
rows.append({"name": "tcp", "status": "ready", "observed": f"{host}:{port}", "expected": "TCP connection"})
except OSError as exc:
rows.append({"name": "tcp", "status": "environment-fail", "observed": str(exc), "expected": f"TCP {host}:{port}", "remediation": "检查端口、防火墙和测试节点到 HOST 的路由"})
return rows
try:
with urllib.request.urlopen(urllib.request.Request(url, method="GET"), timeout=5) as response:
code = response.status
rows.append({"name": "http", "status": "ready" if 200 <= code < 500 else "environment-fail", "observed": code, "expected": "HTTP 2xx-4xx", "remediation": "检查后端健康状态" if code >= 500 else ""})
except urllib.error.HTTPError as exc:
rows.append({"name": "http", "status": "ready" if 200 <= exc.code < 500 else "environment-fail", "observed": exc.code, "expected": "HTTP 2xx-4xx", "remediation": "检查后端服务,不把 5xx 当成可用" if exc.code >= 500 else ""})
except (OSError, urllib.error.URLError) as exc:
rows.append({"name": "http", "status": "environment-fail", "observed": str(exc), "expected": "HTTP response", "remediation": "检查服务监听、代理、TLS 和节点网络"})
return rows
def check_network(evidence: Evidence, config: Mapping[str, Any], row: Mapping[str, Any], platform_name: str, node: str, index: int) -> bool:
tester = row.get("tester") or {}
runner = tester.get("runner") if isinstance(tester, dict) else None
is_local = node in {"local", "localhost", "127.0.0.1"}
statuses: list[str] = []
for probe_index, probe in enumerate(config.get("service_probes") or []):
raw_url = text(probe.get("url"))
if "{PUBLIC_HOST}" in raw_url and not text(config.get("public_host")).strip():
evidence.add(f"{platform_name}-{index}-network-{probe_index}-public-host", platform=platform_name, node=node, target=raw_url, expected="PUBLIC_HOST configured", status="blocked", remediation="提供 PUBLIC_HOST 或改用 HOST 探针")
statuses.append("blocked")
continue
url = normalize_url(expand(raw_url, config))
if not urllib.parse.urlsplit(url).hostname:
evidence.add(f"{platform_name}-{index}-network-{probe_index}-url", platform=platform_name, node=node, target=url, expected="valid probe URL", status="blocked", remediation="修正 service_probes URL 或 HOST/PUBLIC_HOST")
statuses.append("blocked")
continue
if is_local and (not isinstance(runner, dict) or runner.get("kind", "local") == "local"):
details = network_local(url)
expected_statuses = probe.get("expected_status")
for detail in details:
if detail["name"] == "http" and isinstance(expected_statuses, list) and detail["status"] == "ready" and detail["observed"] not in expected_statuses:
detail = {**detail, "status": "environment-fail", "expected": expected_statuses, "remediation": "检查服务探针的 expected_status 或后端响应"}
status = status_from(detail["status"])
statuses.append(status)
evidence.add(f"{platform_name}-{index}-network-{probe_index}-{detail['name']}", platform=platform_name, node=node, target=url, observed=detail["observed"], expected=detail["expected"], status=status, remediation=detail.get("remediation", ""))
elif isinstance(runner, dict) and runner.get("kind") == "contract":
# The runner is selected by the tester binding. Keep the binding
# in the request just like platform_probe, otherwise the shared
# agent-runtime adapter cannot resolve the actual node/worktree.
binding = text(tester.get("binding")) if isinstance(tester, dict) else ""
status, observed, result = runner_call(runner, {"operation": "network_probe", "platform": platform_name, "node": node, "binding": binding, "url": url, "probe": probe})
result = result or {"status": status, "observed": observed}
complete = require_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-network-{probe_index}", platform_name=platform_name, node=node, required={"dns", "tcp", "http"})
network_status = add_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-network-{probe_index}", platform_name=platform_name, node=node, default_expected="DNS/TCP/HTTP reachable from actual tester node")
statuses.append(network_status if complete else "blocked")
else:
evidence.add(f"{platform_name}-{index}-network-{probe_index}", platform=platform_name, node=node, target=url, expected="node runner contract", status="blocked", remediation="远端 tester 必须提供 agent-runtime adapter runner")
statuses.append("blocked")
return "environment-fail" not in statuses and "blocked" not in statuses
def check_fixture_cleanup(evidence: Evidence, config: Mapping[str, Any], platform_name: str, node: str, index: int) -> bool:
ok = True
fixture = config.get("fixtures")
valid_fixture = isinstance(fixture, dict) and (not fixture.get("required", True) or bool(fixture.get("references")))
if not valid_fixture:
evidence.add(f"{platform_name}-{index}-fixture", platform=platform_name, node=node, target="fixtures", expected="required fixture references", status="blocked", remediation="声明隔离测试账号/fixture;不要在运行中临时创建")
ok = False
else:
evidence.add(f"{platform_name}-{index}-fixture", platform=platform_name, node=node, target="fixtures", observed=fixture.get("references"), expected="declared fixture references", status="ready")
cleanup = config.get("cleanup")
valid_noop = isinstance(cleanup, dict) and cleanup.get("required") is False and bool(text(cleanup.get("reason")).strip())
valid_steps = isinstance(cleanup, dict) and (bool(cleanup.get("steps")) or bool(text(cleanup.get("run")).strip()))
if not (valid_noop or valid_steps):
evidence.add(f"{platform_name}-{index}-cleanup", platform=platform_name, node=node, target="cleanup", expected="steps/run or explicit read-only reason", status="blocked", remediation="声明 teardown/rollback;清理缺失时禁止开始测试")
ok = False
else:
evidence.add(f"{platform_name}-{index}-cleanup", platform=platform_name, node=node, target="cleanup", observed=cleanup, expected="cleanup contract declared", status="ready")
return ok
def run(config: Mapping[str, Any], mode: str) -> Evidence:
evidence = Evidence(mode)
shape_ok = validate_shape(config, evidence)
deployment_ok = True
if mode == "prepare":
deployment = config.get("deployment")
environment = deployment.get("environment") if isinstance(deployment, dict) else None
command = argv(deployment.get("command")) if isinstance(deployment, dict) else None
if environment != "test":
evidence.add("deployment-environment", target="deployment.environment", observed=environment, expected="test", status="blocked", remediation="prepare 只允许 deployer 的 test 环境")
if not command:
evidence.add("deployment-command", target="deployment.command", expected="existing deployer adapter command", status="blocked", remediation="提供既有 deployer 的 JSON command contract;不要在 skill 内实现部署")
deployment_ok = False
elif not command:
evidence.add("deployment-command", target="deployment.command", expected="existing deployer adapter command", status="blocked", remediation="提供既有 deployer 的 JSON command contract;不要在 skill 内实现部署")
deployment_ok = False
else:
code, output = command_text(command, payload={"operation": "prepare", "environment": "test", "project_root": config.get("project_root"), "host": config.get("host"), "public_host": config.get("public_host")})
if code != 0:
evidence.add("deployment", target="test", observed=output[-800:], expected="deployer receipt status=ready", status="environment-fail", remediation="按 deployer 返回证据修复测试后端,再重跑 prepare")
deployment_ok = False
else:
try:
receipt = json.loads(output)
except json.JSONDecodeError:
receipt = {}
status = status_from(receipt.get("status"), "environment-fail") if isinstance(receipt, dict) else "environment-fail"
evidence.add("deployment", target="test", observed=receipt.get("observed", output[-800:]) if isinstance(receipt, dict) else output[-800:], expected="deployer receipt status=ready", status=status, remediation="检查既有 deployer 的测试环境 receipt" if status != "ready" else "")
deployment_ok = status == "ready"
if not shape_ok:
return evidence
for index, row in enumerate(config["matrix"]):
platform_name = text(row.get("platform")).lower()
tester = row.get("tester") or {}
node = text(tester.get("node"))
binding = text(tester.get("binding"))
evidence.add(f"{platform_name}-{index}-binding", platform=platform_name, node=node, target=binding, observed=binding, expected="resolved tester.<name> binding", status="ready")
artifact_ok = record_artifact(evidence, row, platform_name, node, index)
fixture_ok = check_fixture_cleanup(evidence, config, platform_name, node, index)
runner = tester.get("runner") if isinstance(tester, dict) else None
is_local = node in {"local", "localhost", "127.0.0.1"}
if is_local and (not isinstance(runner, dict) or runner.get("kind", "local") == "local"):
platform_status, details = local_platform_probe(platform_name)
for name, detail in details.items():
evidence.add(f"{platform_name}-{index}-platform-{name}", platform=platform_name, node=node, target=name, observed=detail.get("observed", ""), expected=detail.get("expected", "platform ready"), status=status_from(detail.get("status"), platform_status), remediation=detail.get("remediation", ""))
platform_ok = platform_status == "ready"
elif isinstance(runner, dict) and runner.get("kind") == "contract":
status, observed, result = runner_call(runner, {"operation": "platform_probe", "platform": platform_name, "node": node, "binding": binding})
result = result or {"status": status, "observed": observed}
required = {"adb", "device", "boot"} if platform_name == "android" else {"macos", "xcrun", "simctl", "boot"}
complete = require_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-platform", platform_name=platform_name, node=node, required=required)
platform_status = add_runner_checks(evidence, result, prefix=f"{platform_name}-{index}-platform", platform_name=platform_name, node=node, default_expected="platform/device ready")
platform_ok = complete and platform_status == "ready"
else:
evidence.add(f"{platform_name}-{index}-platform-runner", platform=platform_name, node=node, target=node, expected="node runner contract", status="blocked", remediation="远端 tester 必须由 agent-runtime 提供可执行 runner")
platform_ok = False
network_ok = check_network(evidence, config, row, platform_name, node, index)
row_ok = artifact_ok and fixture_ok and platform_ok and network_ok
if mode == "prepare":
artifact = row.get("artifact") or {}
identity = artifact.get("package" if platform_name == "android" else "bundle_id")
target_path = text(artifact.get("remote_path") or artifact.get("path"))
if not row_ok or not deployment_ok:
evidence.add(f"{platform_name}-{index}-install", platform=platform_name, node=node, target=target_path, observed="not-run", expected="verify and deployment ready", status="blocked", remediation="先修复前置检查;prepare 不伪造安装成功")
evidence.add(f"{platform_name}-{index}-start", platform=platform_name, node=node, target=text(identity), observed="not-run", expected="install ready", status="blocked", remediation="安装前置失败,未启动 app")
else:
install_request = {"operation": "install", "platform": platform_name, "node": node, "binding": binding, "artifact": target_path, "identity": identity}
start_request = {"operation": "start", "platform": platform_name, "node": node, "binding": binding, "identity": identity}
if is_local and (not isinstance(runner, dict) or runner.get("kind", "local") == "local"):
if platform_name == "android":
adb = shutil.which("adb") or "adb"
serial = os.environ.get("ANDROID_SERIAL", "")
install_code, install_output = command_text([adb, "-s", serial, "install", "-r", target_path] if serial else [adb, "install", "-r", target_path])
install_result = {"status": "ready" if install_code == 0 else "environment-fail", "observed": install_output}
launch = [adb, "-s", serial, "shell", "monkey", "-p", text(identity), "1"] if serial else [adb, "shell", "monkey", "-p", text(identity), "1"]
start_code, start_output = command_text(launch) if install_code == 0 else (1, "install failed")
start_result = {"status": "ready" if start_code == 0 else "environment-fail", "observed": start_output}
else:
install_result = {"status": "environment-fail", "observed": "local iOS install requires a macOS runner", "remediation": "use tester.ios contract runner on Mac"}
start_result = {"status": "blocked", "observed": "not-run", "remediation": "install failed"}
add_runner_checks(evidence, install_result, prefix=f"{platform_name}-{index}-install", platform_name=platform_name, node=node, default_expected="artifact installed")
add_runner_checks(evidence, start_result, prefix=f"{platform_name}-{index}-start", platform_name=platform_name, node=node, default_expected="app launched")
elif isinstance(runner, dict) and runner.get("kind") == "contract":
install_status, install_observed, install_result = runner_call(runner, install_request)
add_runner_checks(evidence, install_result or {"status": install_status, "observed": install_observed}, prefix=f"{platform_name}-{index}-install", platform_name=platform_name, node=node, default_expected="artifact installed")
if install_status == "ready":
start_status, start_observed, start_result = runner_call(runner, start_request)
add_runner_checks(evidence, start_result or {"status": start_status, "observed": start_observed}, prefix=f"{platform_name}-{index}-start", platform_name=platform_name, node=node, default_expected="app launched")
else:
evidence.add(f"{platform_name}-{index}-start", platform=platform_name, node=node, target=text(identity), observed="not-run", expected="install ready", status="blocked", remediation="安装失败,先修复 tester 节点")
return evidence
def write_evidence(evidence: Evidence, yaml_path: Path, csv_path: Path) -> dict[str, Any]:
payload = evidence.payload()
yaml_path.parent.mkdir(parents=True, exist_ok=True)
csv_path.parent.mkdir(parents=True, exist_ok=True)
yaml_path.write_text(yaml.safe_dump(payload, allow_unicode=True, sort_keys=False), encoding="utf-8")
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=EVIDENCE_FIELDS)
writer.writeheader()
writer.writerows(evidence.rows)
return {"status": payload["status"], "yaml": str(yaml_path), "csv": str(csv_path), "checks": len(evidence.rows), "summary": payload["summary"]}
def main() -> int:
parser = argparse.ArgumentParser(description="Verify or prepare a test environment and emit YAML/CSV evidence")
parser.add_argument("mode", choices=("verify", "prepare"))
parser.add_argument("--config", required=True, type=Path)
parser.add_argument("--yaml", dest="yaml_path", type=Path, default=Path("preflight.yaml"))
parser.add_argument("--csv", dest="csv_path", type=Path, default=Path("preflight.csv"))
args = parser.parse_args()
try:
config = load_config(args.config)
evidence = run(config, args.mode)
result = write_evidence(evidence, args.yaml_path, args.csv_path)
except (OSError, ValueError, yaml.YAMLError) as exc:
print(json.dumps({"status": "blocked", "error": str(exc)}, ensure_ascii=False), file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0 if result["status"] == "ready" else 1
if __name__ == "__main__":
raise SystemExit(main())