Skip to content

Commit faf4ae4

Browse files
authored
Reuse normalized ya traces for JUnit enrichment
1 parent 48f2d2c commit faf4ae4

2 files changed

Lines changed: 104 additions & 177 deletions

File tree

.github/scripts/tests/transform_ya_junit.py

Lines changed: 73 additions & 177 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,30 @@
22
from __future__ import annotations
33

44
import argparse
5-
import json
65
import logging
76
import os
87
import re
98
import shutil
109
import sys
1110
import urllib.parse
12-
from typing import Any, NamedTuple, Pattern, TextIO, TypeAlias
11+
from pathlib import Path
12+
from typing import TYPE_CHECKING, Pattern, TextIO, TypeAlias
1313
from xml.etree import ElementTree as ET
1414

1515
from ..helpers import setup_logger
1616
from .junit_utils import add_junit_link_property, is_faulty_testcase
1717
from .mute_utils import mute_target, pattern_to_re
1818

19+
if TYPE_CHECKING:
20+
from ..tracing.yatrace import Chunk, YaTraceCollection
21+
1922
LOGGER = logging.getLogger(__name__)
2023

2124
# TODO: Replace with a named mute rule type in a follow-up cleanup.
2225
CompiledPatternPair: TypeAlias = tuple[Pattern[str], Pattern[str]]
23-
# TODO: Replace these broad trace aliases with typed structures in a follow-up
24-
# cleanup.
25-
TraceEvent: TypeAlias = dict[str, Any]
2626
LogMap: TypeAlias = dict[str, str]
2727

2828

29-
class SubtestTraceKey(NamedTuple):
30-
test_class: str
31-
subtest: str
32-
33-
34-
class ChunkTraceKey(NamedTuple):
35-
suite: str
36-
chunk_index: int
37-
chunks_total: int
38-
39-
4029
def _escape_github_command(value: str) -> str:
4130
return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
4231

@@ -132,156 +121,6 @@ def __call__(self, suite_name: str, test_name: str, test_case: ET.Element) -> bo
132121
return set(matched_test_cases) == set(failed_by_current_chunk_cases)
133122

134123

