Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 4 additions & 18 deletions launchable/commands/record/case_event.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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 <testsuite timestamp> 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 {
Expand All @@ -253,7 +239,7 @@ def _timestamp(ts: Optional[str] = None):
"status": status,
"stdout": stdout or "",
"stderr": stderr or "",
"createdAt": _timestamp(timestamp),
"junitSuiteTimestamp": timestamp,
"data": data
}

Expand Down
15 changes: 15 additions & 0 deletions launchable/commands/record/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 6 additions & 3 deletions tests/cli_test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('junitSuiteTimestamp', 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)
Expand Down
46 changes: 16 additions & 30 deletions tests/commands/record/test_case_event.py
Original file line number Diff line number Diff line change
@@ -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["junitSuiteTimestamp"], 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["junitSuiteTimestamp"])
10 changes: 5 additions & 5 deletions tests/data/minitest/record_test_result_chunk1.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"status": 1,
"stdout": "",
"stderr": "",
"createdAt": "2020-12-23T13:10:01+09:00",
"junitSuiteTimestamp": "2020-12-23T13:10:01+09:00",
"data": null
},
{
Expand All @@ -31,7 +31,7 @@
"status": 1,
"stdout": "",
"stderr": "",
"createdAt": "2020-12-23T13:10:01+09:00",
"junitSuiteTimestamp": "2020-12-23T13:10:01+09:00",
"data": null
},
{
Expand All @@ -48,7 +48,7 @@
"status": 1,
"stdout": "",
"stderr": "",
"createdAt": "2020-12-23T13:10:01+09:00",
"junitSuiteTimestamp": "2020-12-23T13:10:01+09:00",
"data": null
},
{
Expand All @@ -65,7 +65,7 @@
"status": 1,
"stdout": "",
"stderr": "",
"createdAt": "2020-12-23T13:10:01+09:00",
"junitSuiteTimestamp": "2020-12-23T13:10:01+09:00",
"data": null
},
{
Expand All @@ -82,7 +82,7 @@
"status": 1,
"stdout": "",
"stderr": "",
"createdAt": "2020-12-23T13:10:01+09:00",
"junitSuiteTimestamp": "2020-12-23T13:10:01+09:00",
"data": null
}
],
Expand Down
6 changes: 3 additions & 3 deletions tests/data/minitest/record_test_result_chunk2.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"status": 1,
"stdout": "",
"stderr": "",
"createdAt": "2020-12-23T13:10:01+09:00",
"junitSuiteTimestamp": "2020-12-23T13:10:01+09:00",
"data": null
},
{
Expand All @@ -31,7 +31,7 @@
"status": 1,
"stdout": "",
"stderr": "",
"createdAt": "2020-12-23T13:10:01+09:00",
"junitSuiteTimestamp": "2020-12-23T13:10:01+09:00",
"data": null
},
{
Expand All @@ -45,7 +45,7 @@
"status": 1,
"stdout": "",
"stderr": "",
"createdAt": "2020-12-23T13:10:01+09:00",
"junitSuiteTimestamp": "2020-12-23T13:10:01+09:00",
"data": null
}
],
Expand Down
15 changes: 8 additions & 7 deletions tests/test_runners/test_raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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': [
{
Expand All @@ -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(),
'junitSuiteTimestamp': str(test_ruby_timestamp),
'data': None,
'type': 'case',
},
Expand All @@ -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(),
'junitSuiteTimestamp': str(test_go_timestamp),
'data': None,
'type': 'case',
},
Expand All @@ -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(),
'junitSuiteTimestamp': str(test_java_timestamp),
'data': None,
'type': 'case',
},
Expand All @@ -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(),
'junitSuiteTimestamp': str(test_python_timestamp),
'data': {"lineNumber": 5},
'type': 'case',
},
Expand Down Expand Up @@ -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': [
{
Expand All @@ -346,7 +347,7 @@ def test_record_tests_junit_xml(self):
'status': 1,
'stdout': '',
'stderr': '',
'createdAt': timestamp1.replace(tzinfo=tzlocal()).isoformat(),
'junitSuiteTimestamp': str(timestamp1),
'data': None,
'type': 'case',
},
Expand All @@ -359,7 +360,7 @@ def test_record_tests_junit_xml(self):
'status': 1,
'stdout': '',
'stderr': '',
'createdAt': timestamp2.replace(tzinfo=tzlocal()).isoformat(),
'junitSuiteTimestamp': str(timestamp2),
'data': None,
'type': 'case',
},
Expand Down
Loading