cucu 1.3.15__py3-none-any.whl → 1.3.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cucu/browser/core.py CHANGED
@@ -67,6 +67,9 @@ class Browser:
67
67
  def switch_to_tab_that_matches_regex(self, text):
68
68
  raise RuntimeError("implement me")
69
69
 
70
+ def get_session_id(self):
71
+ raise RuntimeError("implement me")
72
+
70
73
  def quit(self):
71
74
  raise RuntimeError("implement me")
72
75
 
cucu/browser/selenium.py CHANGED
@@ -195,6 +195,13 @@ class Selenium(Browser):
195
195
  raise Exception(f"unknown browser {browser}")
196
196
 
197
197
  self.driver.set_window_size(width, height)
198
+ session_id = self.get_session_id()
199
+ logger.debug(f"cucu started Selenium session with ID: {session_id}")
200
+
201
+ def get_session_id(self):
202
+ if self.driver:
203
+ return getattr(self.driver, "session_id", None)
204
+ return None
198
205
 
199
206
  def get_log(self):
200
207
  if config.CONFIG["CUCU_BROWSER"] == "firefox":
cucu/cli/core.py CHANGED
@@ -1,8 +1,8 @@
1
1
  # -*- coding: utf-8 -*-
2
- import json
3
2
  import os
4
3
  import shutil
5
4
  import signal
5
+ import sqlite3
6
6
  import sys
7
7
  import time
8
8
  import xml.etree.ElementTree as ET
@@ -154,12 +154,6 @@ def main():
154
154
  help="the location to put the test report when --generate-report is used",
155
155
  type=click.Path(path_type=Path),
156
156
  )
