#!/usr/bin/env python3 """ Parse pytest commands from Buildkite test-ready.yml, test-merge.yml, test-nightly.yml, and test-weekly.yml; collect test cases (including parametrized) via pytest --collect-only -q and produce an HTML report. Leaf steps may live under ``group:`` blocks (nested ``steps``); those are flattened with the group title carried into the report. Usage (run from repo root): python tools/nightly/buildkite_testcase_statistics.py -o buildkite_testcase_statistics.html python tools/nightly/buildkite_testcase_statistics.py --sample-preview -o buildkite_testcase_statistics_preview.html ``--sample-preview`` emits placeholder rows only (no pytest / no real counts). Requires: PyYAML (pip install pyyaml) """ from __future__ import annotations import argparse import ast import html import re import subprocess import sys from pathlib import Path import yaml # Repo root (parent of the directory containing this script) REPO_ROOT = Path(__file__).resolve().parent.parent.parent BUILDKITE_DIR = REPO_ROOT / ".buildkite" PIPELINE_FILES = ["test-ready.yml", "test-merge.yml", "test-nightly.yml", "test-weekly.yml"] def load_yaml(path: Path) -> dict: with open(path, encoding="utf-8") as f: return yaml.safe_load(f) def iter_pytest_leaf_steps(steps: list, *, group: str | None = None) -> list[tuple[dict, str | None]]: """ Flatten Buildkite YAML steps, expanding ``group`` blocks that contain nested ``steps``. Returns ``(leaf_step_dict, group_title)`` for each leaf step that has non-empty ``commands``. ``group_title`` is ``None`` for top-level steps outside any ``group:`` block. """ out: list[tuple[dict, str | None]] = [] for raw in steps or []: if not isinstance(raw, dict): continue nested = raw.get("steps") if isinstance(nested, list) and nested: next_group = group if "group" in raw: g = raw.get("group") next_group = g if isinstance(g, str) else (str(g) if g is not None else group) out.extend(iter_pytest_leaf_steps(nested, group=next_group)) continue if raw.get("commands"): out.append((raw, group)) return out PYTEST_CMD_RE = re.compile(r"(?:timeout\s+\S+\s+)?(?:python3? -m\s+)?pytest\s+[^\n&|;]*") def normalize_commands(step: dict) -> list[str]: raw = step.get("commands") or [] if isinstance(raw, str): raw = [raw] text = "\n".join((c.strip() if isinstance(c, str) else "") for c in raw if c) text = text.replace("$$", "$") result = [] for m in PYTEST_CMD_RE.finditer(text): line = m.group(0).strip() line_start = text.rfind("\n", 0, m.start()) + 1 before_match = text[line_start : m.start()] if before_match.lstrip().startswith("#"): continue if line: result.append(line) return result def extract_pytest_targets_from_line(line: str) -> list[str]: """ Parse pytest test targets (file path, directory under tests/, or marker expression) from a single command line. Returns e.g. ['tests/xxx.py'] or ['tests/e2e']. When there is no explicit path but markers/options apply to the whole tree, returns a synthetic token built from sidecar args (``--ignore``, ``-m``, ``-k``, ``--run-level``). """ if "pytest" not in line: return [] # Strip leading timeout and anything before pytest idx = line.find("pytest") if idx == -1: return [] rest = line[idx:].strip() if not rest.startswith("pytest"): return [] # Simple split on whitespace (no handling of quoted spaces) parts = re.split(r"\s+", rest, maxsplit=1) args = parts[1].strip() if len(parts) > 1 else "" if not args: return [] targets: list[str] = [] # Omit paths that only appear as --ignore / --test-config-file values when discovering roots. sanitized_for_roots = _strip_test_config_file_clauses_from_args_fragment( _strip_ignore_clauses_from_args_fragment(args) ) # Match tests/.../*.py for m in re.finditer(r"tests/[^\s'\"#]+\.py", sanitized_for_roots): path = m.group(0) if path not in targets: targets.append(path) # Directory / namespace roots: tests/e2e, tests/e2e/, tests/foo/bar (no .py suffix) for m in re.finditer(r"\s(tests/[a-zA-Z0-9_./\-]+)(?=\s|$|[\"'])", " " + sanitized_for_roots): tok = m.group(1).rstrip("/") if tok.endswith(".py"): continue if tok not in targets: targets.append(tok) for m in re.finditer(r"['\"](tests/[a-zA-Z0-9_./\-]+)['\"]", sanitized_for_roots): tok = m.group(1).rstrip("/") if tok.endswith(".py"): continue if tok not in targets: targets.append(tok) if not targets: # Marker-only / option-driven selection relative to tests/ extra = _pytest_collect_sidecar_args(line) if extra: targets.append(" ".join(extra)) return targets def get_pytest_targets_from_step(step: dict) -> list[tuple[str, str]]: """ Extract all pytest-related targets from a step. Returns [(target, raw_line), ...] where target is tests/xxx.py or '(marker/run-level)'. Deduplication uses ``(target, raw_line)`` so the same script path with different options (e.g. ``--test-config-file …``) stays distinct across Buildkite jobs. """ lines = normalize_commands(step) out = [] seen = set() for line in lines: for t in extract_pytest_targets_from_line(line): key = (t, line) if key not in seen: seen.add(key) out.append((t, line)) return out def _strip_ignore_clauses_from_args_fragment(fragment: str) -> str: """Replace ``--ignore=…`` / ``--ignore …`` spans so path discovery does not treat ignores as roots.""" return re.sub(r"--ignore(?:=\S+|\s+\S+)", " ", fragment) def _strip_test_config_file_clauses_from_args_fragment(fragment: str) -> str: """Replace ``--test-config-file`` spans so JSON paths under ``tests/`` are not mistaken for collection roots.""" return re.sub(r"--test-config-file(?:=\S+|\s+\S+)", " ", fragment) def _pytest_collect_sidecar_args(raw_line: str) -> list[str]: """Extract pytest args that affect collection: ``--ignore``, ``--test-config-file``, ``-m``, ``--run-level``, ``-k``. Order matches typical CI lines (ignores / perf driver config, then markers / run-level / keyword). Correctly handles quoted ``-m`` / ``-k`` / ``--run-level`` values with spaces. """ extra: list[str] = [] for m in re.finditer(r"--ignore(?:=(\S+)|\s+(\S+))", raw_line): path = (m.group(1) or m.group(2) or "").strip() if path: extra.extend(["--ignore", path]) for m in re.finditer(r"--test-config-file(?:=(\S+)|\s+(\S+))", raw_line): path = (m.group(1) or m.group(2) or "").strip() if path: extra.extend(["--test-config-file", path]) marker = re.search(r"-m\s+(?:'([^']*)'|\"([^\"]*)\"|(\S+))", raw_line) if marker: value = (marker.group(1) or marker.group(2) or marker.group(3) or "").strip() if value: extra.extend(["-m", value]) runlvl = re.search(r"--run-level\s+(?:'([^']*)'|\"([^\"]*)\"|(\S+))", raw_line) if runlvl: value = (runlvl.group(1) or runlvl.group(2) or runlvl.group(3) or "").strip() if value: extra.extend(["--run-level", value]) kw = re.search(r"-k\s+(?:'([^']*)'|\"([^\"]*)\"|(\S+))", raw_line) if kw: value = (kw.group(1) or kw.group(2) or kw.group(3) or "").strip() if value: extra.extend(["-k", value]) return extra def _parse_extra_args_from_line(raw_line: str) -> list[str]: """Backward-compatible alias for :func:`_pytest_collect_sidecar_args`.""" return _pytest_collect_sidecar_args(raw_line) def _parse_collect_only_stdout(stdout: str, *, raise_on_empty: bool = True, stderr: str = "") -> list[str]: """ Parse pytest --collect-only -q stdout into a list of node ids. Normalizes \\r\\n and \\r to \\n before splitting. If raise_on_empty and no node ids found, raises RuntimeError; otherwise returns []. """ raw = (stdout or "").replace("\r\n", "\n").replace("\r", "\n") out: list[str] = [] for line in raw.splitlines(): line = line.strip() if not line or "::" not in line or "test_" not in line: continue out.append(line) if not out and raise_on_empty: raise RuntimeError( f"Failed to parse pytest --collect-only output: no node ids found.\nstdout:\n{stdout}\n\nstderr:\n{stderr}" ) return out def _resolve_pytest_target(target: str, repo_root: Path, extra: list[str], raw_line: str) -> tuple[list[str], str, int]: """ Resolve target + repo_root + extra to (path_args, fallback_path, timeout). Raises FileNotFoundError/RuntimeError on invalid config. """ if target.endswith(".py"): # Target extracted from buildkite yaml may include glob patterns like # tests/e2e/online_serving/test_*_expansion.py. Pytest does not reliably # expand globs across platforms, so expand them here. rel = Path(target) if any(ch in target for ch in ["*", "?", "["]): matches = sorted(repo_root.glob(rel.as_posix())) if not matches: raise FileNotFoundError(f"Pytest target glob did not match any files: {repo_root / target}") return [str(p.resolve()) for p in matches], target.replace("\\", "/"), 90 path = (repo_root / rel).resolve() if not path.exists(): raise FileNotFoundError(f"Pytest target path does not exist: {path}") return [str(path)], target.replace("\\", "/"), 60 if " " not in target and not target.startswith("-") and target.startswith("tests/"): rel_dir = Path(target.rstrip("/")) dir_path = (repo_root / rel_dir).resolve() if dir_path.exists() and dir_path.is_dir(): return [str(dir_path)], target.replace("\\", "/").rstrip("/"), 120 if not extra: raise RuntimeError(f"Failed to parse pytest sidecar args from line: {raw_line!r}") if not (repo_root / "tests").exists(): raise FileNotFoundError("tests/ directory not found under repo root") return ["tests/"], "tests/", 120 def collect_test_names(target: str, repo_root: Path, raw_line: str) -> list[str]: """ Run pytest --collect-only -q for a target and return test node id list (incl. parametrized). ``path_args`` come from ``target`` (file, dir under ``tests/``, or fallback ``tests/``). Sidecar flags ``--ignore``, ``--test-config-file``, ``-m``, ``-k``, ``--run-level`` are taken from the full ``raw_line`` and appended to the same invocation so collection matches CI (perf scripts parametrize from ``--test-config-file`` at import time). """ extra = _pytest_collect_sidecar_args(raw_line) path_args, _fallback, timeout_quiet = _resolve_pytest_target(target, repo_root, extra, raw_line) cmd_quiet = [sys.executable, "-m", "pytest", *path_args, "--collect-only", "-q"] cmd_quiet.extend(extra) result = subprocess.run( cmd_quiet, cwd=repo_root, capture_output=True, text=True, timeout=timeout_quiet, ) if result.returncode != 0: raise RuntimeError(f"pytest --collect-only failed for {path_args} with args {extra}: {result.stderr.strip()}") print(f"pytest --collect-only success for {path_args} with args {extra}: {result.stdout.strip()}") return _parse_collect_only_stdout(result.stdout or "", stderr=result.stderr or "") def _extra_with_skip_marker(extra: list[str]) -> list[str]: """Build extra args that add -m skip: if extra has -m expr then use 'expr and skip', else -m skip.""" out: list[str] = [] i = 0 has_m = False while i < len(extra): if extra[i] == "-m" and i + 1 < len(extra): out.extend(["-m", extra[i + 1] + " and skip"]) has_m = True i += 2 elif extra[i] == "--run-level" and i + 1 < len(extra): out.extend(["--run-level", extra[i + 1]]) i += 2 elif extra[i] == "-k" and i + 1 < len(extra): out.extend(["-k", extra[i + 1]]) i += 2 elif extra[i] == "--ignore" and i + 1 < len(extra): out.extend(["--ignore", extra[i + 1]]) i += 2 elif extra[i] == "--test-config-file" and i + 1 < len(extra): out.extend(["--test-config-file", extra[i + 1]]) i += 2 else: i += 1 if not has_m: out = ["-m", "skip"] + out return out def get_skip_status(target: str, raw_line: str, repo_root: Path) -> set[str]: """ Use pytest -m skip --collect-only -q to collect tests marked with skip; return their node ids. Does not run tests; only collects. Supports both pytest xxx.py and pytest -m "marker". """ extra = _pytest_collect_sidecar_args(raw_line) try: path_args, _, timeout_quiet = _resolve_pytest_target(target, repo_root, extra, raw_line) except (FileNotFoundError, RuntimeError): return set() extra_skip = _extra_with_skip_marker(extra) cmd = [sys.executable, "-m", "pytest", *path_args, "--collect-only", "-q"] cmd.extend(extra_skip) try: result = subprocess.run( cmd, cwd=repo_root, capture_output=True, text=True, timeout=timeout_quiet, ) except (subprocess.TimeoutExpired, FileNotFoundError): return set() if result.returncode != 0: return set() ids = _parse_collect_only_stdout(result.stdout or "", raise_on_empty=False) return set(ids) def get_docstring_for_node_id(repo_root: Path, node_id: str) -> str: """ Parse the docstring of the test_ function/method from source by pytest node id. Supports path::test_func, path::TestClass::test_method, path::test_func[param], etc. """ if not node_id or "::" not in node_id or node_id.startswith("["): return "" parts = node_id.split("::") # Path accepts forward slashes; no platform-specific replacement needed path = (repo_root / parts[0]).resolve() if not path.exists(): return "" # Strip parametrize suffix, e.g. test_foo[param] -> test_foo func_part = parts[-1] if "[" in func_part: func_part = func_part.split("[", 1)[0] try: source = path.read_text(encoding="utf-8", errors="replace") tree = ast.parse(source) except Exception: return "" # parts: [file, maybe_class, func] or [file, func] if len(parts) == 2: # path::test_func target_func = func_part target_class = None else: # path::TestClass::test_method target_class = parts[1] target_func = func_part for node in tree.body: if isinstance(node, ast.FunctionDef) and node.name == target_func and target_class is None: doc = ast.get_docstring(node) return (doc or "").strip() if isinstance(node, ast.ClassDef) and node.name == target_class: for item in node.body: if isinstance(item, ast.FunctionDef) and item.name == target_func: doc = ast.get_docstring(item) return (doc or "").strip() break return "" def get_test_file_for_node_id(node_id: str) -> str: """Extract test file path from a pytest node id.""" if not node_id: return "" if "::" in node_id: return node_id.split("::", 1)[0] if node_id.endswith(".py"): return node_id return "(marker-only / no collection)" def write_html(all_stats: list[tuple], out_path: Path, total: int, repo_root: Path | None = None) -> None: """Write stats to HTML with styling and responsive tables.""" root = repo_root if repo_root is not None else REPO_ROOT pipeline_badges = { "test-ready": "ready", "test-merge": "merge", "test-nightly": "nightly", "test-weekly": "weekly", } def esc(s: str) -> str: return html.escape(str(s)) def tr_class(i: int) -> str: return "odd" if i % 2 == 0 else "even" pipeline_totals: dict[str, int] = {} for item in all_stats: _, pipeline_name, _, _, _, _, _, count, _, _, _ = item pipeline_totals[pipeline_name] = pipeline_totals.get(pipeline_name, 0) + count pipeline_summary_rows = [] for idx, (pipeline_name, pipeline_count) in enumerate(sorted(pipeline_totals.items())): badge = pipeline_badges.get(pipeline_name, "") badge_span = f'{esc(pipeline_name)}' if badge else esc(pipeline_name) pipeline_summary_rows.append( f'{badge_span}{pipeline_count}' ) # Summary rows by module (item: ..., names, docstrings, skipped_ids) summary_rows = [] for i, item in enumerate(all_stats): _, pipeline_name, group, label, test_files, _, marker_targets, count, _, _, _ = item group_cell = esc(group) if group else "—" if test_files: files_html = "
".join(esc(f) for f in test_files) elif marker_targets: # Display full marker / run-level arguments when no explicit test files. files_html = "
".join(esc(m) for m in marker_targets) else: files_html = "No explicit tests or markers" badge = pipeline_badges.get(pipeline_name, "") badge_span = f'{esc(pipeline_name)}' if badge else esc(pipeline_name) summary_rows.append( f'' f'{badge_span}{group_cell}{esc(label)}{count}' f'{files_html}' ) # Detail rows grouped by Pipeline -> Buildkite group -> Test Suite -> files case_details_thead = """ Pipeline Group Test Suite Test File No. Test Name Status Description """ # pipeline -> group_title -> [(label, count, suite_summary, body)] pipeline_group_suites: dict[str, dict[str | None, list[tuple[str, int, str, str]]]] = {} for item in all_stats: _, pipeline_name, group, label, _, _, _, count, names, docstrings, skipped_ids = item group_cell = esc(group) if group else "—" badge = pipeline_badges.get(pipeline_name, "") badge_span = f'{esc(pipeline_name)}' if badge else esc(pipeline_name) suite_summary = f"{esc(label)} ({count} cases)" file_sections: list[str] = [] if not names: suite_rows = [ f'' f"{badge_span}{group_cell}{esc(label)}" f'(marker-only / no collection)0' f'No collection or only -m/--run-level' f'' ] file_sections.append( '
' "(marker-only / no collection)" f'
{case_details_thead}' f"{''.join(suite_rows)}
" ) else: file_groups: dict[str, list[tuple[str, str, bool]]] = {} for idx, name in enumerate(names): desc = (docstrings[idx] if idx < len(docstrings) else "") or "" if not desc and "::" in name and not name.startswith("["): desc = get_docstring_for_node_id(root, name) file_key = get_test_file_for_node_id(name) file_groups.setdefault(file_key, []).append((name, desc, name in skipped_ids)) for file_name in sorted(file_groups.keys()): suite_rows = [] for i, (name, desc, is_skipped) in enumerate(file_groups[file_name], 1): desc_html = esc(desc).replace("\n", "
") if desc else "" status_html = 'Skipped' if is_skipped else "—" skip_attr = ' data-case-skipped="1"' if is_skipped else ' data-case-skipped="0"' suite_rows.append( f'' f"{badge_span}{group_cell}{esc(label)}" f'{esc(file_name)}' f'{i}' f'{esc(name)}' f'{status_html}{desc_html}' ) file_sections.append( f'
' f"{esc(file_name)} ({len(file_groups[file_name])} cases)" f'
{case_details_thead}' f"{''.join(suite_rows)}
" ) body = "".join(file_sections) pipeline_group_suites.setdefault(pipeline_name, {}).setdefault(group, []).append( (label, count, suite_summary, body) ) def _group_sort_key(k: str | None) -> tuple[bool, str]: return (k is None, (k or "").lower()) detail_sections_html_parts: list[str] = [] all_pipeline_keys = sorted(set(pipeline_badges.keys()) | set(pipeline_group_suites.keys())) for pipeline_name in all_pipeline_keys: if pipeline_name not in pipeline_group_suites: continue groups_dict = pipeline_group_suites[pipeline_name] pipeline_total = sum(c for suites in groups_dict.values() for (_, c, _, _) in suites) badge = pipeline_badges.get(pipeline_name, "") badge_span = f'{esc(pipeline_name)}' if badge else esc(pipeline_name) pipeline_summary = f"{badge_span} ({pipeline_total} cases)" group_sections: list[str] = [] for g_key in sorted(groups_dict.keys(), key=_group_sort_key): suites = groups_dict[g_key] group_total = sum(c for _, c, _, _ in suites) display_g = "—" if g_key is None else g_key inner_html = "".join( f'
{suite_summary}' f'
{body}
' for _, _, suite_summary, body in suites ) group_sections.append( f'
{esc(display_g)} ({group_total} cases)' f'
{inner_html}
' ) detail_sections_html_parts.append( f'
{pipeline_summary}' f'
{"".join(group_sections)}
' ) detail_sections_html = "".join(detail_sections_html_parts) html_content = f""" Buildkite Test Case Statistics

