Skip to content

Commit ed2fe6a

Browse files
authored
Merge pull request #494 from qase-tms/fix/behavex-real-timestamps
fix: preserve behavex absolute timestamps when replaying JSON
2 parents 77c1a6e + 699cc46 commit ed2fe6a

5 files changed

Lines changed: 198 additions & 17 deletions

File tree

qase-behave/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "qase-behave"
7-
version = "3.2.0"
7+
version = "3.2.1"
88
description = "Qase Behave Plugin for Qase TestOps and Qase Report"
99
readme = "README.md"
1010
keywords = ["qase", "behave", "plugin", "testops", "report", "qase reporting", "test observability"]

qase-behave/src/qase/behave/formatter.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,30 +137,58 @@ def launch_json_formatter(self, json_data):
137137
# Workers didn't run QaseFormatter — process JSON ourselves
138138
self.reporter.start_run()
139139

140+
time_offset = self._compute_time_offset(json_data)
141+
140142
for feature in json_data.get('features', []):
141143
feature_filename = feature.get('filename', '')
142144
for scenario_dict in feature.get('scenarios', []):
143-
result = parse_scenario_from_json(scenario_dict, feature_filename)
145+
result = parse_scenario_from_json(
146+
scenario_dict, feature_filename, time_offset=time_offset
147+
)
144148

145149
if result.ignore:
146150
continue
147151

148152
# Background steps first
149-
background = scenario_dict.get('background', {})
153+
background = scenario_dict.get('background') or {}
150154
for step_dict in background.get('steps', []):
151-
step = parse_step_from_json(step_dict)
155+
step = parse_step_from_json(step_dict, time_offset=time_offset)
152156
result.steps.append(step)
153157

154158
# Regular steps
155159
for step_dict in scenario_dict.get('steps', []):
156-
step = parse_step_from_json(step_dict)
160+
step = parse_step_from_json(step_dict, time_offset=time_offset)
157161
result.steps.append(step)
158162

159163
self.reporter.add_result(result)
160164

161165
self.reporter.complete_worker()
162166
self.reporter.complete_run()
163167

168+
@staticmethod
169+
def _compute_time_offset(json_data) -> float:
170+
"""Offset added to every BehaveX scenario/step timestamp.
171+
172+
BehaveX records absolute timestamps from before this Qase run was
173+
created, and the API rejects test results whose start_time predates
174+
the run. Shift all timestamps by the same constant so the earliest
175+
scenario lands at "now" — the relative ordering and durations
176+
between scenarios/steps (including worker parallelism) are
177+
preserved, but the whole timeline ends up inside the run window.
178+
"""
179+
earliest_ms = None
180+
for feature in json_data.get('features', []):
181+
for scenario_dict in feature.get('scenarios', []):
182+
sc_start = scenario_dict.get('start')
183+
if sc_start is None:
184+
continue
185+
if earliest_ms is None or sc_start < earliest_ms:
186+
earliest_ms = sc_start
187+
if earliest_ms is None:
188+
return 0.0
189+
from qase.commons.utils import QaseUtils
190+
return QaseUtils.get_real_time() - (earliest_ms / 1000.0)
191+
164192
def _cleanup_lock_files(self):
165193
"""Remove lock and run_id files."""
166194
for path in (self._run_id_file, self._lock_file):

qase-behave/src/qase/behave/utils.py

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,17 @@ def __extract_fields(tag: str) -> dict:
143143
return {}
144144

145145

146-
def parse_scenario_from_json(scenario_dict: dict, feature_filename: str) -> Result:
147-
"""Parse a BehaveX JSON scenario dict into a Qase Result."""
146+
def parse_scenario_from_json(
147+
scenario_dict: dict,
148+
feature_filename: str,
149+
time_offset: float = 0.0,
150+
) -> Result:
151+
"""Parse a BehaveX JSON scenario dict into a Qase Result.
152+
153+
``time_offset`` (seconds) is added to every absolute timestamp read
154+
from BehaveX so the original timeline can be replayed inside the
155+
current Qase run window. See ``QaseFormatter._compute_time_offset``.
156+
"""
148157
tags = __parse_tags(scenario_dict.get('tags', []))
149158

150159
name = scenario_dict.get('name', '')
@@ -190,11 +199,17 @@ def parse_scenario_from_json(scenario_dict: dict, feature_filename: str) -> Resu
190199