157
- @click.option(
158
- "--report-only-failures",
159
- default=False,
160
- is_flag=True,
161
- help="when set the HTML test report will only contain the failed test results",
162
- )
163
157
  @click.option(
164
158
  "-r",
165
159
  "--results",
@@ -228,7 +222,6 @@ def run(
228
222
  preserve_results,
229
223
  record_env_vars,
230
224
  report,
231
- report_only_failures,
232
225
  results,
233
226
  runtime_timeout,
234
227
  feature_timeout,
@@ -291,9 +284,6 @@ def run(
291
284
  if junit_with_stacktrace:
292
285
  os.environ["CUCU_JUNIT_WITH_STACKTRACE"] = "true"
293
286
 
294
- if report_only_failures:
295
- os.environ["CUCU_REPORT_ONLY_FAILURES"] = "true"
296
-
297
287
  if record_env_vars:
298
288
  os.environ["CUCU_RECORD_ENV_VARS"] = "true"
299
289
 
@@ -514,7 +504,6 @@ def run(
514
504
  _generate_report(
515
505
  results_dir=results,
516
506
  report_folder=report,
517
- only_failures=report_only_failures,
518
507
  junit_folder=junit,
519
508
  )
520
509
 
@@ -522,7 +511,6 @@ def run(
522
511
  def _generate_report(
523
512
  results_dir: Path,
524
513
  report_folder: Path,
525
- only_failures: False,
526
514
  junit_folder: Path | None = None,
527
515
  combine: bool = False,
528
516
  ):
@@ -534,9 +522,7 @@ def _generate_report(
534
522
  if results_dir.exists():
535
523
  consolidate_database_files(results_dir, combine)
536
524
 
537
- report_location = reporter.generate(
538
- results_dir, report_folder, only_failures=only_failures
539
- )
525
+ report_location = reporter.generate(results_dir, report_folder)
540
526
  print(f"HTML test report at {report_location}")
541
527
 
542
528
  if junit_folder:
@@ -559,12 +545,6 @@ def _generate_report(
559
545
  @click.argument(
560
546
  "results_dir", default="results", type=click.Path(path_type=Path)
561
547
  )
562
- @click.option(
563
- "--only-failures",
564
- default=False,
565
- is_flag=True,
566
- help="when set the HTML test report will only contain the failed test results",
567
- )
568
548
  @click.option(
569
549
  "-l",
570
550
  "--logging-level",
@@ -599,7 +579,6 @@ def _generate_report(
599
579
  )
600
580
  def report(
601
581
  results_dir: Path,
602
- only_failures,
603
582
  logging_level,
604
583
  show_skips,
605
584
  output: Path,
@@ -617,23 +596,23 @@ def report(
617
596
  if show_skips:
618
597
  os.environ["CUCU_SHOW_SKIPS"] = "true"
619
598
 
620
- run_details_filepath = results_dir / "run_details.json"
621
-
622
- if os.path.exists(run_details_filepath):
623
- # load the run details at the time of execution for the provided results
624
- # directory
625
- run_details = {}
626
-
627
- with open(run_details_filepath, encoding="utf8") as _input:
628
- run_details = json.loads(_input.read())
629
-
630
- # initialize any underlying custom step code things
631
- behave_init(run_details["filepath"])
599
+ run_db_path = results_dir / "run.db"
600
+ if run_db_path.exists():
601
+ # query cucu_run to get the original filepath used during the run
602
+ with sqlite3.connect(run_db_path) as conn:
603
+ cursor = conn.cursor()
604
+ cursor.execute(
605
+ "SELECT filepath FROM cucu_run ORDER BY start_at DESC LIMIT 1"
606
+ )
607
+ row = cursor.fetchone()
608
+ if row:
609
+ filepath = row[0]
610
+ # initialize any underlying custom step code things
611
+ behave_init(filepath)
632
612
 
633
613
  _generate_report(
634
614
  results_dir=results_dir,
635
615
  report_folder=output,
636
- only_failures=only_failures,
637
616
  junit_folder=junit,
638
617
  combine=combine,
639
618
  )
cucu/db.py CHANGED
@@ -5,7 +5,6 @@ Database creation and management utilities for cucu.
5
5
  import logging
6
6
  import sqlite3
7
7
  import sys
8
- from datetime import datetime
9
8
  from pathlib import Path
10
9
 
11
10
  from peewee import (
@@ -20,7 +19,9 @@ from peewee import (
20
19
  from playhouse.sqlite_ext import JSONField, SqliteExtDatabase
21
20
  from tenacity import RetryError
22
21
 
22
+ from cucu import logger as cucu_logger
23
23
  from cucu.config import CONFIG
24
+ from cucu.utils import get_iso_timestamp_with_ms, parse_iso_timestamp
24
25
 
25
26
  db_filepath = CONFIG["RUN_DB_PATH"]
26
27
  db = SqliteExtDatabase(db_filepath)
@@ -89,7 +90,7 @@ class scenario(BaseModel):
89
90
  column_name="feature_run_id",
90
91
  )
91
92
  name = TextField()
92
- seq = FloatField(null=True)
93
+ seq = IntegerField(null=True)
93
94
  status = TextField(null=True)
94
95
  duration = FloatField(null=True)
95
96
  start_at = DateTimeField(null=True)
@@ -124,13 +125,14 @@ class step(BaseModel):
124
125
  stderr = JSONField()
125
126
  error_message = JSONField(null=True)
126
127
  exception = JSONField(null=True)
127
- debug_output = TextField()
128
+ debug_output = JSONField()
128
129
  browser_info = JSONField()
129
130
  text = JSONField(null=True)
130
131
  table_data = JSONField(null=True)
131
132
  location = TextField()
132
- browser_logs = TextField()
133
- screenshots = JSONField(null=True)
133
+ browser_logs = JSONField()
134
+ screenshots = JSONField()
135
+ image_dir = TextField(null=True)
134
136
 
135
137
 
136
138
  def record_cucu_run():
@@ -139,12 +141,11 @@ def record_cucu_run():
139
141
  worker_run_id = CONFIG["WORKER_RUN_ID"]
140
142
 
141
143
  db.connect(reuse_if_open=True)
142
- start_at = datetime.now().isoformat()
143
144
  cucu_run.create(
144
145
  cucu_run_id=cucu_run_id_val,
145
146
  full_arguments=sys.argv,
146
147
  filepath=filepath,
147
- start_at=start_at,
148
+ start_at=parse_iso_timestamp(get_iso_timestamp_with_ms()),
148
149
  )
149
150
 
150
151
  parent_id = (
@@ -156,7 +157,7 @@ def record_cucu_run():
156
157
  worker_run_id=worker_run_id,
157
158
  cucu_run_id=cucu_run_id_val,
158
159
  parent_id=parent_id,
159
- start_at=datetime.now().isoformat(),
160
+ start_at=parse_iso_timestamp(get_iso_timestamp_with_ms()),
160
161
  )
161
162
 
162
163
  return str(db_filepath)
@@ -173,7 +174,7 @@ def record_feature(feature_obj):
173
174
  if isinstance(feature_obj.description, list)
174
175
  else str(feature_obj.description),
175
176
  tags=feature_obj.tags,
176
- start_at=datetime.now().isoformat(),
177
+ start_at=parse_iso_timestamp(get_iso_timestamp_with_ms()),
177
178
  )
178
179
 
179
180
 
@@ -186,7 +187,7 @@ def record_scenario(scenario_obj):
186
187
  line_number=scenario_obj.line,
187
188
  seq=scenario_obj.seq,
188
189
  tags=scenario_obj.tags,
189
- start_at=getattr(scenario_obj, "start_at", None),
190
+ start_at=parse_iso_timestamp(getattr(scenario_obj, "start_at", None)),
190
191
  )
191
192
 
192
193
 
@@ -214,31 +215,25 @@ def start_step_record(step_obj, scenario_run_id):
214
215
  has_substeps=getattr(step_obj, "has_substeps", False),
215
216
  section_level=getattr(step_obj, "section_level", None),
216
217
  browser_info="",
217
- browser_logs="",
218
- debug_output="",
218
+ browser_logs=[],
219
+ error_message=[],
220
+ debug_logs=[],
221
+ debug_output=[],
219
222
  stderr=[],
220
223
  stdout=[],
224
+ screenshots=[],
221
225
  )
222
226
 
223
227
 
224
228
  def finish_step_record(step_obj, duration):
225
229
  db.connect(reuse_if_open=True)
226
- screenshot_infos = []
227
- if hasattr(step_obj, "screenshots") and step_obj.screenshots:
228
- for screenshot in step_obj.screenshots:
229
- screenshot_info = {
230
- "step_name": screenshot.get("step_name"),
231
- "label": screenshot.get("label"),
232
- "location": screenshot.get("location"),
233
- "size": screenshot.get("size"),
234
- "filepath": screenshot.get("filepath"),
235
- }
236
- screenshot_infos.append(screenshot_info)
237
-
238
- error_message = None
230
+
231
+ error_message = []
239
232
  exception = []
240
233
  if step.error_message and step_obj.status.name == "failed":
241
- error_message = CONFIG.hide_secrets(step_obj.error_message)
234
+ error_message = CONFIG.hide_secrets(
235
+ step_obj.error_message
236
+ ).splitlines()
242
237
 
243
238
  if error := step_obj.exception:
244
239
  if isinstance(error, RetryError):
@@ -255,35 +250,30 @@ def finish_step_record(step_obj, duration):
255
250
  exception = error_lines
256
251
 
257
252
  step.update(
258
- browser_info=getattr(step_obj, "browser_info", ""),
259
- browser_logs=getattr(step_obj, "browser_logs", ""),
260
- debug_output=getattr(step_obj, "debug_output", ""),
253
+ browser_info=getattr(step_obj, "browser_info", {}),
254
+ browser_logs=getattr(step_obj, "browser_logs", []),
255
+ debug_output=getattr(step_obj, "debug_output", []),
261
256
  duration=duration,
262
257
  end_at=getattr(step_obj, "end_at", None),
263
258
  error_message=error_message,
264
259
  exception=exception,
265
260
  has_substeps=getattr(step_obj, "has_substeps", False),
266
261
  parent_seq=getattr(step_obj, "parent_seq", None),
267
- screenshots=screenshot_infos,
262
+ screenshots=getattr(step_obj, "screenshots", []),
268
263
  section_level=getattr(step_obj, "section_level", None),
269
264
  seq=step_obj.seq,
270
- start_at=getattr(step_obj, "start_at", None),
265
+ start_at=parse_iso_timestamp(getattr(step_obj, "start_at", None)),
271
266
  status=step_obj.status.name,
272
267
  stderr=getattr(step_obj, "stderr", []),
273
268
  stdout=getattr(step_obj, "stdout", []),
269
+ image_dir=getattr(step_obj, "step_image_dir", None),
274
270
  ).where(step.step_run_id == step_obj.step_run_id).execute()
275
271
 
276
272
 
277
273
  def finish_scenario_record(scenario_obj):
278
274
  db.connect(reuse_if_open=True)
279
- if getattr(scenario_obj, "start_at", None):
280
- start_at = datetime.fromisoformat(scenario_obj.start_at)
281
- else:
282
- start_at = None
283
- if getattr(scenario_obj, "end_at", None):
284
- end_at = datetime.fromisoformat(scenario_obj.end_at)
285
- else:
286
- end_at = None
275
+ start_at = parse_iso_timestamp(getattr(scenario_obj, "start_at", None))
276
+ end_at = parse_iso_timestamp(getattr(scenario_obj, "end_at", None))
287
277
  if start_at and end_at:
288
278
  duration = (end_at - start_at).total_seconds()
289
279
  else:
@@ -321,7 +311,7 @@ def finish_feature_record(feature_obj):
321
311
  db.connect(reuse_if_open=True)
322
312
  feature.update(
323
313
  status=feature_obj.status.name,
324
- end_at=datetime.now().isoformat(),
314
+ end_at=parse_iso_timestamp(get_iso_timestamp_with_ms()),
325
315
  custom_data=feature_obj.custom_data,
326
316
  ).where(feature.feature_run_id == feature_obj.feature_run_id).execute()
327
317
 
@@ -330,7 +320,7 @@ def finish_worker_record(custom_data=None, worker_run_id=None):
330
320
  db.connect(reuse_if_open=True)
331
321
  target_worker_run_id = worker_run_id or CONFIG["WORKER_RUN_ID"]
332
322
  worker.update(
333
- end_at=datetime.now().isoformat(),
323
+ end_at=parse_iso_timestamp(get_iso_timestamp_with_ms()),
334
324
  custom_data=custom_data,
335
325
  ).where(worker.worker_run_id == target_worker_run_id).execute()
336
326
 
@@ -338,7 +328,7 @@ def finish_worker_record(custom_data=None, worker_run_id=None):
338
328
  def finish_cucu_run_record():
339
329
  db.connect(reuse_if_open=True)
340
330
  cucu_run.update(
341
- end_at=datetime.now().isoformat(),
331
+ end_at=parse_iso_timestamp(get_iso_timestamp_with_ms()),
342
332
  ).where(cucu_run.cucu_run_id == CONFIG["CUCU_RUN_ID"]).execute()
343
333
 
344
334
 
@@ -351,6 +341,57 @@ def create_database_file(db_filepath):
351
341
  db.init(db_filepath)
352
342
  db.connect(reuse_if_open=True)
353
343
  db.create_tables([cucu_run, worker, feature, scenario, step])
344
+ db.execute_sql("""
345
+ CREATE VIEW IF NOT EXISTS flat_all AS
346
+ WITH scenario_with_steps AS (
347
+ SELECT
348
+ *,
349
+ COUNT(st.step_run_id) AS steps
350
+ FROM scenario s
351
+ LEFT JOIN step st ON s.scenario_run_id = st.scenario_run_id
352
+ GROUP BY s.scenario_run_id
353
+ )
354
+ SELECT
355
+ COUNT(DISTINCT s.feature_run_id) AS features,
356
+ COUNT(s.scenario_run_id) AS scenarios,
357
+ SUM(CASE WHEN s.status = 'passed' THEN 1 ELSE 0 END) AS passed,
358
+ SUM(CASE WHEN s.status = 'failed' THEN 1 ELSE 0 END) AS failed,
359
+ SUM(CASE WHEN s.status = 'skipped' THEN 1 ELSE 0 END) AS skipped,
360
+ SUM(CASE WHEN s.status = 'errored' THEN 1 ELSE 0 END) AS errored,
361
+ SUM(s.duration) AS duration,
362
+ SUM(s.steps) AS steps
363
+ FROM scenario_with_steps s
364
+ """)
365
+ db.execute_sql("""
366
+ CREATE VIEW IF NOT EXISTS flat_feature AS
367
+ WITH feature_first_level AS (
368
+ SELECT
369
+ w.cucu_run_id,
370
+ f.start_at,
371
+ f.name AS feature_name,
372
+ COUNT(s.scenario_run_id) AS scenarios,
373
+ SUM(CASE WHEN s.status = 'passed' THEN 1 ELSE 0 END) AS passed,
374
+ SUM(CASE WHEN s.status = 'failed' THEN 1 ELSE 0 END) AS failed,
375
+ SUM(CASE WHEN s.status = 'skipped' THEN 1 ELSE 0 END) AS skipped,
376
+ SUM(CASE WHEN s.status = 'errored' THEN 1 ELSE 0 END) AS errored,
377
+ SUM(s.duration) AS duration
378
+ FROM cucu_run r
379
+ JOIN worker w ON r.cucu_run_id = w.cucu_run_id
380
+ JOIN feature f ON w.worker_run_id = f.worker_run_id
381
+ JOIN scenario s ON f.feature_run_id = s.feature_run_id
382
+ GROUP BY f.feature_run_id
383
+ )
384
+ SELECT
385
+ *,
386
+ CASE
387
+ WHEN failed > 0 THEN 'failed'
388
+ WHEN errored > 0 THEN 'errored'
389
+ WHEN passed > 0 THEN 'passed'
390
+ WHEN skipped > 0 THEN 'skipped'
391
+ END AS status
392
+ FROM feature_first_level
393
+ ORDER BY start_at ASC
394
+ """)
354
395
  db.execute_sql("""
355
396
  CREATE VIEW IF NOT EXISTS flat AS
356
397
  SELECT
@@ -416,7 +457,12 @@ def consolidate_database_files(results_dir, combine=False):
416
457
  results_path = Path(results_dir)
417
458
  target_db_path = results_path / "run.db"
418
459
  if not target_db_path.exists():
460
+ cucu_logger.info(
461
+ f"Creating new consolidated database at {target_db_path}"
462
+ )
419
463
  create_database_file(target_db_path)
464
+ else:
465
+ cucu_logger.debug(f"Found existing database at {target_db_path}")
420
466
 
421
467
  if not combine:
422
468
  db_files = [
@@ -428,6 +474,14 @@ def consolidate_database_files(results_dir, combine=False):
428
474
  db for db in results_path.rglob("run*.db") if db != Path("run.db")
429
475
  ]
430
476
 
477
+ if not db_files:
478
+ cucu_logger.debug("No database files found to consolidate.")
479
+ return
480
+ else:
481
+ cucu_logger.debug(
482
+ f"Found {len(db_files)} database files to consolidate."
483
+ )
484
+
431
485
  tables_to_copy = ["cucu_run", "worker", "feature", "scenario", "step"]
432
486
  with sqlite3.connect(target_db_path) as target_conn:
433
487
  target_cursor = target_conn.cursor()
cucu/environment.py CHANGED
@@ -1,4 +1,3 @@
1
- import datetime
2
1
  import json
3
2
  import sys
4
3
  import traceback
@@ -13,6 +12,8 @@ from cucu.page_checks import init_page_checks
13
12
  from cucu.utils import (
14
13
  TeeStream,
15
14
  ellipsize_filename,
15
+ get_iso_timestamp_with_ms,
16
+ parse_iso_timestamp,
16
17
  take_screenshot,
17
18
  )
18
19
 
@@ -90,7 +91,7 @@ def before_scenario(ctx, scenario):
90
91
 
91
92
  # reset the step timer dictionary
92
93
  ctx.step_timers = {}
93
- scenario.start_at = datetime.datetime.now().isoformat()[:-3]
94
+ scenario.start_at = get_iso_timestamp_with_ms()
94
95
 
95
96
  if config.CONFIG["CUCU_RESULTS_DIR"] is not None:
96
97
  ctx.scenario_dir = ctx.feature_dir / ellipsize_filename(scenario.name)
@@ -173,10 +174,10 @@ def after_scenario(ctx, scenario):
173
174
  "current_tab_index": tab_info["index"],
174
175
  "all_tabs": all_tabs,
175
176
  "browser_type": ctx.browser.driver.name,
177
+ "session_id": ctx.browser.get_session_id(),
176
178
  }
177
179
  except Exception as e:
178
180
  logger.error(f"Error getting browser info: {e}")
179
-
180
181
  scenario.browser_info = browser_info
181
182
 
182
183
  run_after_scenario_hook(ctx, scenario, download_mht_data)
@@ -205,7 +206,7 @@ def after_scenario(ctx, scenario):
205
206
  CONFIG.to_yaml_without_secrets()
206
207
  )
207
208
 
208
- scenario.end_at = datetime.datetime.now().isoformat()[:-3]
209
+ scenario.end_at = get_iso_timestamp_with_ms()
209
210
 
210
211
 
211
212
  def download_mht_data(ctx):
@@ -237,7 +238,7 @@ def cleanup_browsers(ctx):
237
238
 
238
239
 
239
240
  def before_step(ctx, step):
240
- step.start_at = datetime.datetime.now().isoformat()[:-3]
241
+ step.start_at = get_iso_timestamp_with_ms()
241
242
 
242
243
  sys.stdout.captured()
243
244
  sys.stderr.captured()
@@ -268,16 +269,16 @@ def after_step(ctx, step):
268
269
 
269
270
  # Capture debug output from the TeeStream for this step
270
271
  if hasattr(ctx, "scenario_debug_log_tee"):
271
- step.debug_output = ctx.scenario_debug_log_tee.getvalue()
272
+ step.debug_output = ctx.scenario_debug_log_tee.getvalue().splitlines()
272
273
  else:
273
- step.debug_output = ""
274
+ step.debug_output = []
274
275
 
275
- step.end_at = datetime.datetime.now().isoformat()[:-3]
276
+ step.end_at = get_iso_timestamp_with_ms()
276
277
 
277
278
  # calculate duration from ISO timestamps
278
- start_at = datetime.datetime.fromisoformat(step.start_at)
279
- end_at = datetime.datetime.fromisoformat(step.end_at)
280
- ctx.scenario.previous_step_duration = (end_at - start_at).total_seconds()
279
+ ctx.scenario.previous_step_duration = (
280
+ parse_iso_timestamp(step.end_at) - parse_iso_timestamp(step.start_at)
281
+ ).total_seconds()
281
282
 
282
283
  # when set this means we're running in parallel mode using --workers and
283
284
  # we want to see progress reported using simply dots
@@ -325,16 +326,13 @@ def after_step(ctx, step):
325
326
  hook(ctx)
326
327
 
327
328
  # Capture browser logs and info for this step
328
- step.browser_logs = ""
329
-
329
+ step.browser_logs = []
330
330
  browser_info = {"has_browser": False}
331
331
  if ctx.browser:
332
- browser_logs = []
333
332
  for log in ctx.browser.get_log():
334
333
  log_entry = json.dumps(log)
335
- browser_logs.append(log_entry)
334
+ step.browser_logs.append(log)
336
335
  ctx.browser_log_tee.write(f"{log_entry}\n")
337
- step.browser_logs = "\n".join(browser_logs)
338
336
 
339
337
  tab_info = ctx.browser.get_tab_info()
340
338
 
cucu/formatter/rundb.py CHANGED
@@ -1,7 +1,6 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  from __future__ import absolute_import
3
3
 
4
- import datetime
5
4
  import os
6
5
  import time
7
6
  from pathlib import Path
@@ -26,6 +25,7 @@ from cucu.db import (
26
25
  )
27
26
  from cucu.utils import (
28
27
  generate_short_id,
28
+ get_iso_timestamp_with_ms,
29
29
  )
30
30
 
31
31
 
@@ -125,7 +125,7 @@ class RundbFormatter(Formatter):
125
125
  for index, step in enumerate(self.this_steps):
126
126
  if getattr(step, "seq", -1) == -1:
127
127
  step.seq = index + 1 # 1-based sequence
128
- finish_step_record(step, None)
128
+ finish_step_record(step, 0)
129
129
 
130
130
  finish_scenario_record(self.this_scenario)
131
131
 
@@ -137,7 +137,7 @@ class RundbFormatter(Formatter):
137
137
 
138
138
  self.this_scenario = scenario
139
139
  self.this_steps = []
140
- self.next_start_at = datetime.datetime.now().isoformat()[:-3]
140
+ self.next_start_at = get_iso_timestamp_with_ms()
141
141
  scenario_run_id_seed = (
142
142
  f"{scenario.feature.feature_run_id}_{time.perf_counter()}"
143
143
  )
@@ -185,9 +185,7 @@ class RundbFormatter(Formatter):
185
185
  def result(self, step):
186
186
  """Called after processing a step result is known, applies to executed/skipped too."""
187
187
  step.start_at = self.next_start_at
188
- self.next_start_at = step.end_at = datetime.datetime.now().isoformat()[
189
- :-3
190
- ]
188
+ self.next_start_at = step.end_at = get_iso_timestamp_with_ms()
191
189
  previous_step_duration = getattr(
192
190
  self.this_scenario, "previous_step_duration", 0
193
191
  )