From 041bc533490a4e4914d7ea0cf8d43cd9e750ed44 Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 2 Nov 2026 14:32:35 +0530 Subject: [PATCH] AIENG-379: emit per-suite start timestamp for robot runner Co-Authored-By: Claude Opus 4.8 --- launchable/test_runners/robot.py | 43 ++++++++++++++++++++++++++------ tests/test_runners/test_robot.py | 13 ++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/launchable/test_runners/robot.py b/launchable/test_runners/robot.py index 4b7581263..7a3119a2d 100644 --- a/launchable/test_runners/robot.py +++ b/launchable/test_runners/robot.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Optional from xml.etree import ElementTree as ET import click @@ -9,10 +10,31 @@ def parse_func(p: str) -> ET.ElementTree: - - def parse_suite(suite: ET.Element): - DATETIME_FORMAT = '%Y%m%d %H:%M:%S.%f' - + DATETIME_FORMAT = '%Y%m%d %H:%M:%S.%f' + + def suite_timestamp(suite: ET.Element) -> Optional[str]: + """The suite's start time as an ISO-8601 naive string, or None if absent. + + Robot records a naive, local wall-clock start (e.g. '20210319 11:34:50.085', + no timezone). The server owns timezone resolution (naive -> agent-local); + the CLI only normalizes the shape to ISO-8601. + """ + status = suite.find('status') + if status is None: + return None + # RF 7.0+: already-ISO 'start' attribute; hand it over untouched. + iso_start = status.get('start') + if iso_start: + return iso_start + raw = status.get('starttime') + if not raw: + return None + try: + return datetime.strptime(raw, DATETIME_FORMAT).isoformat() + except ValueError: + return None + + def parse_suite(suite: ET.Element, testsuite: ET.Element): suite_name = suite.get('name') for test in suite.iter("test"): test_name = test.get('name') @@ -68,14 +90,19 @@ def find_leaf_suites(suite: ET.Element): return leaves original_tree = ET.parse(p) - testsuite = ET.Element("testsuite", {"name": "robot"}) + testsuites = ET.Element("testsuites") SUITE_TAG_NAME = "suite" for top_suite in original_tree.findall(SUITE_TAG_NAME): for leaf_suite in find_leaf_suites(top_suite): - parse_suite(leaf_suite) - - return ET.ElementTree(testsuite) + attribs = {"name": str(leaf_suite.get('name'))} + timestamp = suite_timestamp(leaf_suite) + if timestamp: + attribs["timestamp"] = timestamp + testsuite = ET.SubElement(testsuites, "testsuite", attribs) + parse_suite(leaf_suite, testsuite) + + return ET.ElementTree(testsuites) @click.argument('reports', required=True, nargs=-1) diff --git a/tests/test_runners/test_robot.py b/tests/test_runners/test_robot.py index 5da1b9bce..d34ee134b 100644 --- a/tests/test_runners/test_robot.py +++ b/tests/test_runners/test_robot.py @@ -3,10 +3,23 @@ import responses # type: ignore +from launchable.test_runners.robot import parse_func from tests.cli_test_case import CliTestCase class RobotTest(CliTestCase): + def test_suite_start_timestamp(self): + # Each leaf suite carries its own start as an ISO-8601 naive string for the server to + # resolve. RF < 7.0 'starttime' (naive, space-separated) is normalized to ISO. + legacy = parse_func(str(self.test_files_dir) + "/output_legacy.xml").getroot() + self.assertEqual( + [s.get("timestamp") for s in legacy.findall("testsuite")], + ["2021-03-19T11:34:50.080000", "2021-03-19T11:34:50.092000"], + ) + # RF 7.0+ 'start' is already ISO and passes through; every suite must be tagged. + modern = parse_func(str(self.test_files_dir) + "/output.xml").getroot() + self.assertTrue(all(s.get("timestamp") for s in modern.findall("testsuite"))) + @responses.activate @mock.patch.dict(os.environ, {"LAUNCHABLE_TOKEN": CliTestCase.launchable_token}) def test_subset(self):