From a86c5fe0cc86abc07ab247cf90ddf92e1f0563e0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:12:25 +0000 Subject: [PATCH 1/2] [tagpr] prepare for the next release From 3803efa29f28d3c08fb2929ed39b38c9a0c763cb Mon Sep 17 00:00:00 2001 From: jsekar Date: Fri, 23 Oct 2026 12:39:38 +0530 Subject: [PATCH 2/2] enhancement/LCHIB-614: fix nested maven test usecase --- launchable/commands/record/case_event.py | 32 ++++++++++++++++------ launchable/commands/record/tests.py | 35 +++++++++++++++++------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/launchable/commands/record/case_event.py b/launchable/commands/record/case_event.py index b4511d0ac..4d214fe6b 100644 --- a/launchable/commands/record/case_event.py +++ b/launchable/commands/record/case_event.py @@ -1,5 +1,6 @@ import datetime import sys +import uuid from typing import Any, Callable, Dict, List, Optional, Tuple import dateutil.parser @@ -18,6 +19,13 @@ RERUN_TAGS = ("rerunFailure", "rerunError") FLAKY_TAGS = ("flakyFailure", "flakyError") +# Internal-only key tagging which original an event came from (see +# from_case_and_suite). Used by summarize_case_events to group retry/flaky attempts of the +# SAME testcase without relying on testPath (which can be lossy, e.g. Maven's nested-class +# $-stripping, or missing/extra attributes from the raw runner's free-form TestPathComponent). +# Must be stripped before a payload is uploaded (see tests.py's payload()). +BATCH_ID_KEY = "testcaseBatchId" + CaseEventType = Dict[str, Any] @@ -196,17 +204,23 @@ def child_text(tag: str) -> Optional[str]: test_path = path_canonicalizer(path_builder(case, suite, report_file)) data = data_builder(case) + # One id per original , shared by every attempt derived from it here, so + # summarize_case_events can group retries correctly without relying on test_path. + batch_id = uuid.uuid4() return [ - CaseEvent.create( - test_path=test_path, - duration_secs=attempt_duration, - status=attempt_status, - stdout=attempt_stdout, - stderr=attempt_stderr, - timestamp=suite.timestamp, - data=data, - ) + { + **CaseEvent.create( + test_path=test_path, + duration_secs=attempt_duration, + status=attempt_status, + stdout=attempt_stdout, + stderr=attempt_stderr, + timestamp=suite.timestamp, + data=data, + ), + BATCH_ID_KEY: batch_id, + } for attempt_status, attempt_stdout, attempt_stderr, attempt_duration in attempts ] diff --git a/launchable/commands/record/tests.py b/launchable/commands/record/tests.py index f0d664b39..24fe07830 100644 --- a/launchable/commands/record/tests.py +++ b/launchable/commands/record/tests.py @@ -5,7 +5,7 @@ import xml.etree.ElementTree as ET from concurrent.futures import ThreadPoolExecutor, as_completed from http import HTTPStatus -from typing import Callable, Dict, Generator, Iterable, List, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, Generator, Iterable, List, Optional, Sequence, Tuple, Union import click from dateutil.parser import parse @@ -27,7 +27,7 @@ from ...utils.no_build import NO_BUILD_BUILD_NAME, NO_BUILD_TEST_SESSION_ID from ...utils.session import parse_session, read_build from ..helper import find_or_create_session, time_ns -from .case_event import CaseEvent, CaseEventType +from .case_event import BATCH_ID_KEY, CaseEvent, CaseEventType GROUP_NAME_RULE = re.compile("^[a-zA-Z0-9][a-zA-Z0-9_-]*$") RESERVED_GROUP_NAMES = ["group", "groups", "nogroup", "nogroups"] @@ -490,7 +490,7 @@ def testcases(reports: List[str]) -> Generator[CaseEventType, None, None]: # generator that creates the payload incrementally def payload( - cases: Generator[TestCase, None, None], + cases: Generator[CaseEventType, None, None], test_runner, group: str, test_suite_name: str, flavors: Dict[str, str]) -> Tuple[Dict[str, Union[str, List, dict, bool]], List[Exception]]: @@ -500,11 +500,15 @@ def payload( while True: try: - cs.append(next(cases)) + tc = next(cases) except StopIteration: break except Exception as ex: exs.append(ex) + continue + # internal-only; never leaves the process + tc.pop(BATCH_ID_KEY, None) + cs.append(tc) count += len(cs) return { @@ -665,16 +669,27 @@ def summarize_case_events(events: Iterable[CaseEventType]) -> Tuple[int, int, in Summarizes a stream of case events into (test_count, success_count, fail_count, duration_minutes), for the "Tests found / Tests passed / Tests failed" table printed after `record tests` runs. - Grouped by testPath rather than counted per event: a flaky/rerun testcase produces multiple - events (see CaseEvent.from_case_and_suite), but it's still a single test. A test that ever - passed counts as passed (even if it also failed on earlier attempts); a test that only ever - failed counts as failed. + Grouped by which original testcase an event came from, not counted per event: a flaky/rerun + testcase produces multiple events (see CaseEvent.from_case_and_suite), but it's still a + single test. A test that ever passed counts as passed (even if it also failed on earlier + attempts); a test that only ever failed counts as failed. + + JUnit-based events carry BATCH_ID_KEY (one id per original , see + from_case_and_suite), which is used as the grouping key directly. Other runners (e.g. raw) + build events without going through from_case_and_suite, so they never have that key; for + those, fall back to grouping by testPath's complete attribute set. This fallback never + assumes a component has "type"/"name" specifically -- TestPathComponent is an arbitrary + bag of string attributes (see raw's free-form testPath/testPathComponents) -- and keeps + every attribute, not just type/name, so two components differing only in some other + attribute are never treated as the same path. """ - tests: Dict[Tuple, Dict[str, bool]] = {} + tests: Dict[Any, Dict[str, bool]] = {} duration = float(0) for tc in events: - key = tuple((p["type"], p["name"]) for p in tc.get("testPath", [])) + key = tc.get(BATCH_ID_KEY) + if key is None: + key = tuple(tuple(sorted(p.items())) for p in tc.get("testPath", [])) entry = tests.setdefault(key, {"passed": False, "failed": False}) status = tc.get("status") if status == 1: