#!/usr/bin/env python3 """ Frontend-aligned API probe runner. Runs machine-readable probe specs that mirror the current frontend's actual backend calls. This is intended to complement the existing backend-oriented E2E API suite with a CI-friendly smoke layer that exercises the same paths and payload shapes the UI uses today. """ import argparse import json import os import re import sys from datetime import datetime from typing import Any, Dict, List, Optional from urllib.parse import quote sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import api # noqa: E402 TOKEN_PATTERN = re.compile(r"\{([^{}]+)\}") def camel_to_upper_snake(value: str) -> str: normalized = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", value) normalized = re.sub(r"[^A-Za-z0-9]+", "_", normalized) return normalized.upper().strip("_") def parse_cli_vars(entries: List[str]) -> Dict[str, str]: result: Dict[str, str] = {} for entry in entries: if "=" not in entry: raise ValueError(f"Invalid --var value: {entry!r}. Expected KEY=VALUE.") key, value = entry.split("=", 1) key = key.strip() if not key: raise ValueError(f"Invalid --var value: {entry!r}. KEY must not be empty.") result[key] = value return result class FrontendCaseProbeRunner: def __init__(self, spec_path: str, cli_vars: Dict[str, str], report_path: Optional[str] = None): self.spec_path = spec_path self.report_path = report_path self.client = api.NexusAPIClient() self.global_vars: Dict[str, Any] = dict(cli_vars) self.results: List[Dict[str, Any]] = [] self.spec = self._load_spec() def _load_spec(self) -> Dict[str, Any]: with open(self.spec_path, "r") as f: data = json.load(f) if not isinstance(data, dict) or "cases" not in data: raise ValueError("frontend probe spec must be a JSON object with a top-level 'cases' array") if not isinstance(data["cases"], list): raise ValueError("frontend probe spec 'cases' must be a list") return data def _env_candidates(self, name: str) -> List[str]: snake = camel_to_upper_snake(name) return [name, name.upper(), snake, f"FRONTEND_{snake}"] def _lookup_var(self, name: str, variables: Dict[str, Any]) -> Any: if name in variables: return variables[name] for candidate in self._env_candidates(name): value = os.environ.get(candidate) if value not in ("", None): return value return None def _resolve_token(self, token: str, variables: Dict[str, Any]) -> Any: if token == "today": return datetime.now().strftime("%Y-%m-%d") if token == "timestamp": return datetime.now().strftime("%Y%m%d-%H%M%S") if token.startswith("env:"): return os.environ.get(token[4:]) if token.startswith("url:"): raw = self._resolve_token(token[4:], variables) return None if raw is None else quote(str(raw), safe="") return self._lookup_var(token, variables) def _substitute(self, obj: Any, variables: Dict[str, Any]) -> Any: if isinstance(obj, str): def replace_token(match: re.Match[str]) -> str: token = match.group(1) resolved = self._resolve_token(token, variables) return match.group(0) if resolved is None else str(resolved) return TOKEN_PATTERN.sub(replace_token, obj) if isinstance(obj, dict): return {key: self._substitute(value, variables) for key, value in obj.items()} if isinstance(obj, list): return [self._substitute(item, variables) for item in obj] return obj def _resolve_path(self, data: Any, path: str) -> Any: if path == "": return data current = data for segment in path.split("."): if segment == "": continue if segment == "length": if isinstance(current, (list, dict, str)): current = len(current) continue return None if isinstance(current, list): if not segment.isdigit(): return None index = int(segment) if index < 0 or index >= len(current): return None current = current[index] continue if isinstance(current, dict): if segment not in current: return None current = current[segment] continue return None return current def _is_missing(self, value: Any) -> bool: if value is None: return True if isinstance(value, str) and ("{" in value or value.strip() == ""): return True return False def _initialize_case_vars(self, case: Dict[str, Any]) -> Dict[str, Any]: variables = dict(self.global_vars) for key, value in case.get("variables", {}).items(): variables[key] = self._substitute(value, variables) missing = [] for name in case.get("required_vars", []): resolved = self._lookup_var(name, variables) if self._is_missing(resolved): missing.append(name) else: variables[name] = resolved if missing: raise ValueError(f"missing required variables: {', '.join(missing)}") return variables def _execute_http(self, method: str, endpoint: str, payload: Any) -> tuple[int, Any]: upper = method.upper() if upper == "GET": return self.client.get(endpoint) if upper == "POST": return self.client.post(endpoint, payload) if upper == "PATCH": return self.client.patch(endpoint, payload) if upper == "DELETE": return self.client.delete(endpoint) raise ValueError(f"Unsupported HTTP method: {method}") def _status_matches(self, expected_status: Any, actual_status: int) -> bool: if expected_status is None: return True if isinstance(expected_status, list): return actual_status in expected_status return actual_status == expected_status def run_case(self, case: Dict[str, Any]) -> Dict[str, Any]: case_id = case.get("id", "unknown") case_name = case.get("name", "") print(f"\n{'=' * 72}") print(f"[{case_id}] {case_name}") print(f"{'=' * 72}") result: Dict[str, Any] = { "id": case_id, "name": case_name, "description": case.get("description"), "frontend_sources": case.get("frontend_sources", []), "status": "PASS", "steps": [], "variables": {}, } try: variables = self._initialize_case_vars(case) except Exception as exc: result["status"] = "ERROR" result["error"] = str(exc) print(f" ERROR: {exc}") return result for step in case.get("steps", []): step_name = step.get("name", "unnamed-step") method = step.get("method", "GET").upper() endpoint = self._substitute(step.get("endpoint", ""), variables) payload = self._substitute(step.get("payload"), variables) expected_status = step.get("expected_status") print(f" -> {step_name}") print(f" {method} {endpoint}") step_result: Dict[str, Any] = { "name": step_name, "request": { "method": method, "endpoint": endpoint, "payload": payload, }, } try: status_code, body = self._execute_http(method, endpoint, payload) step_result["response"] = { "status_code": status_code, "body": body, } extracted: Dict[str, Any] = {} for var_name, path in step.get("extract", {}).items(): value = self._resolve_path(body, path) extracted[var_name] = value if value is not None: variables[var_name] = value self.global_vars[var_name] = value if extracted: step_result["extracted"] = extracted print(f" extracted: {json.dumps(extracted, ensure_ascii=False)}") if not self._status_matches(expected_status, status_code): step_result["status"] = "FAIL" step_result["error"] = f"status {status_code} != expected {expected_status}" result["status"] = "FAIL" print(f" FAIL: status {status_code} != expected {expected_status}") result["steps"].append(step_result) break step_result["status"] = "PASS" print(f" PASS: status {status_code}") result["steps"].append(step_result) except Exception as exc: step_result["status"] = "ERROR" step_result["error"] = str(exc) result["status"] = "ERROR" result["steps"].append(step_result) print(f" ERROR: {exc}") break result["variables"] = variables return result def run(self, case_id: Optional[str] = None) -> List[Dict[str, Any]]: cases = self.spec["cases"] if case_id: cases = [case for case in cases if case.get("id") == case_id] print("NexusCRM Frontend Probe Runner") print(f"Spec: {self.spec_path}") print(f"Cases: {len(cases)}") if case_id: print(f"Filter: {case_id}") self.results = [self.run_case(case) for case in cases] timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") report_path = self.report_path or os.path.join( os.path.dirname(self.spec_path), f"frontend-probe-report-{timestamp}.json", ) with open(report_path, "w") as f: json.dump(self.results, f, indent=2, default=str) passed = sum(1 for item in self.results if item["status"] == "PASS") failed = sum(1 for item in self.results if item["status"] == "FAIL") errors = sum(1 for item in self.results if item["status"] == "ERROR") print(f"\nReport: {report_path}") print(f"Overall: {passed} passed, {failed} failed, {errors} errors") return self.results def main() -> None: parser = argparse.ArgumentParser(description="Run frontend-aligned backend probes") parser.add_argument( "--spec", default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend_case_specs.json"), help="Path to the frontend probe spec JSON file", ) parser.add_argument("--case", help="Run only one case ID") parser.add_argument( "--include-mutations", action="store_true", help="Include probes that create or mutate records. Leave off for read-only local smoke checks.", ) parser.add_argument( "--var", action="append", default=[], help="Set or override a runtime variable using KEY=VALUE. Can be provided multiple times.", ) parser.add_argument("--report", help="Optional explicit report JSON path") args = parser.parse_args() try: cli_vars = parse_cli_vars(args.var) except ValueError as exc: print(f"ERROR: {exc}", file=sys.stderr) sys.exit(2) runner = FrontendCaseProbeRunner(args.spec, cli_vars, report_path=args.report) if not args.include_mutations: runner.spec["cases"] = [case for case in runner.spec["cases"] if not case.get("mutates_data")] results = runner.run(case_id=args.case) all_passed = all(item["status"] == "PASS" for item in results) sys.exit(0 if all_passed else 1) if __name__ == "__main__": main()