135-
class YTestReportTrace:
136-
def __init__(self, out_root: str) -> None:
137-
self.out_root = out_root
138-
self.traces: dict[SubtestTraceKey, TraceEvent] = {}
139-
self.chunk_traces: dict[ChunkTraceKey, list[TraceEvent]] = {}
140-
141-
def load(self, subdir: str) -> None:
142-
test_results_dir = os.path.join(self.out_root, f"{subdir}/test-results/")
143-
144-
if not os.path.isdir(test_results_dir):
145-
LOGGER.info("Directory %s doesn't exist", test_results_dir)
146-
return
147-
148-
for folder in os.listdir(test_results_dir):
149-
fn = os.path.join(
150-
self.out_root,
151-
test_results_dir,
152-
folder,
153-
"ytest.report.trace",
154-
)
155-
156-
if not os.path.isfile(fn):
157-
continue
158-
159-
with open(fn, "r") as fp:
160-
for line in fp:
161-
event = json.loads(line.strip())
162-
if event["name"] == "subtest-finished":
163-
event = event["value"]
164-
class_event = event["class"].replace("::", ".")
165-
subtest = event["subtest"]
166-
LOGGER.info("loaded (%s, %s)", class_event, subtest)
167-
self.traces[SubtestTraceKey(class_event, subtest)] = event
168-
elif event["name"] == "chunk-event":
169-
event = event["value"]
170-
event["test_results_folder"] = folder
171-
chunk_idx = event["chunk_index"]
172-
chunk_total = event["nchunks"]
173-
LOGGER.info(
174-
"loaded (%s, %s, %s)", subdir, chunk_idx, chunk_total
175-
)
176-
self.chunk_traces.setdefault(
177-
ChunkTraceKey(subdir, chunk_idx, chunk_total), []
178-
).append(event)
179-
180-
def get_logs(self, class_event: str, name: str) -> LogMap:
181-
trace = self.traces.get(SubtestTraceKey(class_event, name))
182-
if not trace:
183-
return {}
184-
185-
logs = trace["logs"]
186-
result: LogMap = {}
187-
for key, path in logs.items():
188-
if key == "logsdir":
189-
continue
190-
result[key] = path.replace("$(BUILD_ROOT)", self.out_root)
191-
192-
return result
193-
194-
def select_chunk_trace(
195-
self,
196-
suite: str,
197-
idx: int,
198-
total: int,
199-
failure_text: str | None = None,
200-
warn_on_ambiguity: bool = True,
201-
) -> TraceEvent | None:
202-
traces = self.chunk_traces.get(ChunkTraceKey(suite, idx, total), [])
203-
if not traces:
204-
return None
205-
if len(traces) == 1:
206-
return traces[0]
207-
if not failure_text:
208-
if warn_on_ambiguity:
209-
emit_transform_warning(
210-
"Unable to disambiguate chunk logs for "
211-
f"{suite} [{idx}/{total}]: failure text is empty; "
212-
"leaving log links empty"
213-
)
214-
return None
215-
216-
for trace in traces:
217-
folder = trace.get("test_results_folder")
218-
if folder and f"test-results/{folder}" in failure_text:
219-
return trace
220-
221-
for trace in traces:
222-
logs = trace.get("logs", {})
223-
for path in logs.values():
224-
if path.replace("$(BUILD_ROOT)", self.out_root) in failure_text:
225-
return trace
226-
227-
if warn_on_ambiguity:
228-
emit_transform_warning(
229-
"Unable to disambiguate chunk logs for "
230-
f"{suite} [{idx}/{total}]; leaving log links empty"
231-
)
232-
return None
233-
234-
def get_logs_chunks(
235-
self, suite: str, idx: int, total: int, failure_text: str | None = None
236-
) -> LogMap:
237-
trace = self.select_chunk_trace(suite, idx, total, failure_text)
238-
if not trace:
239-
return {}
240-
241-
logs = trace["logs"]
242-
result: LogMap = {}
243-
for key, path in logs.items():
244-
if key == "logsdir":
245-
continue
246-
result[key] = path.replace("$(BUILD_ROOT)", self.out_root)
247-
248-
return result
249-
250-
def get_log_dir(self, class_event: str, name: str) -> str | None:
251-
logs_dir = (
252-
self.traces.get(SubtestTraceKey(class_event, name), {})
253-
.get("logs", {})
254-
.get("logsdir")
255-
)
256-
257-
if logs_dir is None:
258-
return None
259-
260-
return logs_dir.replace("$(BUILD_ROOT)", "").lstrip("/")
261-
262-
def get_log_dir_chunk(
263-
self,
264-
suite: str,
265-
idx: int,
266-
total: int,
267-
failure_text: str | None = None,
268-
warn_on_ambiguity: bool = True,
269-
) -> str | None:
270-
trace = self.select_chunk_trace(
271-
suite,
272-
idx,
273-
total,
274-
failure_text,
275-
warn_on_ambiguity=warn_on_ambiguity,
276-
)
277-
logs_dir = trace.get("logs", {}).get("logsdir") if trace else None
278-
279-
if logs_dir is None:
280-
return None
281-
282-
return logs_dir.replace("$(BUILD_ROOT)", "").lstrip("/")
283-
284-
285124
def filter_empty_logs(logs: LogMap) -> LogMap:
286125
result: LogMap = {}
287126
for key, value in logs.items():
@@ -292,6 +131,42 @@ def filter_empty_logs(logs: LogMap) -> LogMap:
292131
return result
293132

294133

