#!/usr/bin/env python3 """ Scenario Runner for NexusCRM E2E Tests. Executes multi-step scenario files that chain API calls with shared state. """ import argparse import glob import json import os import re import sys import time from datetime import datetime from typing import Dict, List, Any, Optional sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import api import config class ScenarioRunner: """Executes E2E scenario files with sequential step execution and shared state.""" def __init__(self, base_path: str = None): self.base_path = base_path or os.path.dirname(os.path.abspath(__file__)) self.scenarios_dir = os.path.join(self.base_path, "scenarios") self.api_client = api.NexusAPIClient() self.variables = {} # Shared state across steps self.results = [] self.report_path = None def _substitute(self, obj: Any) -> Any: """Substitute {variable} references in strings, dicts, lists.""" if isinstance(obj, str): def replace_var(match): var_name = match.group(1) if var_name == "today": return datetime.now().strftime("%Y-%m-%d") return str(self.variables.get(var_name, match.group(0))) result = re.sub(r'\{([^}]+)\}', replace_var, obj) return result elif isinstance(obj, dict): return {k: self._substitute(v) for k, v in obj.items()} elif isinstance(obj, list): return [self._substitute(item) for item in obj] return obj def _extract_fields(self, response: Any, extract_rules: Dict) -> None: """Extract values from response and store in variables.""" if not extract_rules or not response: return if isinstance(response, dict): for var_name, field_path in extract_rules.items(): if field_path == "id": self.variables[var_name] = response.get("id") or response.get("Id") elif field_path == "totalSize": self.variables[var_name] = response.get("totalSize", 0) elif field_path == "QuoteNumber": self.variables[var_name] = response.get("QuoteNumber") elif field_path == "OrderNumber": self.variables[var_name] = response.get("OrderNumber") else: self.variables[var_name] = response.get(field_path) def _assertions(self, response: Any, assertions: List[Dict], context: str = "") -> tuple: """Run assertions against response. Returns (passed, error_msg).""" if not assertions or not response: return True, None if isinstance(response, dict): for a in assertions: field = a.get("field", "") operator = a.get("operator", "eq") expected = self._substitute(a.get("value")) actual = response.get(field) if operator == "eq": if str(actual) != str(expected): return False, f"{context}{field}: {actual!r} != {expected!r}" elif operator == "ne": if str(actual) == str(expected): return False, f"{context}{field}: {actual!r} == {expected!r}" elif operator == "gt": if not (actual is not None and int(actual) > int(expected)): return False, f"{context}{field}: {actual} <= {expected}" elif operator == "is_null": if (actual is None) != (not expected): return False, f"{context}{field}: is_null check failed" elif operator == "is_not_null": if actual is None: return False, f"{context}{field}: expected not null" elif operator == "matches": if not re.match(expected, str(actual)): return False, f"{context}{field}: {actual!r} does not match {expected!r}" return True, None def _execute_step(self, step: Dict, step_index: int) -> Dict: """Execute a single scenario step. Returns step result.""" step_id = step.get("step", step_index) name = step.get("name", f"Step {step_id}") api_def = step.get("api", {}) result = { "step": step_id, "name": name, "status": "PASS", "error": None, "request": {}, "response": {} } method = api_def.get("method", "GET").upper() endpoint = self._substitute(api_def.get("endpoint", "")) payload = self._substitute(api_def.get("payload")) result["request"] = {"method": method, "endpoint": endpoint, "payload": payload} try: if method == "GET": status_code, response = self.api_client.get(endpoint) elif method == "POST": status_code, response = self.api_client.post(endpoint, payload) elif method == "PATCH": status_code, response = self.api_client.patch(endpoint, payload) elif method == "DELETE": status_code, response = self.api_client.delete(endpoint) else: status_code, response = 400, {"error": f"Unsupported method: {method}"} result["response"] = {"status_code": status_code, "body": response} # Check expected status expects = step.get("expects", {}) expected_status = expects.get("status_code") if expected_status and status_code != expected_status: result["status"] = "FAIL" result["error"] = f"Status {status_code} != expected {expected_status}" return result # Extract variables from response extract = step.get("extract", {}) if extract: self._extract_fields(response, extract) # Run verify assertions verify = step.get("verify", {}) if verify: # query_after assertions qa = verify.get("query_after") if qa: object_name = qa.get("object") obj_id = self.variables.get(f"{object_name}Id") or self.variables.get(f"{object_name}_id") if obj_id: _, qa_resp = self.api_client.get(f"/services/data/v63.0/sobjects/{object_name}/{obj_id}") passed, err = self._assertions(qa_resp, qa.get("assertions", []), "query_after: ") if not passed: result["status"] = "FAIL" result["error"] = err result["response"]["query_after"] = qa_resp return result result["response"]["query_after"] = qa_resp # direct assertions on response direct_asserts = verify.get("assertions", []) if direct_asserts: passed, err = self._assertions(response, direct_asserts) if not passed: result["status"] = "FAIL" result["error"] = err return result except Exception as e: result["status"] = "ERROR" result["error"] = str(e) return result def _run_teardown(self, teardown: Dict) -> None: """Run teardown steps.""" if not teardown: return for step_def in teardown.get("steps", []): method = step_def.get("method", "DELETE").upper() endpoint = self._substitute(step_def.get("endpoint", "")) try: if method == "DELETE": self.api_client.delete(endpoint) elif method == "PATCH": self.api_client.patch(endpoint, step_def.get("payload")) except Exception as e: print(f" [teardown] warning: {e}") def _init_variables_from_seed(self, setup: Dict) -> None: """Load seed data and initialize variables.""" import mysql.connector seed_path = os.path.join(self.base_path, "seed_data.json") if not os.path.exists(seed_path): return with open(seed_path, "r") as f: seed_data = json.load(f) seed_keys = setup.get("seed_keys", []) for key in seed_keys: parts = key.split("_") if len(parts) >= 2: table = parts[0].lower() # e.g., "Account" index = int(parts[1]) table_key = f"{table.lower()}s" if table.lower() != "opportunity" else "opportunity" records = seed_data.get(table_key, seed_data.get(table.lower(), [])) if index < len(records): record = records[index] obj_id = record.get("Id") or record.get("id") if obj_id: self.variables[f"{table}Id"] = str(obj_id) # Also store OpportunityId specifically if table.lower() == "opportunity": self.variables["OpportunityId"] = str(obj_id) elif table.lower() == "account": self.variables["AccountId"] = str(obj_id) elif table.lower() == "contract": self.variables["ContractId"] = str(obj_id) def run_scenario(self, scenario_path: str) -> Dict: """Run a single scenario file. Returns result dict.""" with open(scenario_path, "r") as f: scenario = json.load(f) scenario_id = scenario.get("id", "unknown") scenario_name = scenario.get("name", "") print(f"\n{'='*60}") print(f"Scenario: {scenario_id} - {scenario_name}") print(f"{'='*60}") # Initialize from seed setup = scenario.get("setup", {}) self.variables = {} self._init_variables_from_seed(setup) print(f"[setup] Variables initialized from seed: {list(self.variables.keys())}") # Check if scenario should be skipped if scenario.get("skip"): print(f"\n{'='*60}") print(f"Scenario {scenario_id} SKIPPED (marked as skip)") print(f"{'='*60}") return { "scenario_id": scenario_id, "scenario_name": scenario_name, "status": "SKIP", "steps_total": len(scenario.get("steps", [])), "steps_passed": 0, "steps_failed": 0, "steps_errors": 0, "results": [] } # Run steps steps = scenario.get("steps", []) self.results = [] all_passed = True for step_def in steps: step_num = step_def.get("step", "?") name = step_def.get("name", "") print(f"\n[Step {step_num}] {name}...") result = self._execute_step(step_def, step_num) self.results.append(result) if result["status"] == "PASS": print(f" PASS") if result.get("response", {}).get("status_code"): print(f" Status: {result['response']['status_code']}") else: print(f" {result['status']}: {result.get('error', 'Unknown')}") all_passed = False print(f"\n [STOPPED] Step {step_num} failed — skipping remaining steps") # Run teardown and stop teardown = scenario.get("teardown", {}) if teardown: print(f"\n[teardown] Cleaning up...") self._run_teardown(teardown) break else: # Normal completion teardown (only runs if loop wasn't broken) teardown = scenario.get("teardown", {}) print(f"\n[teardown] Cleaning up...") self._run_teardown(teardown) # Summary passed = sum(1 for r in self.results if r["status"] == "PASS") failed = sum(1 for r in self.results if r["status"] == "FAIL") errors = sum(1 for r in self.results if r["status"] == "ERROR") print(f"\n{'='*60}") print(f"Scenario Result: {scenario_id}") print(f"Steps: {len(steps)} | Passed: {passed} | Failed: {failed} | Errors: {errors}") print(f"{'='*60}") return { "scenario_id": scenario_id, "scenario_name": scenario_name, "status": "PASS" if all_passed else "FAIL", "steps_total": len(steps), "steps_passed": passed, "steps_failed": failed, "steps_errors": errors, "results": self.results } def run(self, scenario_id: str = None) -> List[Dict]: """Run all scenarios or a specific one.""" scenarios = [] if scenario_id: pattern = os.path.join(self.scenarios_dir, f"{scenario_id}.json") if os.path.exists(pattern): scenarios = [pattern] else: patterns = glob.glob(os.path.join(self.scenarios_dir, "*.json")) scenarios = sorted(patterns) print(f"NexusCRM Scenario Runner") print(f"Scenarios found: {len(scenarios)}") if scenario_id: print(f"Filter: {scenario_id}") all_results = [] for path in scenarios: result = self.run_scenario(path) all_results.append(result) # Save report timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") self.report_path = os.path.join(self.base_path, f"scenario-report-{timestamp}.json") with open(self.report_path, "w") as f: json.dump(all_results, f, indent=2, default=str) print(f"\nReport: {self.report_path}") total_passed = sum(1 for r in all_results if r["status"] == "PASS") total_skipped = sum(1 for r in all_results if r["status"] == "SKIP") total_failed = sum(1 for r in all_results if r["status"] == "FAIL") print(f"\nOverall: {total_passed} passed, {total_skipped} skipped, {total_failed} failed out of {len(all_results)} scenarios") return all_results def main(): parser = argparse.ArgumentParser(description="NexusCRM Scenario Runner") parser.add_argument("--scenario", type=str, help="Run specific scenario ID (e.g. SCN-QTO-001)") args = parser.parse_args() runner = ScenarioRunner() results = runner.run(scenario_id=args.scenario) # Exit code: 0 if all passed or skipped, 1 if any failed all_passed = all(r["status"] in ("PASS", "SKIP") for r in results) sys.exit(0 if all_passed else 1) if __name__ == "__main__": main()