Buildkite Pytest Case Statistics

Test Suites: {len(all_stats)} · Total cases (sum of steps): {total} ·

Pipeline Summary

{"".join(pipeline_summary_rows)}
PipelineTotal Cases

Summary by Test Suite

{"".join(summary_rows)}
PipelineGroupTest SuiteCasesTest Files

Case Details

Expand by Pipeline, then Buildkite group, then Test Suite, then by Test File.

Path filter applies to file sections; status hides non-matching rows.
{detail_sections_html}
↑ Top """ out_path.write_text(html_content, encoding="utf-8") def _sample_preview_stats() -> list[tuple]: """Synthetic stats tuples matching ``main()`` layout — UI demo only.""" names_a = [ "tests/e2e/demo/test_alpha.py::test_smoke", "tests/e2e/demo/test_alpha.py::test_params[x0]", "tests/e2e/demo/test_beta.py::test_widgets", ] skipped_a = {"tests/e2e/demo/test_alpha.py::test_smoke"} return [ ( "test-ready / :card_index_dividers: Simple Test / Simple · Diffusion", "test-ready", ":card_index_dividers: Simple Test", "Simple · Diffusion & Model Executor Test", ["tests/diffusion/test_dummy.py", "tests/model_executor/test_dummy.py"], False, [], len(names_a) - len(skipped_a), names_a, ["Alpha smoke.", "", "Widgets."], skipped_a, ), ( "test-ready / Custom Pipeline Test", "test-ready", None, "Custom Pipeline Test", ["tests/e2e/offline_inference/custom_pipeline/test_cp.py"], False, [], 1, ["tests/e2e/offline_inference/custom_pipeline/test_cp.py::test_cp_one"], [""], set(), ), ( "test-merge / :card_index_dividers: Diffusion Test / LoRA", "test-merge", ":card_index_dividers: Diffusion Test", "Diffusion · LoRA Test", ["tests/diffusion/lora/test_lora.py"], False, [], 2, [ "tests/diffusion/lora/test_lora.py::test_load", "tests/diffusion/lora/test_lora.py::test_infer[a]", ], ["", ""], set(), ), ( "test-nightly / Omni marker-only (sample)", "test-nightly", ":card_index_dividers: Omni Model Test", "Omni · marker-only step (sample)", [], True, ["-m full_model and omni and H100", "--run-level full_model"], 0, [], [], set(), ), ( "test-weekly / Reliability (sample)", "test-weekly", None, "Reliability Test - qwen3-omni", ["tests/dfx/reliability/test_reliability_qwen3_omni.py"], False, [], 1, ["tests/dfx/reliability/test_reliability_qwen3_omni.py::test_placeholder"], ["Weekly reliability placeholder."], set(), ), ] def main() -> None: parser = argparse.ArgumentParser(description="Count pytest cases from Buildkite pipelines") parser.add_argument( "--buildkite-dir", type=Path, default=BUILDKITE_DIR, help="Path to .buildkite directory", ) default_out = REPO_ROOT / "buildkite_testcase_statistics.html" parser.add_argument( "-o", "--output", type=Path, default=default_out, help=f"Output HTML file path (default: {default_out})", ) parser.add_argument( "--sample-preview", action="store_true", help="Write HTML using placeholder pipelines/groups/cases only (no pytest, no YAML-driven counts).", ) args = parser.parse_args() if args.sample_preview: all_stats = _sample_preview_stats() out_path = args.output.resolve() out_path.parent.mkdir(parents=True, exist_ok=True) total = sum(item[7] for item in all_stats) write_html(all_stats, out_path, total, REPO_ROOT) print(f"Written (sample preview): {out_path}") print(f"Test Suites: {len(all_stats)}, total placeholder cases: {total}") return all_stats = [] for filename in PIPELINE_FILES: yml_path = args.buildkite_dir / filename if not yml_path.exists(): print(f"Skipped (not found): {yml_path}", file=sys.stderr) continue data = load_yaml(yml_path) steps = data.get("steps") or [] pipeline_name = filename.replace(".yml", "") for step, group in iter_pytest_leaf_steps(steps): if not isinstance(step, dict): continue label = step.get("label") or "(no label)" if not step.get("commands"): continue targets = get_pytest_targets_from_step(step) if not targets: continue module_key = f"{pipeline_name} / {label}" if group: module_key = f"{pipeline_name} / {group} / {label}" # test_files: only real .py files; marker-only steps will have an empty list. test_files = [t for t, _ in targets if t.endswith(".py")] # marker_targets: synthetic marker targets (e.g. "-m expr", "-m expr --run-level level"). marker_targets = [t for t, _ in targets if not t.endswith(".py")] # marker_only: all targets are marker expressions (no explicit .py files). marker_only = bool(targets) and all(not t.endswith(".py") for t, _ in targets) names = [] skipped_ids: set[str] = set() for target, raw in targets: collected = collect_test_names(target, REPO_ROOT, raw) skipped_ids.update(get_skip_status(target, raw, REPO_ROOT)) names.extend(collected) count = len(names) - len(skipped_ids) docstrings = [ get_docstring_for_node_id(REPO_ROOT, n) if "::" in n and not n.startswith("[") else "" for n in names ] all_stats.append( ( module_key, pipeline_name, group, label, test_files, marker_only, marker_targets, count, names, docstrings, skipped_ids, ) ) out_path = args.output.resolve() out_path.parent.mkdir(parents=True, exist_ok=True) total = sum(item[7] for item in all_stats) write_html(all_stats, out_path, total, REPO_ROOT) print(f"Written: {out_path}") print(f"Test Suites: {len(all_stats)}, total cases (sum of steps): {total}") if __name__ == "__main__": main()