191200
duration = scenario_dict.get('duration', 0)
192201
result.execution.duration = int(duration * 1000)
193-
# Always calculate timestamps relative to current time.
194-
# BehaveX timestamps are from before run creation and would be rejected by the API.
195-
current_time = QaseUtils.get_real_time()
196-
result.execution.end_time = current_time
197-
result.execution.start_time = current_time - duration
202+
start_ms = scenario_dict.get('start')
203+
stop_ms = scenario_dict.get('stop')
204+
if start_ms is not None and stop_ms is not None:
205+
result.execution.start_time = (start_ms / 1000.0) + time_offset
206+
result.execution.end_time = (stop_ms / 1000.0) + time_offset
207+
else:
208+
# Fallback when BehaveX did not record absolute timestamps:
209+
# synthesise a window ending "now" with the recorded duration.
210+
current_time = QaseUtils.get_real_time()
211+
result.execution.end_time = current_time
212+
result.execution.start_time = current_time - duration
198213

199214
worker_id = scenario_dict.get('worker_id')
200215
if worker_id is not None:
@@ -216,8 +231,11 @@ def parse_scenario_from_json(scenario_dict: dict, feature_filename: str) -> Resu
216231
return result
217232

218233

219-
def parse_step_from_json(step_dict: dict) -> QaseStep:
220-
"""Parse a BehaveX JSON step dict into a Qase Step."""
234+
def parse_step_from_json(step_dict: dict, time_offset: float = 0.0) -> QaseStep:
235+
"""Parse a BehaveX JSON step dict into a Qase Step.
236+
237+
See ``parse_scenario_from_json`` for the ``time_offset`` contract.
238+
"""
221239
keyword = step_dict.get('step_type', 'given')
222240
name = step_dict.get('name', '')
223241
line = step_dict.get('line', 0)
@@ -242,9 +260,15 @@ def parse_step_from_json(step_dict: dict) -> QaseStep:
242260

243261
duration = step_dict.get('duration', 0)
244262
model.execution.duration = int(duration * 1000)
245-
current_time = QaseUtils.get_real_time()
246-
model.execution.end_time = current_time
247-
model.execution.start_time = current_time - duration
263+
start_ms = step_dict.get('start')
264+
stop_ms = step_dict.get('stop')
265+
if start_ms is not None and stop_ms is not None:
266+
model.execution.start_time = (start_ms / 1000.0) + time_offset
267+
model.execution.end_time = (stop_ms / 1000.0) + time_offset
268+
else:
269+
current_time = QaseUtils.get_real_time()
270+
model.execution.end_time = current_time
271+
model.execution.start_time = current_time - duration
248272

249273
return model
250274

qase-behave/tests/test_formatter.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,61 @@ def test_launch_json_formatter_with_existing_lock_file(self):
281281
if os.path.exists(lock_path):
282282
os.remove(lock_path)
283283

284+
def test_launch_json_formatter_survives_null_background(self):
285+
"""Real BehaveX reports often carry ``"background": null`` for
286+
scenarios without a background — ``.get('background', {})`` would
287+
return None and the next ``.get('steps')`` would raise."""
288+
formatter = QaseFormatter()
289+
mock_reporter = MagicMock()
290+
mock_reporter.start_run.return_value = "1"
291+
292+
json_data = {
293+
"features": [{
294+
"name": "F", "filename": "f.feature",
295+
"scenarios": [{
296+
"name": "X", "status": "passed", "duration": 0.0,
297+
"tags": [], "filename": "f.feature", "line": 1,
298+
"steps": [],
299+
"background": None,
300+
}],
301+
}],
302+
}
303+
304+
with patch('qase.behave.formatter.QaseCoreReporter', return_value=mock_reporter), \
305+
patch('qase.behave.formatter.ConfigManager'):
306+
formatter.launch_json_formatter(json_data)
307+
308+
mock_reporter.add_result.assert_called_once()
309+
310+
311+
class TestComputeTimeOffset:
312+
"""``_compute_time_offset`` shifts the whole BehaveX timeline so the
313+
earliest scenario lands at ~"now", preserving relative timing."""
314+
315+
def test_no_scenarios_returns_zero(self):
316+
offset = QaseFormatter._compute_time_offset({"features": []})
317+
assert offset == 0.0
318+
319+
def test_scenarios_without_start_returns_zero(self):
320+
json_data = {"features": [{"scenarios": [{"name": "x"}]}]}
321+
assert QaseFormatter._compute_time_offset(json_data) == 0.0
322+
323+
def test_offset_lands_earliest_near_now(self):
324+
from qase.commons.utils import QaseUtils
325+
before = QaseUtils.get_real_time()
326+
json_data = {"features": [{"scenarios": [
327+
{"name": "later", "start": 1_000_500}, # 1000.5 s
328+
{"name": "earlier", "start": 1_000_000}, # 1000.0 s ← earliest
329+
{"name": "latest", "start": 1_000_800},
330+
]}]}
331+
332+
offset = QaseFormatter._compute_time_offset(json_data)
333+
after = QaseUtils.get_real_time()
334+
335+
# Earliest BehaveX ts (1000.0 s) + offset ≈ now → offset ≈ now - 1000.0.
336+
# Allow for the small wall-clock window in this test.
337+
assert before - 1000.0 <= offset <= after - 1000.0
338+
284339

