From 1e6b5820a3347ee1f029682b62c82644105f8647 Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 26 Oct 2026 10:45:53 +0530 Subject: [PATCH 1/2] AIENG-379: send raw suite timestamp + agent UTC offset; stop resolving timezones client-side Co-Authored-By: Claude Opus 4.8 --- launchable/commands/record/case_event.py | 22 ++------- launchable/commands/record/tests.py | 15 ++++++ tests/cli_test_case.py | 9 ++-- tests/commands/record/test_case_event.py | 46 +++++++------------ .../minitest/record_test_result_chunk1.json | 10 ++-- .../minitest/record_test_result_chunk2.json | 6 +-- tests/test_runners/test_raw.py | 15 +++--- 7 files changed, 57 insertions(+), 66 deletions(-) diff --git a/launchable/commands/record/case_event.py b/launchable/commands/record/case_event.py index 4d214fe6b..89d193a44 100644 --- a/launchable/commands/record/case_event.py +++ b/launchable/commands/record/case_event.py @@ -1,14 +1,9 @@ -import datetime import sys import uuid from typing import Any, Callable, Dict, List, Optional, Tuple -import dateutil.parser -from dateutil.tz import tzlocal from junitparser import Error, Failure, IntAttr, Skipped, TestCase, TestSuite -from launchable.utils.common_tz import COMMON_TIMEZONES # type: ignore - from ...testpath import FilePathNormalizer, TestPath POSSIBLE_RESULTS = (Failure, Error, Skipped) @@ -228,22 +223,13 @@ def child_text(tag: str) -> Optional[str]: def create(cls, test_path: TestPath, duration_secs: float, status, stdout: Optional[str] = None, stderr: Optional[str] = None, timestamp: Optional[str] = None, data: Optional[Dict] = None) -> Dict: - def _timestamp(ts: Optional[str] = None): - if ts is None: - return datetime.datetime.now(datetime.timezone.utc).isoformat() - try: - date = dateutil.parser.parse(timestr=ts, tzinfos=COMMON_TIMEZONES) - if date.tzinfo is None: - return date.replace(tzinfo=tzlocal()).isoformat() - return date.isoformat() - except Exception: - return datetime.datetime.now(datetime.timezone.utc).isoformat() - """ Builds a JSON representation of CaseEvent from arbitrary set of values status: TEST_FAILED or TEST_PASSED - timestamp: ISO-8601 formatted date + timestamp: the raw string, sent unresolved. The server owns timezone + resolution (see mothership TestTimestampResolver); the CLI holds no framework-TZ + knowledge. None when the runner emits no suite timestamp. data: arbitrary data to be submitted to the server. reserved for future enhancement. """ return { @@ -253,7 +239,7 @@ def _timestamp(ts: Optional[str] = None): "status": status, "stdout": stdout or "", "stderr": stderr or "", - "createdAt": _timestamp(timestamp), + "suiteTimestamp": timestamp, "data": data } diff --git a/launchable/commands/record/tests.py b/launchable/commands/record/tests.py index 24fe07830..e9b80b9e4 100644 --- a/launchable/commands/record/tests.py +++ b/launchable/commands/record/tests.py @@ -33,6 +33,18 @@ RESERVED_GROUP_NAMES = ["group", "groups", "nogroup", "nogroups"] +def _agent_utc_offset() -> str: + """The recording agent's current UTC offset as an ISO string (e.g. '+09:00', '+00:00'). + + Sent once per 'record tests' request so the server can resolve naive suite timestamps. Parseable + by the server's ZoneOffset.of(...). + """ + offset = datetime.datetime.now().astimezone().strftime("%z") # e.g. '+0900', '-0700', '+0000' + if not offset: + return "+00:00" + return offset[:3] + ":" + offset[3:5] + + def _validate_group(ctx, param, value): if value is None: return "" @@ -521,6 +533,9 @@ def payload( # testSuite and flavors are applied only when the no-build option is enabled "testSuite": test_suite_name, "flavors": flavors, + # The recording agent's UTC offset, sent once per request. Only the client knows + # it; the server needs it to resolve naive (offset-less) suite timestamps. + "agentUtcOffset": _agent_utc_offset(), }, exs def send(payload: Dict[str, Union[str, List]]) -> None: diff --git a/tests/cli_test_case.py b/tests/cli_test_case.py index 4071988e7..5c9b0fed2 100644 --- a/tests/cli_test_case.py +++ b/tests/cli_test_case.py @@ -264,11 +264,14 @@ def assert_record_tests_payload(self, golden_image_filename: str, payload=None): if not payload: payload = json.loads(gzip.decompress(self.find_request('/events').request.body).decode()) - # Remove timestamp because it depends on the machine clock + # Remove the suite timestamp: goldens compared here don't assert it (it's the raw report + # value now; dedicated runner tests assert it directly). for c in payload['events']: - del c['createdAt'] - # metadata includes server dependent data + c.pop('createdAt', None) + c.pop('suiteTimestamp', None) + # metadata and the agent offset include machine/clock-dependent data del payload['metadata'] + payload.pop('agentUtcOffset', None) expected = self.load_json_from_file(self.test_files_dir.joinpath(golden_image_filename)) self.assert_json_orderless_equal(expected, payload) diff --git a/tests/commands/record/test_case_event.py b/tests/commands/record/test_case_event.py index 3af311ddd..11356b67a 100644 --- a/tests/commands/record/test_case_event.py +++ b/tests/commands/record/test_case_event.py @@ -1,37 +1,23 @@ import unittest -from io import StringIO -from unittest import mock from launchable.commands.record.case_event import CaseEvent -UNKNOWN_TIMEZONE_WARNING = "UnknownTimezoneWarning" - class TestCaseEventTimestamp(unittest.TestCase): - def test_timestamp_with_known_abbreviation(self): - # Use a known abbreviation from COMMON_TIMEZONES - ts = "2024-06-23T12:34:56.789 PDT" # PDT is in COMMON_TIMEZONES - with mock.patch('sys.stderr', new=StringIO()) as stderr: - result = CaseEvent.create( - test_path=[], duration_secs=1.0, status=CaseEvent.TEST_PASSED, timestamp=ts - ) - # Should parse to ISO format with correct offset - self.assertEqual(result["createdAt"], "2024-06-23T12:34:56.789000-07:00") - # Should NOT output a warning to stderr - self.assertNotIn(UNKNOWN_TIMEZONE_WARNING, stderr.getvalue()) + """The CLI no longer resolves timezones: it sends the raw suite timestamp and the server + resolves it. These tests pin that raw-passthrough contract.""" + + def test_suite_timestamp_is_sent_raw_and_unresolved(self): + ts = "2024-06-23T12:34:56.789 PDT" + result = CaseEvent.create( + test_path=[], duration_secs=1.0, status=CaseEvent.TEST_PASSED, timestamp=ts + ) + # Sent verbatim, with no client-side timezone resolution, and no resolved createdAt. + self.assertEqual(result["suiteTimestamp"], ts) + self.assertNotIn("createdAt", result) - def test_timestamp_with_unknown_abbreviation(self): - # Use an unknown abbreviation - ts = "2024-06-23T12:34:56.789 XYZ" # XYZ is not in COMMON_TIMEZONES - with mock.patch('sys.stderr', new=StringIO()) as stderr: - result = CaseEvent.create( - test_path=[], duration_secs=1.0, status=CaseEvent.TEST_PASSED, timestamp=ts - ) - # Should output a warning to stderr - self.assertIn(UNKNOWN_TIMEZONE_WARNING, stderr.getvalue()) - # The createdAt may still start with the input timestamp, but should not have a timezone offset - # We check that the prefix matches the input timestamp because - # `dateutil.parser` will use the system's default timezone if it - # encounters an unknown timezone abbreviation (e.g., 'XYZ'), resulting in - # a timestamp like '2024-06-23T12:34:56.789000+09:00'. - self.assertTrue(result["createdAt"].startswith("2024-06-23T12:34:56.789")) + def test_absent_timestamp_is_none(self): + result = CaseEvent.create( + test_path=[], duration_secs=1.0, status=CaseEvent.TEST_PASSED, timestamp=None + ) + self.assertIsNone(result["suiteTimestamp"]) diff --git a/tests/data/minitest/record_test_result_chunk1.json b/tests/data/minitest/record_test_result_chunk1.json index 6c9c7d2f0..f65681fdd 100644 --- a/tests/data/minitest/record_test_result_chunk1.json +++ b/tests/data/minitest/record_test_result_chunk1.json @@ -14,7 +14,7 @@ "status": 1, "stdout": "", "stderr": "", - "createdAt": "2020-12-23T13:10:01+09:00", + "suiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -31,7 +31,7 @@ "status": 1, "stdout": "", "stderr": "", - "createdAt": "2020-12-23T13:10:01+09:00", + "suiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -48,7 +48,7 @@ "status": 1, "stdout": "", "stderr": "", - "createdAt": "2020-12-23T13:10:01+09:00", + "suiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -65,7 +65,7 @@ "status": 1, "stdout": "", "stderr": "", - "createdAt": "2020-12-23T13:10:01+09:00", + "suiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -82,7 +82,7 @@ "status": 1, "stdout": "", "stderr": "", - "createdAt": "2020-12-23T13:10:01+09:00", + "suiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null } ], diff --git a/tests/data/minitest/record_test_result_chunk2.json b/tests/data/minitest/record_test_result_chunk2.json index 0bd9b80c7..8f5d8d311 100644 --- a/tests/data/minitest/record_test_result_chunk2.json +++ b/tests/data/minitest/record_test_result_chunk2.json @@ -14,7 +14,7 @@ "status": 1, "stdout": "", "stderr": "", - "createdAt": "2020-12-23T13:10:01+09:00", + "suiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -31,7 +31,7 @@ "status": 1, "stdout": "", "stderr": "", - "createdAt": "2020-12-23T13:10:01+09:00", + "suiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -45,7 +45,7 @@ "status": 1, "stdout": "", "stderr": "", - "createdAt": "2020-12-23T13:10:01+09:00", + "suiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null } ], diff --git a/tests/test_runners/test_raw.py b/tests/test_runners/test_raw.py index 78b3b2326..2dc15f02c 100644 --- a/tests/test_runners/test_raw.py +++ b/tests/test_runners/test_raw.py @@ -6,7 +6,6 @@ import dateutil.parser import responses # type: ignore -from dateutil.tz import tzlocal from launchable.utils.http_client import get_base_url from launchable.utils.session import write_build @@ -228,6 +227,7 @@ def test_record_tests(self): # Check request body payload = json.loads(gzip.decompress(self.find_request('/events').request.body).decode()) del payload['metadata'] + payload.pop('agentUtcOffset', None) self.assert_json_orderless_equal(payload, { 'events': [ { @@ -239,7 +239,7 @@ def test_record_tests(self): 'status': 1, 'stdout': 'This is stdout', 'stderr': 'This is stderr', - 'createdAt': test_ruby_timestamp.replace(tzinfo=tzlocal()).isoformat(), + 'suiteTimestamp': str(test_ruby_timestamp), 'data': None, 'type': 'case', }, @@ -252,7 +252,7 @@ def test_record_tests(self): 'status': 1, 'stdout': 'This is stdout', 'stderr': 'This is stderr', - 'createdAt': test_go_timestamp.replace(tzinfo=tzlocal()).isoformat(), + 'suiteTimestamp': str(test_go_timestamp), 'data': None, 'type': 'case', }, @@ -266,7 +266,7 @@ def test_record_tests(self): 'status': 1, 'stdout': 'This is stdout', 'stderr': 'This is stderr', - 'createdAt': test_java_timestamp.replace(tzinfo=tzlocal()).isoformat(), + 'suiteTimestamp': str(test_java_timestamp), 'data': None, 'type': 'case', }, @@ -279,7 +279,7 @@ def test_record_tests(self): 'status': 1, 'stdout': 'This is stdout', 'stderr': 'This is stderr', - 'createdAt': test_python_timestamp.replace(tzinfo=tzlocal()).isoformat(), + 'suiteTimestamp': str(test_python_timestamp), 'data': {"lineNumber": 5}, 'type': 'case', }, @@ -335,6 +335,7 @@ def test_record_tests_junit_xml(self): # Check request body payload = json.loads(gzip.decompress(self.find_request('/events').request.body).decode()) del payload['metadata'] + payload.pop('agentUtcOffset', None) self.assert_json_orderless_equal(payload, { 'events': [ { @@ -346,7 +347,7 @@ def test_record_tests_junit_xml(self): 'status': 1, 'stdout': '', 'stderr': '', - 'createdAt': timestamp1.replace(tzinfo=tzlocal()).isoformat(), + 'suiteTimestamp': str(timestamp1), 'data': None, 'type': 'case', }, @@ -359,7 +360,7 @@ def test_record_tests_junit_xml(self): 'status': 1, 'stdout': '', 'stderr': '', - 'createdAt': timestamp2.replace(tzinfo=tzlocal()).isoformat(), + 'suiteTimestamp': str(timestamp2), 'data': None, 'type': 'case', }, From ab46e996ff0ade9ea72c57b5e96be567375be277 Mon Sep 17 00:00:00 2001 From: Uday Date: Mon, 26 Oct 2026 12:56:03 +0530 Subject: [PATCH 2/2] AIENG-379: rename wire field suiteTimestamp -> junitSuiteTimestamp (avoid clash with ST test suite) Co-Authored-By: Claude Opus 4.8 --- launchable/commands/record/case_event.py | 2 +- tests/cli_test_case.py | 2 +- tests/commands/record/test_case_event.py | 4 ++-- tests/data/minitest/record_test_result_chunk1.json | 10 +++++----- tests/data/minitest/record_test_result_chunk2.json | 6 +++--- tests/test_runners/test_raw.py | 12 ++++++------ 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/launchable/commands/record/case_event.py b/launchable/commands/record/case_event.py index 89d193a44..5dd9dc25b 100644 --- a/launchable/commands/record/case_event.py +++ b/launchable/commands/record/case_event.py @@ -239,7 +239,7 @@ def create(cls, test_path: TestPath, duration_secs: float, status, "status": status, "stdout": stdout or "", "stderr": stderr or "", - "suiteTimestamp": timestamp, + "junitSuiteTimestamp": timestamp, "data": data } diff --git a/tests/cli_test_case.py b/tests/cli_test_case.py index 5c9b0fed2..d50b76673 100644 --- a/tests/cli_test_case.py +++ b/tests/cli_test_case.py @@ -268,7 +268,7 @@ def assert_record_tests_payload(self, golden_image_filename: str, payload=None): # value now; dedicated runner tests assert it directly). for c in payload['events']: c.pop('createdAt', None) - c.pop('suiteTimestamp', None) + c.pop('junitSuiteTimestamp', None) # metadata and the agent offset include machine/clock-dependent data del payload['metadata'] payload.pop('agentUtcOffset', None) diff --git a/tests/commands/record/test_case_event.py b/tests/commands/record/test_case_event.py index 11356b67a..0e9ffdedc 100644 --- a/tests/commands/record/test_case_event.py +++ b/tests/commands/record/test_case_event.py @@ -13,11 +13,11 @@ def test_suite_timestamp_is_sent_raw_and_unresolved(self): test_path=[], duration_secs=1.0, status=CaseEvent.TEST_PASSED, timestamp=ts ) # Sent verbatim, with no client-side timezone resolution, and no resolved createdAt. - self.assertEqual(result["suiteTimestamp"], ts) + self.assertEqual(result["junitSuiteTimestamp"], ts) self.assertNotIn("createdAt", result) def test_absent_timestamp_is_none(self): result = CaseEvent.create( test_path=[], duration_secs=1.0, status=CaseEvent.TEST_PASSED, timestamp=None ) - self.assertIsNone(result["suiteTimestamp"]) + self.assertIsNone(result["junitSuiteTimestamp"]) diff --git a/tests/data/minitest/record_test_result_chunk1.json b/tests/data/minitest/record_test_result_chunk1.json index f65681fdd..9d091f9a4 100644 --- a/tests/data/minitest/record_test_result_chunk1.json +++ b/tests/data/minitest/record_test_result_chunk1.json @@ -14,7 +14,7 @@ "status": 1, "stdout": "", "stderr": "", - "suiteTimestamp": "2020-12-23T13:10:01+09:00", + "junitSuiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -31,7 +31,7 @@ "status": 1, "stdout": "", "stderr": "", - "suiteTimestamp": "2020-12-23T13:10:01+09:00", + "junitSuiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -48,7 +48,7 @@ "status": 1, "stdout": "", "stderr": "", - "suiteTimestamp": "2020-12-23T13:10:01+09:00", + "junitSuiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -65,7 +65,7 @@ "status": 1, "stdout": "", "stderr": "", - "suiteTimestamp": "2020-12-23T13:10:01+09:00", + "junitSuiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -82,7 +82,7 @@ "status": 1, "stdout": "", "stderr": "", - "suiteTimestamp": "2020-12-23T13:10:01+09:00", + "junitSuiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null } ], diff --git a/tests/data/minitest/record_test_result_chunk2.json b/tests/data/minitest/record_test_result_chunk2.json index 8f5d8d311..c23da6cec 100644 --- a/tests/data/minitest/record_test_result_chunk2.json +++ b/tests/data/minitest/record_test_result_chunk2.json @@ -14,7 +14,7 @@ "status": 1, "stdout": "", "stderr": "", - "suiteTimestamp": "2020-12-23T13:10:01+09:00", + "junitSuiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -31,7 +31,7 @@ "status": 1, "stdout": "", "stderr": "", - "suiteTimestamp": "2020-12-23T13:10:01+09:00", + "junitSuiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null }, { @@ -45,7 +45,7 @@ "status": 1, "stdout": "", "stderr": "", - "suiteTimestamp": "2020-12-23T13:10:01+09:00", + "junitSuiteTimestamp": "2020-12-23T13:10:01+09:00", "data": null } ], diff --git a/tests/test_runners/test_raw.py b/tests/test_runners/test_raw.py index 2dc15f02c..a14b6b3cb 100644 --- a/tests/test_runners/test_raw.py +++ b/tests/test_runners/test_raw.py @@ -239,7 +239,7 @@ def test_record_tests(self): 'status': 1, 'stdout': 'This is stdout', 'stderr': 'This is stderr', - 'suiteTimestamp': str(test_ruby_timestamp), + 'junitSuiteTimestamp': str(test_ruby_timestamp), 'data': None, 'type': 'case', }, @@ -252,7 +252,7 @@ def test_record_tests(self): 'status': 1, 'stdout': 'This is stdout', 'stderr': 'This is stderr', - 'suiteTimestamp': str(test_go_timestamp), + 'junitSuiteTimestamp': str(test_go_timestamp), 'data': None, 'type': 'case', }, @@ -266,7 +266,7 @@ def test_record_tests(self): 'status': 1, 'stdout': 'This is stdout', 'stderr': 'This is stderr', - 'suiteTimestamp': str(test_java_timestamp), + 'junitSuiteTimestamp': str(test_java_timestamp), 'data': None, 'type': 'case', }, @@ -279,7 +279,7 @@ def test_record_tests(self): 'status': 1, 'stdout': 'This is stdout', 'stderr': 'This is stderr', - 'suiteTimestamp': str(test_python_timestamp), + 'junitSuiteTimestamp': str(test_python_timestamp), 'data': {"lineNumber": 5}, 'type': 'case', }, @@ -347,7 +347,7 @@ def test_record_tests_junit_xml(self): 'status': 1, 'stdout': '', 'stderr': '', - 'suiteTimestamp': str(timestamp1), + 'junitSuiteTimestamp': str(timestamp1), 'data': None, 'type': 'case', }, @@ -360,7 +360,7 @@ def test_record_tests_junit_xml(self): 'status': 1, 'stdout': '', 'stderr': '', - 'suiteTimestamp': str(timestamp2), + 'junitSuiteTimestamp': str(timestamp2), 'data': None, 'type': 'case', },