161 lines
6.0 KiB
Python
161 lines
6.0 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate one live browser/JSRPC action result before it is reported as success.
|
|
|
|
The validator deliberately checks evidence fields rather than service liveness. It
|
|
accepts either a raw /go response or an envelope with ``result`` and optional
|
|
bridge/tab/operation metadata.
|
|
|
|
Usage:
|
|
python3 scripts/validate_browser_evidence.py \
|
|
--input artifacts/jsrpc_smoke.json \
|
|
--output artifacts/browser_evidence.json
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
ERROR_MARKERS = ("JSRA_ERROR", "EvidenceMissing", "unavailable", "not verified")
|
|
|
|
|
|
def non_empty(value: Any) -> bool:
|
|
if value is None or value is False:
|
|
return False
|
|
if isinstance(value, str):
|
|
return bool(value.strip())
|
|
if isinstance(value, (list, dict)):
|
|
return bool(value)
|
|
return True
|
|
|
|
|
|
def path_value(root: dict[str, Any], *paths: str) -> tuple[Any, str | None]:
|
|
for path in paths:
|
|
value: Any = root
|
|
ok = True
|
|
for part in path.split("."):
|
|
if not isinstance(value, dict) or part not in value:
|
|
ok = False
|
|
break
|
|
value = value[part]
|
|
if ok:
|
|
return value, path
|
|
return None, None
|
|
|
|
|
|
def marker_in(value: Any) -> str | None:
|
|
try:
|
|
text = json.dumps(value, ensure_ascii=False)
|
|
except (TypeError, ValueError):
|
|
text = str(value)
|
|
lowered = text.lower()
|
|
for marker in ERROR_MARKERS:
|
|
if marker.lower() in lowered:
|
|
return marker
|
|
return None
|
|
|
|
|
|
def validate(payload: Any, require_session: bool = False) -> dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
return {"passed": False, "reason": "top_level_object_required"}
|
|
|
|
result = payload.get("result", payload)
|
|
# Legacy JSRPC /go responses commonly serialize the action result in the
|
|
# outer ``data`` field. Decode that envelope before checking evidence;
|
|
# an undecoded transport wrapper is not itself a missing business result.
|
|
if isinstance(result, dict) and isinstance(result.get("data"), str):
|
|
try:
|
|
decoded = json.loads(result["data"])
|
|
except (TypeError, ValueError):
|
|
decoded = None
|
|
if isinstance(decoded, dict):
|
|
result = decoded
|
|
if isinstance(result, dict) and isinstance(result.get("data"), dict):
|
|
data = result["data"]
|
|
elif isinstance(result, dict):
|
|
data = result
|
|
else:
|
|
return {"passed": False, "reason": "result_object_required"}
|
|
|
|
checks: dict[str, dict[str, Any]] = {}
|
|
|
|
plaintext, plaintext_path = path_value(data, "plaintext", "input", "value")
|
|
checks["plaintext"] = {"ok": non_empty(plaintext), "path": plaintext_path}
|
|
|
|
request_body, request_body_path = path_value(
|
|
data, "requestBody", "request.body", "request.bodyText", "ciphertext"
|
|
)
|
|
checks["request_body"] = {"ok": non_empty(request_body), "path": request_body_path}
|
|
|
|
status, status_path = path_value(data, "status", "httpStatus", "response.status")
|
|
checks["http_status"] = {
|
|
"ok": isinstance(status, int) and not isinstance(status, bool) and 100 <= status <= 599,
|
|
"path": status_path,
|
|
"value_type": type(status).__name__,
|
|
}
|
|
|
|
route, route_path = path_value(
|
|
data, "route", "request.route", "request.url", "url", "request.path"
|
|
)
|
|
checks["final_route"] = {"ok": non_empty(route), "path": route_path}
|
|
|
|
response, response_path = path_value(data, "response", "businessResponse", "body")
|
|
checks["business_response"] = {"ok": non_empty(response), "path": response_path}
|
|
|
|
error_marker = marker_in(result)
|
|
checks["no_error_marker"] = {"ok": error_marker is None, "marker": error_marker}
|
|
|
|
session = payload.get("session") or payload.get("browser") or {}
|
|
session_checks: dict[str, Any] = {"provided": bool(session)}
|
|
if session:
|
|
connected = session.get("connected", session.get("bridge", {}).get("connected"))
|
|
tab_id = session.get("tabId", session.get("tab", {}).get("tabId"))
|
|
document_id = session.get("documentId", session.get("tab", {}).get("documentId"))
|
|
session_checks.update({
|
|
"connected": connected is True,
|
|
"tab_id": non_empty(tab_id),
|
|
"document_id": non_empty(document_id),
|
|
})
|
|
elif require_session:
|
|
session_checks["reason"] = "session_metadata_required"
|
|
|
|
required = [item["ok"] for item in checks.values()]
|
|
if require_session:
|
|
required.extend(session_checks.get(key, False) for key in ("connected", "tab_id", "document_id"))
|
|
return {
|
|
"passed": all(required),
|
|
"evidence": {
|
|
"plaintext_present": checks["plaintext"]["ok"],
|
|
"request_body_present": checks["request_body"]["ok"],
|
|
"final_route_present": checks["final_route"]["ok"],
|
|
"http_status_present": checks["http_status"]["ok"],
|
|
"business_response_present": checks["business_response"]["ok"],
|
|
},
|
|
"checks": checks,
|
|
"session": session_checks,
|
|
"reportable_success": all(required),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Validate live browser/JSRPC evidence.")
|
|
parser.add_argument("--input", required=True)
|
|
parser.add_argument("--output", required=True)
|
|
parser.add_argument("--require-session", action="store_true",
|
|
help="Require connected bridge, tabId and documentId metadata.")
|
|
args = parser.parse_args()
|
|
|
|
payload = json.loads(Path(args.input).read_text(encoding="utf-8"))
|
|
report = validate(payload, require_session=args.require_session)
|
|
output = Path(args.output)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
print(json.dumps(report, ensure_ascii=False))
|
|
return 0 if report["passed"] else 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|