|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Resolve true OTE openstack-test outcomes from run-suite / run-test logs. |
| 3 | +
|
| 4 | +OTE often marks every outer JSON entry as failed when stderr begins with klog |
| 5 | +lines (``I0809 ...``), producing:: |
| 6 | +
|
| 7 | + Deserializaion Error: invalid character 'I' looking for beginning of value |
| 8 | +
|
| 9 | +The real outcome is in nested STDOUT JSON (``"result": "passed"|"failed"|...``) |
| 10 | +or in ginkgo summary lines (``SUCCESS!`` / ``FAIL!``). |
| 11 | +
|
| 12 | +Usage: |
| 13 | + ote_resolve_results.py count <log> passed|failed|skipped |
| 14 | + ote_resolve_results.py junit <log> <junit_xml_path> |
| 15 | +""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import json |
| 20 | +import re |
| 21 | +import sys |
| 22 | +import xml.etree.ElementTree as ET |
| 23 | +from typing import Any |
| 24 | + |
| 25 | + |
| 26 | +def _load_outer_results(raw: str) -> list[dict[str, Any]]: |
| 27 | + raw = raw.strip() |
| 28 | + if not raw: |
| 29 | + return [] |
| 30 | + |
| 31 | + # Skip leading klog / noise before the JSON array/object. |
| 32 | + start_candidates = [i for i, ch in enumerate(raw) if ch in "[{"] |
| 33 | + for start in start_candidates: |
| 34 | + chunk = raw[start:] |
| 35 | + try: |
| 36 | + data, _ = json.JSONDecoder().raw_decode(chunk) |
| 37 | + except json.JSONDecodeError: |
| 38 | + continue |
| 39 | + if isinstance(data, list): |
| 40 | + return [r for r in data if isinstance(r, dict)] |
| 41 | + if isinstance(data, dict): |
| 42 | + return [data] |
| 43 | + |
| 44 | + # NDJSON fallback |
| 45 | + results: list[dict[str, Any]] = [] |
| 46 | + for line in raw.splitlines(): |
| 47 | + line = line.strip() |
| 48 | + if not line or not line.startswith("{"): |
| 49 | + continue |
| 50 | + try: |
| 51 | + obj = json.loads(line) |
| 52 | + except json.JSONDecodeError: |
| 53 | + continue |
| 54 | + if isinstance(obj, dict): |
| 55 | + results.append(obj) |
| 56 | + return results |
| 57 | + |
| 58 | + |
| 59 | +def _extract_nested_results(output: str) -> list[dict[str, Any]] | None: |
| 60 | + if not output: |
| 61 | + return None |
| 62 | + |
| 63 | + m = re.search(r"STDOUT:\n(.*?)(?:\n\nSTDERR:|\nSTDERR:|\Z)", output, re.S) |
| 64 | + stdout = m.group(1) if m else output |
| 65 | + |
| 66 | + for match in re.finditer(r"\[", stdout): |
| 67 | + chunk = stdout[match.start() :] |
| 68 | + try: |
| 69 | + obj, _ = json.JSONDecoder().raw_decode(chunk) |
| 70 | + except json.JSONDecodeError: |
| 71 | + continue |
| 72 | + if ( |
| 73 | + isinstance(obj, list) |
| 74 | + and obj |
| 75 | + and isinstance(obj[0], dict) |
| 76 | + and "result" in obj[0] |
| 77 | + ): |
| 78 | + return obj |
| 79 | + return None |
| 80 | + |
| 81 | + |
| 82 | +def _result_from_ginkgo(output: str) -> str | None: |
| 83 | + if not output: |
| 84 | + return None |
| 85 | + # Prefer FAIL over SUCCESS when both somehow appear. |
| 86 | + if re.search(r"FAIL! -- .*\| 1 Failed", output) or re.search( |
| 87 | + r"FAIL! -- 0 Passed \| [1-9]", output |
| 88 | + ): |
| 89 | + return "failed" |
| 90 | + if re.search(r"SUCCESS! -- 0 Passed \| 0 Failed \| 0 Pending \| [1-9]+ Skipped", output): |
| 91 | + return "skipped" |
| 92 | + if re.search(r"SUCCESS! -- [1-9]\d* Passed \| 0 Failed", output): |
| 93 | + return "passed" |
| 94 | + if "[FAILED]" in output and "SUCCESS! -- 1 Passed" not in output: |
| 95 | + return "failed" |
| 96 | + return None |
| 97 | + |
| 98 | + |
| 99 | +def _has_deser_error(output: str) -> bool: |
| 100 | + return "Deserializaion Error" in output or "Deserialization Error" in output |
| 101 | + |
| 102 | + |
| 103 | +def resolve_result(entry: dict[str, Any]) -> str: |
| 104 | + """Return passed|failed|skipped|unknown for one outer OTE log entry.""" |
| 105 | + outer = (entry.get("result") or "").lower() |
| 106 | + output = entry.get("output") or "" |
| 107 | + |
| 108 | + nested = _extract_nested_results(output) |
| 109 | + if nested: |
| 110 | + # One outer entry maps to one (or few) nested specs; use first, or |
| 111 | + # failed if any nested failed. |
| 112 | + nested_results = [(r.get("result") or "").lower() for r in nested] |
| 113 | + if "failed" in nested_results: |
| 114 | + return "failed" |
| 115 | + if nested_results and all(r == "skipped" for r in nested_results): |
| 116 | + return "skipped" |
| 117 | + if nested_results and all(r == "passed" for r in nested_results): |
| 118 | + return "passed" |
| 119 | + if "passed" in nested_results and "failed" not in nested_results: |
| 120 | + return "passed" |
| 121 | + |
| 122 | + ginkgo = _result_from_ginkgo(output) |
| 123 | + if ginkgo: |
| 124 | + return ginkgo |
| 125 | + |
| 126 | + # Outer "failed" with only deserialize noise and no other signal is unknown; |
| 127 | + # callers should not treat unknown as a real failure count source of truth. |
| 128 | + if outer == "failed" and _has_deser_error(output) and not nested and not ginkgo: |
| 129 | + return "unknown" |
| 130 | + |
| 131 | + if outer in ("passed", "failed", "skipped"): |
| 132 | + return outer |
| 133 | + return "unknown" |
| 134 | + |
| 135 | + |
| 136 | +def resolve_all(log_path: str) -> list[dict[str, Any]]: |
| 137 | + try: |
| 138 | + with open(log_path, encoding="utf-8") as f: |
| 139 | + raw = f.read() |
| 140 | + except OSError: |
| 141 | + return [] |
| 142 | + |
| 143 | + resolved: list[dict[str, Any]] = [] |
| 144 | + for entry in _load_outer_results(raw): |
| 145 | + result = resolve_result(entry) |
| 146 | + resolved.append( |
| 147 | + { |
| 148 | + "name": entry.get("name") or "unknown", |
| 149 | + "result": result, |
| 150 | + "duration": entry.get("duration") or 0, |
| 151 | + "output": entry.get("output") or entry.get("error") or "", |
| 152 | + "error": entry.get("error") or "", |
| 153 | + } |
| 154 | + ) |
| 155 | + return resolved |
| 156 | + |
| 157 | + |
| 158 | +def cmd_count(log_path: str, want: str) -> int: |
| 159 | + want = want.lower() |
| 160 | + return sum(1 for r in resolve_all(log_path) if r["result"] == want) |
| 161 | + |
| 162 | + |
| 163 | +def cmd_junit(log_path: str, junit_path: str) -> None: |
| 164 | + results = resolve_all(log_path) |
| 165 | + suite = ET.Element("testsuite", name="openstack-test") |
| 166 | + failures = 0 |
| 167 | + skipped = 0 |
| 168 | + for r in results: |
| 169 | + duration = r.get("duration") or 0 |
| 170 | + try: |
| 171 | + # OTE durations are often nanoseconds; keep seconds for junit. |
| 172 | + dur = float(duration) |
| 173 | + if dur > 10_000: |
| 174 | + # ns or ms — treat large values as ns |
| 175 | + time_s = f"{dur / 1_000_000_000.0:.3f}" if dur > 1_000_000 else f"{dur / 1000.0:.3f}" |
| 176 | + else: |
| 177 | + time_s = f"{dur:.3f}" |
| 178 | + except (TypeError, ValueError): |
| 179 | + time_s = "0" |
| 180 | + |
| 181 | + case = ET.SubElement(suite, "testcase", name=r["name"], time=time_s) |
| 182 | + result = r["result"] |
| 183 | + if result == "failed": |
| 184 | + failures += 1 |
| 185 | + fail = ET.SubElement(case, "failure") |
| 186 | + fail.text = r.get("error") or r.get("output") or "failed" |
| 187 | + elif result == "skipped": |
| 188 | + skipped += 1 |
| 189 | + skip = ET.SubElement(case, "skipped") |
| 190 | + skip.text = r.get("output") or "skipped" |
| 191 | + elif result == "unknown": |
| 192 | + # Do not inflate failure counts for unparsable deserialize-only noise. |
| 193 | + skip = ET.SubElement(case, "skipped") |
| 194 | + skip.text = "unresolved OTE outcome" |
| 195 | + |
| 196 | + suite.set("tests", str(len(results))) |
| 197 | + suite.set("failures", str(failures)) |
| 198 | + suite.set("skipped", str(skipped)) |
| 199 | + ET.ElementTree(suite).write(junit_path, encoding="utf-8", xml_declaration=True) |
| 200 | + |
| 201 | + |
| 202 | +def main(argv: list[str]) -> int: |
| 203 | + if len(argv) < 2: |
| 204 | + print(__doc__, file=sys.stderr) |
| 205 | + return 2 |
| 206 | + cmd = argv[1] |
| 207 | + if cmd == "count": |
| 208 | + if len(argv) != 4: |
| 209 | + print("usage: count <log> passed|failed|skipped", file=sys.stderr) |
| 210 | + return 2 |
| 211 | + print(cmd_count(argv[2], argv[3])) |
| 212 | + return 0 |
| 213 | + if cmd == "junit": |
| 214 | + if len(argv) != 4: |
| 215 | + print("usage: junit <log> <junit_xml_path>", file=sys.stderr) |
| 216 | + return 2 |
| 217 | + cmd_junit(argv[2], argv[3]) |
| 218 | + return 0 |
| 219 | + print(__doc__, file=sys.stderr) |
| 220 | + return 2 |
| 221 | + |
| 222 | + |
| 223 | +if __name__ == "__main__": |
| 224 | + raise SystemExit(main(sys.argv)) |
0 commit comments