diff --git a/smart_tests/commands/record/case_event.py b/smart_tests/commands/record/case_event.py index 148cf355f..5c8bb8210 100644 --- a/smart_tests/commands/record/case_event.py +++ b/smart_tests/commands/record/case_event.py @@ -1,13 +1,8 @@ -import datetime import sys from typing import Any, Callable, Dict, Generator, List, Optional, Tuple -import dateutil.parser -from dateutil.tz import tzlocal from junitparser import Error, Failure, IntAttr, Skipped, TestCase, TestSuite -from smart_tests.utils.common_tz import COMMON_TIMEZONES # type: ignore - from ...testpath import FilePathNormalizer, TestPath POSSIBLE_RESULTS = (Failure, Error, Skipped) @@ -215,22 +210,13 @@ def child_text(tag: str) -> Optional[str]: def create(cls, test_path: TestPath, duration_secs: float, status, stdout: str | None = None, stderr: str | None = None, timestamp: str | None = None, data: Dict | None = None) -> Dict: - def _timestamp(ts: str | None = 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; 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 { @@ -240,7 +226,7 @@ def _timestamp(ts: str | None = None): "status": status, "stdout": stdout or "", "stderr": stderr or "", - "createdAt": _timestamp(timestamp), + "suiteTimestamp": timestamp, "data": data } diff --git a/smart_tests/commands/record/tests.py b/smart_tests/commands/record/tests.py index 1f41fc01b..5a0ffe67b 100644 --- a/smart_tests/commands/record/tests.py +++ b/smart_tests/commands/record/tests.py @@ -45,6 +45,17 @@ 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. + """ + 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(value): if value is None: return "" @@ -379,6 +390,7 @@ def payload( # testSuite and flavors are applied only when the no-build option is enabled "testSuite": test_suite_name, "flavors": flavors, + "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 af80af314..4b5bcf2b5 100644 --- a/tests/cli_test_case.py +++ b/tests/cli_test_case.py @@ -222,11 +222,14 @@ def assert_record_tests_payload(self, golden_image_filename: str, payload=None): if not payload: payload = self.decode_request_body(self.find_request('/events').request.body) - # 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 1933c54be..0438fc7c3 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 smart_tests.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.""" + + 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 9234f6c94..d3cf4e37f 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 smart_tests.test_runners.raw import _needs_test_path_file from smart_tests.utils.http_client import get_base_url @@ -229,6 +228,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': [ { @@ -240,7 +240,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', }, @@ -253,7 +253,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', }, @@ -267,7 +267,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', }, @@ -280,7 +280,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', }, @@ -333,6 +333,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': [ { @@ -344,7 +345,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', }, @@ -357,7 +358,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', },