from collections import defaultdict from datetime import datetime import json import argparse import os import re def stringify(obj): if obj is None or obj == "": return "`None`" if isinstance(obj, list): return ', '.join(stringify(e) for e in obj) if isinstance(obj, dict): return ', '.join(f"`{k}: {stringify(v)}`" for k, v in obj.items()) return f"`{str(obj)}`" def normalize_nodeid(nodeid): """Convert pytest nodeid to Allure fullName format""" match = re.match(r"(tests[/\\].+?)\.py::(.+?)(?:\[.*)?$", nodeid) if match: file_part = match.group(1).replace("/", ".").replace("\\", ".") func_part = match.group(2) return f"{file_part}#{func_part}" return None def write_json_value(f, value, indent=0): prefix = " " * indent if isinstance(value, dict): if not value: f.write(f"{prefix}{{}}\n") else: for k, v in value.items(): if isinstance(v, (dict, list)): f.write(f"{prefix}{k}:\n") write_json_value(f, v, indent + 1) else: # valeur simple, on Γ©crit sur la mΓͺme ligne if v is None: f.write(f"{prefix}{k}: None\n") else: f.write(f"{prefix}{k}: {v}\n") elif isinstance(value, list): if not value: f.write(f"{prefix}[]\n") else: for item in value: write_json_value(f, item, indent) else: if value is None: f.write(f"{prefix}None\n") else: f.write(f"{prefix}{value}\n") def load_allure_metadata(allure_test_cases_dir): allure_data = {} if not os.path.exists(allure_test_cases_dir): print(f"❌ Allure document untraceable: {allure_test_cases_dir}") return allure_data print(f"Loading Allure files from: {allure_test_cases_dir}") for file in os.listdir(allure_test_cases_dir): if file.endswith(".json"): path = os.path.join(allure_test_cases_dir, file) with open(path, 'r', encoding='utf-8') as f: try: data = json.load(f) full_name = data.get("fullName") if not full_name: continue params = data.get("parameters", []) severity = data.get("extra", {}).get("severity", None) allure_data[full_name] = { "parameters": params, "severity": severity } except Exception as e: print(f"❌ Error in {file}: {e}") return allure_data def json_to_md_nested(json_path, md_path, allure_dir=None): with open(json_path) as f: data = json.load(f) allure_data = load_allure_metadata(allure_dir) if allure_dir else {} with open(md_path, 'w', encoding='utf-8') as f: f.write(f"# πŸ§ͺ Test Report\n") f.write(f"*Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n") if 'summary' in data: f.write("## πŸ“‹ Summary\n") for key, value in data['summary'].items(): f.write(f"- **{key.capitalize()}**: {stringify(value)}\n") duration = data.get("duration") f.write(f"- **Total Duration**: `{duration:.3f}`s\n" if duration else "- **Total Duration**: `None`\n") f.write("\n") if "tests" in data and "summary" in data: test_counter = 1 for test in data["tests"]: test['global_number'] = test_counter test_counter += 1 f.write("## πŸ”Ž Tests\n") summary_items = list(data["summary"].items()) total_index = next((i for i, (k, _) in enumerate(summary_items) if k == "total"), len(summary_items)) status_order = [k for k, _ in summary_items[:total_index]] tests_by_status = defaultdict(list) for test in data["tests"]: outcome = test.get("outcome", "unknown") tests_by_status[outcome].append(test) for status in status_order: if status not in tests_by_status: continue count = len(tests_by_status[status]) emoji = "βœ…" if status == "passed" else "❌" status_label = status.capitalize().replace('_', ' ') f.write(f"
\n{emoji} {status_label} ({count})\n\n") grouped = defaultdict(lambda: defaultdict(list)) for test in tests_by_status[status]: nodeid = test.get("nodeid", "") parts = nodeid.split("::") filename = parts[0].replace("tests\\", "").replace("tests/", "") funcname = parts[1].split("[")[0] grouped[filename][funcname].append(test) for filename, funcs in grouped.items(): f.write(f"
\nπŸ“ {filename}\n\n") for funcname, tests in funcs.items(): f.write(f"
\nπŸ”§ Function: `{funcname}`\n\n") for test in sorted(tests, key=lambda x: x['global_number']): f.write(f"
\n{emoji} #{test['global_number']}\n\n") nodeid = test.get("nodeid", "") f.write(f"- **Status:** {emoji} `{status}`\n") duration = test.get("call", {}).get("duration") f.write(f"- **Duration:** `{duration:.6f}` s\n" if duration else "- **Duration:** `None`\n") full_name = normalize_nodeid(nodeid) allure_info = allure_data.get(full_name) if allure_info: if allure_info["parameters"]: f.write("- **Parameters (Allure):**\n") for param in allure_info["parameters"]: name = param.get("name") val = param.get("value") f.write(f" - `{name}` = `{val}`\n") f.write("\n") if allure_info["severity"]: f.write(f"- **Severity:** `{allure_info['severity']}`\n") for phase in ['setup', 'call', 'teardown']: if phase in test: f.write(f"\n### πŸ”§ {phase.capitalize()} Phase\n\n") for field, value in test[phase].items(): if value is None: f.write(f"- **{field.capitalize()}:** None\n") else: f.write(f"
\nπŸ“Œ {field.capitalize()}\n\n") f.write("```\n") write_json_value(f, value) f.write("```\n") f.write("
\n\n") f.write("
\n\n") f.write("
\n\n") f.write("
\n\n") f.write("
\n\n") if "collectors" in data: f.write("## πŸ“š Collected files\n") grouped = defaultdict(list) for collector in data["collectors"]: nodeid = collector.get("nodeid", "unknown") path = nodeid.split("::")[0] main_folder = path.split("/")[0] if "/" in path else path grouped[main_folder].append(collector) for folder, collectors in grouped.items(): has_fail = any(c.get("outcome") != "passed" for c in collectors) folder_emoji = "βœ…" if not has_fail else "❌" f.write(f"
\n{folder_emoji} {folder} ({len(collectors)} tests)\n\n") outputs = [] for collector in collectors: outcome = collector.get("outcome", "unknown") nodeid = collector.get("nodeid", "unknown") short_node = nodeid.split("[")[0] results = collector.get("result", []) if outcome != "passed" and results: outputs.append(f"### ❌ {short_node}\n```\n" + "\n".join( f"{k}: {v}" if isinstance(item, dict) else str(item) for item in results for k, v in item.items() if isinstance(item, dict) ) + "\n```") if outputs: f.write("### 🧾 Error or Result Summary\n\n") for out in outputs: f.write(out + "\n") collectors_sorted = sorted(collectors, key=lambda c: c.get("nodeid", "").split("[")[0]) for collector in collectors_sorted: outcome = collector.get("outcome", "unknown") emoji = "βœ…" if outcome == "passed" else "❌" nodeid = collector.get("nodeid", "unknown") short_node = nodeid.split("[")[0] f.write(f"
\n{emoji} {short_node}\n\n") f.write(f"- **Outcome:** `{outcome}`\n") other_keys = {k: v for k, v in collector.items() if k not in {"nodeid", "outcome"}} if other_keys: f.write("- **Details:**\n") f.write("```\n") for k, v in other_keys.items(): f.write(f"{k}:\n") try: if v is None: f.write(" None\n") else: write_json_value(f, v, indent=1) except Exception as e: f.write(f" \n") f.write("\n") f.write("```\n") else: f.write("- **Details:** `None`\n") f.write("
\n\n") f.write("
\n\n") def main(): parser = argparse.ArgumentParser(description="Convert JSON test results to Markdown.") parser.add_argument("--input", required=True, help="Path to pytest JSON file") parser.add_argument("--output", required=True, help="Path to output Markdown file") parser.add_argument("--allure-dir", required=False, help="Directory of Allure test-cases (optional)") args = parser.parse_args() json_to_md_nested(args.input, args.output, args.allure_dir) print(f"βœ… Report generated at {args.output}") if __name__ == "__main__": main()