134+
def _select_chunk_event(
135+
traces: YaTraceCollection,
136+
suite: str,
137+
chunk_index: int,
138+
chunks_total: int,
139+
failure_text: str | None,
140+
) -> Chunk | None:
141+
event = traces.select_chunk_event(
142+
suite,
143+
chunk_index,
144+
chunks_total,
145+
failure_text,
146+
)
147+
candidates = traces.chunk_event_candidates(
148+
suite,
149+
chunk_index,
150+
chunks_total,
151+
)
152+
if event is not None or len(candidates) <= 1:
153+
return event
154+
155+
if failure_text:
156+
message = (
157+
f"Unable to disambiguate chunk logs for "
158+
f"{suite} [{chunk_index}/{chunks_total}]; leaving log links empty"
159+
)
160+
else:
161+
message = (
162+
f"Unable to disambiguate chunk logs for "
163+
f"{suite} [{chunk_index}/{chunks_total}]: failure text is empty; "
164+
"leaving log links empty"
165+
)
166+
emit_transform_warning(message)
167+
return None
168+
169+
295170
def save_log(
296171
build_root: str,
297172
fn: str,
@@ -327,6 +202,21 @@ def save_log(
327202
return f"{log_url_prefix}{quoted_fpath}"
328203

329204

205+
def _load_ya_traces(ya_out: Path) -> YaTraceCollection | None:
206+
try:
207+
# Trace enrichment is optional: keep JUnit processing usable even when
208+
# tracing dependencies could not be installed on the runner.
209+
from ..tracing.yatrace import YaTraceCollection
210+
211+
return YaTraceCollection.load(ya_out)
212+
except Exception as error:
213+
emit_transform_warning(
214+
f"Ya trace enrichment is unavailable: {error}; "
215+
"continuing without trace-derived log links"
216+
)
217+
return None
218+
219+
330220
def transform(
331221
fp: TextIO,
332222
ya_mute_check: YaMuteCheck,
@@ -340,13 +230,13 @@ def transform(
340230
) -> None:
341231
tree = ET.parse(fp)
342232
root = tree.getroot()
233+
ya_out = Path(ya_out_dir)
234+
traces = _load_ya_traces(ya_out)
343235

344236
for suite in root.findall("testsuite"):
345237
suite_name = suite.get("name")
346238
if suite_name is None:
347239
continue
348-
traces = YTestReportTrace(ya_out_dir)
349-
traces.load(suite_name)
350240

351241
for case in suite.findall("testcase"):
352242
test_name = case.get("name")
@@ -364,13 +254,18 @@ def transform(
364254

365255
if not is_fail:
366256
continue
257+
if traces is None:
258+
continue
367259

368260
if "." in test_name:
369261
test_name = test_name.replace("kubernetes.io", "kubernetes::io")
370262
test_cls, test_method = test_name.rsplit(".", maxsplit=1)
371263
test_method = test_method.replace("kubernetes::io", "kubernetes.io")
372-
logs = filter_empty_logs(traces.get_logs(test_cls, test_method))
373-
logs_directory = traces.get_log_dir(test_cls, test_method)
264+
trace_event = traces.finished_test(
265+
suite_name,
266+
test_cls,
267+
test_method,
268+
)
374269
elif "chunk" in test_name:
375270
failure = case.find("failure")
376271
if failure is None:
@@ -389,21 +284,22 @@ def transform(
389284
continue
390285
chunk_idx = int(match.group(1))
391286
chunks_total = int(match.group(2))
392-
logs = filter_empty_logs(
393-
traces.get_logs_chunks(
394-
suite_name, chunk_idx, chunks_total, failure_text
395-
)
396-
)
397-
logs_directory = traces.get_log_dir_chunk(
287+
trace_event = _select_chunk_event(
288+
traces,
398289
suite_name,
399290
chunk_idx,
400291
chunks_total,
401292
failure_text,
402-
warn_on_ambiguity=False,
403293
)
404294
else:
405295
continue
406296

297+
logs = filter_empty_logs(
298+
trace_event.resolved_logs(traces.root) if trace_event else {}
299+
)
300+
logs_directory = (
301+
trace_event.logs_directory if trace_event is not None else None
302+
)
407303
if logs_directory is not None and not is_mute:
408304
add_junit_link_property(
409305
case,

.github/scripts/tests/transform_ya_junit_test.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99

1010
from scripts.tests import transform_ya_junit as tyj
11+
import scripts.tracing.yatrace.limits as yatrace_limits
1112

1213

1314
def _write_junit(path: Path) -> None:
@@ -186,6 +187,36 @@ def test_transform_adds_links_and_copies_logs(tmp_path: Path) -> None:
186187
assert (logs_out / "suite-name" / "stdout.log").exists()
187188

188189

190+
def test_transform_continues_when_ya_traces_exceed_limit(
191+
tmp_path: Path,
192+
monkeypatch: pytest.MonkeyPatch,
193+
capsys: pytest.CaptureFixture[str],
194+
) -> None:
195+
report = tmp_path / "junit.xml"
196+
ya_out = tmp_path / "ya-out"
197+
_write_junit(report)
198+
_write_trace(ya_out)
199+
monkeypatch.setattr(yatrace_limits, "MAX_YA_TRACE_BYTES", 1)
200+
with report.open("r") as fp:
201+
tyj.transform(
202+
fp,
203+
tyj.YaMuteCheck(),
204+
str(ya_out),
205+
True,
206+
"https://logs/",
207+
None,
208+
0,
209+
None,
210+
"https://data",
211+
)
212+
213+
case = ET.parse(report).getroot().find("./testsuite/testcase")
214+
assert case is not None
215+
assert case.get("classname") == "suite-name"
216+
assert case.find("failure") is not None
217+
assert "Ya trace enrichment is unavailable" in capsys.readouterr().err
218+
219+
189220
def test_save_log_applies_truncation(tmp_path: Path) -> None:
190221
src_dir = tmp_path / "src"
191222
src_dir.mkdir()

0 commit comments

Comments
 (0)