Skip to content

Commit 5abe8ef

Browse files
Resolve OTE run-suite false failures from klog deserialize errors
Outer JSON marks every result failed when stderr starts with klog "I..." lines. Count and rewrite junit from nested STDOUT / ginkgo SUCCESS|FAIL so real suite failures stay UNSTABLE instead of "no tests run". Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent ec46f4e commit 5abe8ef

2 files changed

Lines changed: 278 additions & 127 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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

Comments
 (0)