From 7f6ad27306765a81f17999c033d4662b5e6b7e2f Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Mon, 25 May 2026 17:39:03 +0300 Subject: [PATCH 1/2] fix: compute robotframework step duration from total elapsed time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listener.__parse_steps was reading the .microseconds field of the elapsed_time timedelta — the residual-microseconds slot (0..999999) — and writing it to step.execution.duration. Because the model contract stores durations as milliseconds, this inflated every sub-second step's duration by ~1000x: a 2.3 ms step appeared as 2.3 s, a 0.5 s step as 500 s. Compute duration via int(elapsed_time.total_seconds() * 1000) so it reflects the real elapsed milliseconds. Add unit tests covering the ms conversion across sub-millisecond, sub-second and multi-second durations. --- .../src/qase/robotframework/listener.py | 2 +- .../test_listener.py | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/qase-robotframework/src/qase/robotframework/listener.py b/qase-robotframework/src/qase/robotframework/listener.py index 8490636d..894e676b 100644 --- a/qase-robotframework/src/qase/robotframework/listener.py +++ b/qase-robotframework/src/qase/robotframework/listener.py @@ -527,7 +527,7 @@ def __parse_steps(self, result, accumulated_vars: dict = None) -> List[Step]: step_status = "failed" # Keep as failed for steps, main test status is handled above step.execution.set_status(step_status) step.execution.start_time = result.body[i].start_time.timestamp() - step.execution.duration = result.body[i].elapsed_time.microseconds + step.execution.duration = int(result.body[i].elapsed_time.total_seconds() * 1000) step.execution.end_time = result.body[i].end_time.timestamp() if hasattr(result.body[i], "body"): diff --git a/qase-robotframework/tests/tests_qaseio_robotframework/test_listener.py b/qase-robotframework/tests/tests_qaseio_robotframework/test_listener.py index fc2aa3d8..0ded2c7a 100644 --- a/qase-robotframework/tests/tests_qaseio_robotframework/test_listener.py +++ b/qase-robotframework/tests/tests_qaseio_robotframework/test_listener.py @@ -4,6 +4,7 @@ or ConfigManager/QaseCoreReporter initialization. """ +from datetime import datetime, timedelta, timezone from unittest.mock import patch, MagicMock import pytest @@ -223,3 +224,66 @@ def test_non_root_call_without_prior_root_skips_extraction(self): listener.start_suite(account, MagicMock()) assert listener.tests == {} + + +class _FakeKeyword: + """Minimal Robot Framework keyword stand-in for __parse_steps. + + The class name must be ``Keyword`` so listener's ``class_name == 'Keyword'`` + branch is taken and ``elapsed_time`` is read from the real attribute. + """ + + def __init__(self, elapsed_seconds: float): + start = datetime(2026, 1, 1, tzinfo=timezone.utc) + self.type = "KEYWORD" + self.name = "Some Keyword" + self.args = () + self.status = "PASS" + self.start_time = start + self.elapsed_time = timedelta(seconds=elapsed_seconds) + self.end_time = start + self.elapsed_time + self.body = [] + + +Keyword = _FakeKeyword # rename so __class__.__name__ == 'Keyword' inside listener + + +class TestParseStepsDuration: + """Step duration must be elapsed time in milliseconds. + + Regression for the bug where duration was taking ``timedelta.microseconds`` + — the residual-microseconds field (0..999999) — instead of the full + elapsed milliseconds, inflating sub-second durations by ~1000x. + """ + + def _parse(self, listener, body_element): + result = MagicMock(spec=["body", "status"]) + result.body = [body_element] + result.status = "PASS" + return listener._Listener__parse_steps(result) + + @pytest.mark.parametrize( + "elapsed_seconds, expected_ms", + [ + (0.0015, 1), # 1.5 ms → 1 ms after int() + (0.5, 500), # half a second → 500 ms (was 500000 before the fix) + (2.5, 2500), # 2.5 s → 2500 ms (was 500000: only the residual µs field) + (12.345, 12345), # double-digit seconds with sub-ms tail + ], + ) + def test_duration_is_total_elapsed_milliseconds(self, elapsed_seconds, expected_ms): + listener = _bare_listener() + steps = self._parse(listener, Keyword(elapsed_seconds)) + + assert len(steps) == 1 + assert steps[0].execution.duration == expected_ms + + def test_start_and_end_time_round_trip(self): + listener = _bare_listener() + elapsed = 1.234 + keyword = Keyword(elapsed) + + steps = self._parse(listener, keyword) + + assert steps[0].execution.start_time == keyword.start_time.timestamp() + assert steps[0].execution.end_time == keyword.end_time.timestamp() From 73b0030b6a2daaa2c7943ccd00f2e159cca72bd4 Mon Sep 17 00:00:00 2001 From: Dmitrii Gridnev Date: Mon, 25 May 2026 17:39:09 +0300 Subject: [PATCH 2/2] chore: bump qase-robotframework to 6.0.1 --- qase-robotframework/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qase-robotframework/pyproject.toml b/qase-robotframework/pyproject.toml index 08d25e3b..e834447b 100644 --- a/qase-robotframework/pyproject.toml +++ b/qase-robotframework/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "qase-robotframework" -version = "6.0.0" +version = "6.0.1" description = "Qase Robot Framework Plugin" readme = "README.md" authors = [{name = "Qase Team", email = "support@qase.io"}]