import json from pathlib import Path from collections import defaultdict from typing import List, Dict, Any class CucumberReportSplitter: """ Splits a BrowserStack-style combined Cucumber report into separate per-feature JSON reports. Handles malformed concatenated JSON fragments by scanning and decoding objects one-by-one. """ def __init__(self, source_file: str, output_dir: str): self.source_file = Path(source_file) self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) # ---------------------------------------------------------------------- # Public API # ---------------------------------------------------------------------- def split_by_feature(self) -> Dict[str, Path]: data = self._load_all_json_fragments() grouped = self._group_by_feature(data) written = {} for uri, features in grouped.items(): feature_name = Path(uri).stem out_file = self.output_dir / f"cucumber_{feature_name}.json" with out_file.open("w", encoding="utf-8") as f: json.dump(features, f, indent=2) written[uri] = out_file print(f"Found {len(grouped)} features, wrote {len(written)} files.") return written # ---------------------------------------------------------------------- # JSON LOADER — tolerates broken concatenated JSON reports # ---------------------------------------------------------------------- def _load_all_json_fragments(self) -> List[Dict[str, Any]]: if not self.source_file.exists(): raise FileNotFoundError(self.source_file) raw = self.source_file.read_text(encoding="utf-8") decoder = json.JSONDecoder() idx = 0 parsed: List[Dict[str, Any]] = [] while idx < len(raw): try: obj, offset = decoder.raw_decode(raw[idx:]) idx += offset # We expect BrowserStack to produce arrays of features if isinstance(obj, list): for item in obj: if isinstance(item, dict): parsed.append(item) elif isinstance(obj, dict): parsed.append(obj) except json.JSONDecodeError: # Skip a single character and try again idx += 1 print(f"🔍 Parsed {len(parsed)} JSON objects from combined report") return parsed # ---------------------------------------------------------------------- # URI EXTRACTION — handles missing "uri" field # BrowserStack sometimes omits uri, so we extract it from "location" # ---------------------------------------------------------------------- @staticmethod def _extract_uri_from_feature(feature: Dict[str, Any]) -> str | None: # Case 1: Proper Cucumber format if "uri" in feature: return feature["uri"] # Case 2: Extract from scenario "location" fields elements = feature.get("elements", []) for el in elements: loc = el.get("location") # example: "features/ios_basic_flow.feature:5" if (isinstance(loc, str) and loc.startswith("features/") and ":" in loc): return loc.split(":")[0] # keep only features/...feature return None # ---------------------------------------------------------------------- # GROUP BY FEATURE # ---------------------------------------------------------------------- def _group_by_feature(self, data: List[Dict[str, Any]]): grouped = defaultdict(list) skipped = 0 for feature in data: if not isinstance(feature, dict): skipped += 1 continue uri = self._extract_uri_from_feature(feature) if uri: grouped[uri].append(feature) else: skipped += 1 print(f"Grouped into {len(grouped)} features. " f"Skipped {skipped} items.") return grouped