285340
class TestBehaveXWorkerMode:
286341
"""Test QaseFormatter in BehaveX worker mode (lock file coordination)."""

qase-behave/tests/test_utils.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,3 +350,77 @@ def test_step_defaults(self):
350350
assert step.data.name == ''
351351
assert step.data.line == 0
352352
assert step.step_type == StepType.GHERKIN
353+
354+
355+
class TestBehavexAbsoluteTimestamps:
356+
"""When BehaveX records ``start`` / ``stop`` (unix-ms), the parsed
357+
Result/Step must use those timestamps (shifted by ``time_offset``)
358+
rather than synthesising a window relative to ``now()``."""
359+
360+
def test_scenario_uses_real_start_stop_with_offset(self):
361+
# BehaveX timestamps from an old run (start=10s, stop=10.5s in unix-ms).
362+
scenario_dict = {
363+
'name': 'old run', 'status': 'passed', 'duration': 0.5,
364+
'tags': [], 'start': 10_000, 'stop': 10_500,
365+
}
366+
offset = 1_000_000.0 # shift the whole timeline by 1e6 seconds
367+
368+
result = parse_scenario_from_json(
369+
scenario_dict, 'features/x.feature', time_offset=offset
370+
)
371+
372+
assert result.execution.start_time == 10.0 + offset
373+
assert result.execution.end_time == 10.5 + offset
374+
assert result.execution.duration == 500
375+
376+
def test_scenario_without_start_stop_falls_back_to_now(self):
377+
scenario_dict = {
378+
'name': 'no times', 'status': 'passed', 'duration': 0.3, 'tags': [],
379+
}
380+
result = parse_scenario_from_json(
381+
scenario_dict, 'features/x.feature', time_offset=999.0
382+
)
383+
384+
# No start/stop → behave like the legacy path; offset must NOT be applied.
385+
assert result.execution.duration == 300
386+
assert result.execution.end_time > 1_000_000 # current unix time, not 999
387+
assert abs(result.execution.end_time - result.execution.start_time - 0.3) < 0.1
388+
389+
def test_step_uses_real_start_stop_with_offset(self):
390+
step_dict = {
391+
'step_type': 'when', 'name': 'press button', 'line': 1,
392+
'status': 'passed', 'duration': 0.12,
393+
'start': 2_000, 'stop': 2_120,
394+
}
395+
offset = 5_000_000.0
396+
step = parse_step_from_json(step_dict, time_offset=offset)
397+
398+
assert step.execution.start_time == 2.0 + offset
399+
assert step.execution.end_time == 2.12 + offset
400+
assert step.execution.duration == 120
401+
402+
def test_scenarios_preserve_real_ordering_after_offset(self):
403+
"""Three BehaveX scenarios spaced 200 ms apart must remain spaced
404+
200 ms apart after offsetting (regression for the bug that
405+
collapsed all scenarios onto the same current_time)."""
406+
offset = 1_000_000.0
407+
scenarios = [
408+
{'name': 'A', 'status': 'passed', 'duration': 0.1, 'tags': [],
409+
'start': 0, 'stop': 100},
410+
{'name': 'B', 'status': 'passed', 'duration': 0.2, 'tags': [],
411+
'start': 200, 'stop': 400},
412+
{'name': 'C', 'status': 'passed', 'duration': 0.3, 'tags': [],
413+
'start': 600, 'stop': 900},
414+
]
415+
416+
results = [
417+
parse_scenario_from_json(sc, 'features/x.feature', time_offset=offset)
418+
for sc in scenarios
419+
]
420+
421+
starts = [r.execution.start_time for r in results]
422+
ends = [r.execution.end_time for r in results]
423+
424+
assert ends[0] < starts[1] < ends[1] < starts[2] < ends[2]
425+
assert pytest.approx(starts[1] - starts[0], abs=1e-6) == 0.2
426+
assert pytest.approx(starts[2] - starts[1], abs=1e-6) == 0.4

0 commit comments

Comments
 (0)