cucu 1.2.3__tar.gz → 1.2.5__tar.gz

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.

Potentially problematic release.


This version of cucu might be problematic. Click here for more details.

Files changed (95) hide show
  1. {cucu-1.2.3 → cucu-1.2.5}/PKG-INFO +12 -11
  2. {cucu-1.2.3 → cucu-1.2.5}/pyproject.toml +4 -3
  3. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/browser/selenium.py +7 -3
  4. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/cli/core.py +15 -5
  5. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/cli/run.py +25 -26
  6. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/config.py +39 -12
  7. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/environment.py +106 -62
  8. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/formatter/cucu.py +1 -1
  9. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/formatter/json.py +1 -1
  10. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/templates/scenario.html +2 -2
  11. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/browser_steps.py +4 -4
  12. cucu-1.2.5/src/cucu/steps/section_steps.py +44 -0
  13. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/utils.py +60 -2
  14. cucu-1.2.3/.gitignore +0 -191
  15. cucu-1.2.3/CHANGELOG.md +0 -424
  16. cucu-1.2.3/LICENSE +0 -32
  17. cucu-1.2.3/src/cucu/steps/section_steps.py +0 -25
  18. {cucu-1.2.3 → cucu-1.2.5}/README.md +0 -0
  19. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/__init__.py +0 -0
  20. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/ansi_parser.py +0 -0
  21. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/behave_tweaks.py +0 -0
  22. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/browser/__init__.py +0 -0
  23. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/browser/core.py +0 -0
  24. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/browser/frames.py +0 -0
  25. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/browser/selenium_tweaks.py +0 -0
  26. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/cli/__init__.py +0 -0
  27. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/cli/steps.py +0 -0
  28. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/cli/thread_dumper.py +0 -0
  29. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/edgedriver_autoinstaller/README.md +0 -0
  30. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/edgedriver_autoinstaller/__init__.py +0 -0
  31. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/edgedriver_autoinstaller/utils.py +0 -0
  32. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/external/jquery/jquery-3.5.1.min.js +0 -0
  33. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/formatter/__init__.py +0 -0
  34. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/formatter/junit.py +0 -0
  35. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/fuzzy/__init__.py +0 -0
  36. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/fuzzy/core.py +0 -0
  37. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/fuzzy/fuzzy.js +0 -0
  38. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/helpers.py +0 -0
  39. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/hooks.py +0 -0
  40. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/init_data/.gitignore +0 -0
  41. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/init_data/README.md +0 -0
  42. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/init_data/cucurc.yml +0 -0
  43. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/init_data/data/www/example.html +0 -0
  44. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/init_data/features/cucurc.yml +0 -0
  45. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/init_data/features/environment.py +0 -0
  46. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/init_data/features/example.feature +0 -0
  47. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/init_data/features/lint_rules/sid.yaml +0 -0
  48. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/init_data/features/steps/__init__.py +0 -0
  49. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/init_data/features/steps/my_steps.py +0 -0
  50. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/language_server/__init__.py +0 -0
  51. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/language_server/core.py +0 -0
  52. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/lint/__init__.py +0 -0
  53. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/lint/linter.py +0 -0
  54. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/lint/rules/format.yaml +0 -0
  55. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/logger.py +0 -0
  56. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/matcher/__init__.py +0 -0
  57. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/matcher/core.py +0 -0
  58. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/page_checks.py +0 -0
  59. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/__init__.py +0 -0
  60. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/external/bootstrap.min.css +0 -0
  61. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/external/bootstrap.min.js +0 -0
  62. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/external/dataTables.bootstrap.min.css +0 -0
  63. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/external/dataTables.bootstrap.min.js +0 -0
  64. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/external/jquery-3.5.1.min.js +0 -0
  65. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/external/jquery.dataTables.min.js +0 -0
  66. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/external/popper.min.js +0 -0
  67. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/favicon.png +0 -0
  68. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/html.py +0 -0
  69. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/templates/feature.html +0 -0
  70. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/templates/flat.html +0 -0
  71. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/templates/index.html +0 -0
  72. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/reporter/templates/layout.html +0 -0
  73. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/__init__.py +0 -0
  74. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/base_steps.py +0 -0
  75. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/button_steps.py +0 -0
  76. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/checkbox_steps.py +0 -0
  77. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/command_steps.py +0 -0
  78. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/draggable_steps.py +0 -0
  79. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/dropdown_steps.py +0 -0
  80. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/file_input_steps.py +0 -0
  81. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/filesystem_steps.py +0 -0
  82. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/flow_control_steps.py +0 -0
  83. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/image_steps.py +0 -0
  84. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/input_steps.py +0 -0
  85. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/link_steps.py +0 -0
  86. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/menuitem_steps.py +0 -0
  87. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/platform_steps.py +0 -0
  88. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/radio_steps.py +0 -0
  89. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/step_utils.py +0 -0
  90. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/tab_steps.py +0 -0
  91. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/table_steps.py +0 -0
  92. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/tables.js +0 -0
  93. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/text_steps.py +0 -0
  94. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/variable_steps.py +0 -0
  95. {cucu-1.2.3 → cucu-1.2.5}/src/cucu/steps/webserver_steps.py +0 -0
@@ -1,23 +1,18 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.3
2
2
  Name: cucu
3
- Version: 1.2.3
3
+ Version: 1.2.5
4
4
  Summary: Easy BDD web testing
5
- Project-URL: Homepage, https://github.com/dominodatalab/cucu/wiki
6
- Project-URL: Download, https://pypi.org/project/cucu/
7
- Project-URL: Source Code, https://github.com/dominodatalab/cucu
5
+ Keywords: cucumber,selenium,behave
6
+ Author: Domino Data Lab, Rodney Gomes, Cedric Young, Xin Dong, Kavya, Kevin Garton, Joy Liao
8
7
  Author-email: Domino Data Lab <open-source@dominodatalab.com>, Rodney Gomes <107359+rlgomes@users.noreply.github.com>, Cedric Young <4129217+ccedricyoung@users.noreply.github.com>, Xin Dong <104880864+ddl-xin@users.noreply.github.com>, Kavya <91882851+ddl-kavya@users.noreply.github.com>, Kevin Garton <71028750+ddl-kgarton@users.noreply.github.com>, Joy Liao <107583686+ddl-joy-liao@users.noreply.github.com>
9
- Maintainer-email: Domino Data Lab <open-source@dominodatalab.com>, Cedric Young <4129217+ccedricyoung@users.noreply.github.com>, Kevin Garton <71028750+ddl-kgarton@users.noreply.github.com>, Brian Colby <92048365+ddl-bcolby@users.noreply.github.com>
10
8
  License: The Clear BSD License
11
- License-File: LICENSE
12
- Keywords: behave,cucumber,selenium
13
9
  Classifier: Development Status :: 4 - Beta
14
10
  Classifier: Environment :: Console
15
11
  Classifier: Intended Audience :: Developers
16
12
  Classifier: License :: OSI Approved :: BSD License
17
- Classifier: Natural Language :: English
18
13
  Classifier: Operating System :: OS Independent
14
+ Classifier: Natural Language :: English
19
15
  Classifier: Topic :: Software Development :: Testing :: BDD
20
- Requires-Python: >=3.10
21
16
  Requires-Dist: beautifulsoup4~=4.13.3
22
17
  Requires-Dist: behave==1.2.6
23
18
  Requires-Dist: chromedriver-autoinstaller~=0.6.2
@@ -33,10 +28,16 @@ Requires-Dist: mpire~=2.10.2
33
28
  Requires-Dist: psutil>=6.0
34
29
  Requires-Dist: pygls~=1.3.1
35
30
  Requires-Dist: pyyaml~=6.0.1
36
- Requires-Dist: requests<3.0.0,>=2.31.0
31
+ Requires-Dist: requests>=2.31.0,<3.0.0
37
32
  Requires-Dist: selenium~=4.31
38
33
  Requires-Dist: tabulate~=0.9.0
39
34
  Requires-Dist: tenacity>=9.0
35
+ Maintainer: Domino Data Lab, Cedric Young, Kevin Garton, Brian Colby
36
+ Maintainer-email: Domino Data Lab <open-source@dominodatalab.com>, Cedric Young <4129217+ccedricyoung@users.noreply.github.com>, Kevin Garton <71028750+ddl-kgarton@users.noreply.github.com>, Brian Colby <92048365+ddl-bcolby@users.noreply.github.com>
37
+ Requires-Python: >=3.10
38
+ Project-URL: Download, https://pypi.org/project/cucu/
39
+ Project-URL: Homepage, https://github.com/dominodatalab/cucu/wiki
40
+ Project-URL: Source Code, https://github.com/dominodatalab/cucu
40
41
  Description-Content-Type: text/markdown
41
42
 
42
43
  [![pypi](https://img.shields.io/pypi/v/cucu.svg)](https://pypi.org/project/cucu/)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cucu"
3
- version = "1.2.3"
3
+ version = "1.2.5"
4
4
  description = "Easy BDD web testing"
5
5
  readme = "README.md"
6
6
  license = { text = "The Clear BSD License" }
@@ -65,8 +65,8 @@ Download = "https://pypi.org/project/cucu/"
65
65
  cucu = "cucu.cli:main"
66
66
 
67
67
  [build-system]
68
- requires = ["hatchling"]
69
- build-backend = "hatchling.build"
68
+ requires = ["uv_build>=0.8.0,<0.9.0"]
69
+ build-backend = "uv_build"
70
70
 
71
71
  [tool.hatch.build.targets.sdist]
72
72
  include = [
@@ -83,6 +83,7 @@ exclude = [
83
83
  dev-dependencies = [
84
84
  "pre-commit>=3.8.0",
85
85
  "pytest~=8.4.0",
86
+ "pytest-check>=2.5.3",
86
87
  "ruff>=0.6.4",
87
88
  ]
88
89
 
@@ -258,18 +258,22 @@ class Selenium(Browser):
258
258
  }
259
259
 
260
260
  def get_all_tabs_info(self):
261
+ window_handles = self.driver.window_handles
262
+ current_window = self.driver.current_window_handle
261
263
  tabs_info = []
262
- handles = self.driver.window_handles
263
- for idx, handle in enumerate(handles):
264
+
265
+ for handle in window_handles:
264
266
  self.driver.switch_to.window(handle)
265
267
  tabs_info.append(
266
268
  {
267
- "index": idx,
268
269
  "title": self.driver.title,
269
270
  "url": self.driver.current_url,
270
271
  }
271
272
  )
272
273
 
274
+ # Switch back to the original window
275
+ self.driver.switch_to.window(current_window)
276
+
273
277
  return tabs_info
274
278
 
275
279
  def back(self):
@@ -30,10 +30,11 @@ from cucu import (
30
30
  reporter,
31
31
  )
32
32
  from cucu.cli import thread_dumper
33
- from cucu.cli.run import behave, behave_init, write_run_details
33
+ from cucu.cli.run import behave, behave_init, create_run
34
34
  from cucu.cli.steps import print_human_readable_steps, print_json_steps
35
35
  from cucu.config import CONFIG
36
36
  from cucu.lint import linter
37
+ from cucu.utils import generate_short_id
37
38
 
38
39
  # will start coverage tracking once COVERAGE_PROCESS_START is set
39
40
  coverage.process_startup()
@@ -297,8 +298,12 @@ def run(
297
298
  if record_env_vars:
298
299
  os.environ["CUCU_RECORD_ENV_VARS"] = "true"
299
300
 
301
+ os.environ["CUCU_RUN_ID"] = CONFIG["CUCU_RUN_ID"] = generate_short_id()
302
+ CONFIG["WORKER_RUN_ID"] = "parent"
300
303
  if not dry_run:
301
- write_run_details(results, filepath)
304
+ create_run(results, filepath)
305
+
306
+ CONFIG.snapshot("core_run")
302
307
 
303
308
  try:
304
309
  if workers is None or workers == 1:
@@ -486,6 +491,8 @@ def run(
486
491
  "there are failures, see above for details"
487
492
  )
488
493
  finally:
494
+ CONFIG.restore(with_pop=True)
495
+
489
496
  if dumper is not None:
490
497
  dumper.stop()
491
498
 
@@ -499,7 +506,10 @@ def run(
499
506
 
500
507
 
501
508
  def _generate_report(
502
- filepath: str, output: str, only_failures: False, junit: str | None = None
509
+ results_dir: str,
510
+ output: str,
511
+ only_failures: False,
512
+ junit: str | None = None,
503
513
  ):
504
514
  """
505
515
  helper method to handle report generation so it can be used by the `cucu report`
@@ -508,7 +518,7 @@ def _generate_report(
508
518
 
509
519
 
510
520
  parameters:
511
- filepath(string): the results directory containing the previous test run
521
+ results_dir(string): the results directory containing the previous test run
512
522
  output(string): the directory where we'll generate the report
513
523
  only_failures(bool, optional): if only report failures. The default is False.
514
524
  junit(str|None, optional): the directory of the JUnit files. The default if None.
@@ -519,7 +529,7 @@ def _generate_report(
519
529
  os.makedirs(output)
520
530
 
521
531
  report_location = reporter.generate(
522
- filepath, output, only_failures=only_failures
532
+ results_dir, output, only_failures=only_failures
523
533
  )
524
534
  print(f"HTML test report at {report_location}")
525
535
 
@@ -4,6 +4,7 @@ import os
4
4
  import socket
5
5
  import sys
6
6
  from datetime import datetime
7
+ from pathlib import Path
7
8
 
8
9
  from cucu import (
9
10
  behave_tweaks,
@@ -16,13 +17,12 @@ from cucu.page_checks import init_page_checks
16
17
 
17
18
 
18
19
  def get_feature_name(file_path):
19
- with open(file_path, "r") as file:
20
- text = file.read()
21
- lines = text.split("\n")
22
- for line in lines:
23
- if "Feature:" in line:
24
- feature_name = line.replace("Feature:", "").strip()
25
- return feature_name
20
+ text = Path(file_path).read_text(encoding="utf8")
21
+ lines = text.split("\n")
22
+ for line in lines:
23
+ if "Feature:" in line:
24
+ feature_name = line.replace("Feature:", "").strip()
25
+ return feature_name
26
26
 
27
27
 
28
28
  def behave_init(filepath="features"):
@@ -104,7 +104,7 @@ def behave(
104
104
  run_json_filename = "run.json"
105
105
  if redirect_output:
106
106
  feature_name = get_feature_name(filepath)
107
- run_json_filename = f"{feature_name + '-run.json'}"
107
+ run_json_filename = f"{feature_name}-run.json"
108
108
 
109
109
  if dry_run:
110
110
  args += [
@@ -120,7 +120,7 @@ def behave(
120
120
  "--no-logcapture",
121
121
  # generate a JSON file containing the exact details of the whole run
122
122
  "--format=cucu.formatter.json:CucuJSONFormatter",
123
- f"--outfile={results}/{run_json_filename}",
123
+ f"--outfile={Path(results) / run_json_filename}",
124
124
  # console formatter
125
125
  "--format=cucu.formatter.cucu:CucuFormatter",
126
126
  f"--logging-level={os.environ['CUCU_LOGGING_LEVEL'].upper()}",
@@ -148,8 +148,8 @@ def behave(
148
148
  try:
149
149
  if redirect_output:
150
150
  feature_name = get_feature_name(filepath)
151
- log_filename = f"{feature_name + '.log'}"
152
- log_filepath = os.path.join(results, log_filename)
151
+ log_filename = f"{feature_name}.log"
152
+ log_filepath = Path(results) / log_filename
153
153
 
154
154
  CONFIG["__CUCU_PARENT_STDOUT"] = sys.stdout
155
155
 
@@ -161,7 +161,7 @@ def behave(
161
161
  # provide progress feedback on screen
162
162
  register_before_retry_hook(retry_progress)
163
163
 
164
- with open(log_filepath, "w", encoding="utf8") as output:
164
+ with log_filepath.open("w", encoding="utf8") as output:
165
165
  with contextlib.redirect_stderr(output):
166
166
  with contextlib.redirect_stdout(output):
167
167
  # intercept the stdout/stderr so we can do things such
@@ -180,28 +180,27 @@ def behave(
180
180
  return result
181
181
 
182
182
 
183
- def write_run_details(results, filepath):
184
- """
185
- writes a JSON file with run details to the results directory which can be
186
- used to figure out any runtime details that would otherwise be lost and
187
- difficult to figure out.
188
- """
189
- run_details_filepath = os.path.join(results, "run_details.json")
183
+ def create_run(results, filepath):
184
+ results_path = Path(results)
185
+ run_json_filepath = results_path / "run_details.json"
190
186
 
191
- if os.path.exists(run_details_filepath):
187
+ if run_json_filepath.exists():
192
188
  return
193
189
 
194
- if CONFIG["CUCU_RECORD_ENV_VARS"]:
195
- env_values = dict(os.environ)
196
- else:
197
- env_values = "To enable use the --record-env-vars flag"
190
+ env_values = (
191
+ dict(os.environ)
192
+ if CONFIG["CUCU_RECORD_ENV_VARS"]
193
+ else "To enable use the --record-env-vars flag"
194
+ )
198
195
 
199
196
  run_details = {
197
+ "cucu_run_id": CONFIG["CUCU_RUN_ID"],
200
198
  "filepath": filepath,
201
199
  "full_arguments": sys.argv,
202
200
  "env": env_values,
203
201
  "date": datetime.now().isoformat(),
204
202
  }
205
203
 
206
- with open(run_details_filepath, "w", encoding="utf8") as output:
207
- output.write(json.dumps(run_details, indent=2, sort_keys=True))
204
+ run_json_filepath.write_text(
205
+ json.dumps(run_details, indent=2, sort_keys=True), encoding="utf8"
206
+ )
@@ -1,4 +1,5 @@
1
1
  import json
2
+ import logging
2
3
  import os
3
4
  import re
4
5
  import socket
@@ -15,6 +16,7 @@ class Config(dict):
15
16
  self.resolving = False
16
17
  self.defined_variables = {}
17
18
  self.variable_lookups = {}
19
+ self.snapshots = []
18
20
 
19
21
  def define(self, name, description, default=None):
20
22
  """
@@ -255,19 +257,37 @@ class Config(dict):
255
257
 
256
258
  return string
257
259
 
258
- def snapshot(self):
259
- """
260
- make a shallow copy of the current config values which can later be
261
- restored using the `restore` method.
262
- """
263
- self.snapshot_data = self.copy()
260
+ def snapshot(self, name=None):
261
+ if name is None:
262
+ name = f"snapshot_{len(self.snapshots)}"
263
+
264
+ snapshot_data = {"name": name, "config": self.copy()}
265
+ self.snapshots.append(snapshot_data)
266
+
267
+ logging.debug(
268
+ f"CONFIG: snapshot taken '{name}' (stack depth: {len(self.snapshots)})"
269
+ )
270
+
271
+ def restore(self, with_pop=False):
272
+ if not self.snapshots:
273
+ return
274
+
275
+ if with_pop:
276
+ latest_snapshot = self.snapshots.pop()
277
+ action = "popped and restored"
278
+ else:
279
+ latest_snapshot = self.snapshots[-1]
280
+ action = "restored to"
264
281
 
265
- def restore(self):
266
- """
267
- restore a previous `snapshot`
268
- """
269
282
  self.clear()
270
- self.update(**self.snapshot_data)
283
+ self.update(**latest_snapshot["config"])
284
+
285
+ logging.debug(
286
+ f"CONFIG: {action} snapshot '{latest_snapshot['name']}' (stack depth: {len(self.snapshots)})"
287
+ )
288
+
289
+ def list_snapshots(self):
290
+ return [snapshot["name"] for snapshot in self.snapshots]
271
291
 
272
292
  def register_custom_variable_handling(self, regex, lookup):
273
293
  """
@@ -313,7 +333,14 @@ class Config(dict):
313
333
  v = f"'{v}'"
314
334
  config[k] = v
315
335
 
316
- return yaml.dump(config)
336
+ # Resolve non-atomic classes to their string representation
337
+ yaml.representer.SafeRepresenter.add_representer(
338
+ None,
339
+ lambda dumper, data: dumper.represent_scalar(
340
+ "tag:yaml.org,2002:str", str(data)
341
+ ),
342
+ )
343
+ return yaml.safe_dump(config)
317
344
 
318
345
 
319
346
  # global config object
@@ -1,22 +1,22 @@
1
- import datetime
2
- import hashlib
3
1
  import json
4
- import os
5
2
  import sys
6
- import time
7
3
  import traceback
4
+ from datetime import datetime
8
5
  from functools import partial
6
+ from pathlib import Path
7
+
8
+ import yaml
9
9
 
10
10
  from cucu import config, init_scenario_hook_variables, logger
11
11
  from cucu.config import CONFIG
12
12
  from cucu.page_checks import init_page_checks
13
- from cucu.utils import ellipsize_filename, take_screenshot
14
-
15
- CONFIG.define(
16
- "FEATURE_RESULTS_DIR",
17
- "the results directory for the currently executing feature",
18
- default=None,
13
+ from cucu.utils import (
14
+ TeeStream,
15
+ ellipsize_filename,
16
+ generate_short_id,
17
+ take_screenshot,
19
18
  )
19
+
20
20
  CONFIG.define(
21
21
  "SCENARIO_RESULTS_DIR",
22
22
  "the results directory for the currently executing scenario",
@@ -44,8 +44,12 @@ def check_browser_initialized(ctx):
44
44
 
45
45
  def before_all(ctx):
46
46
  CONFIG["__CUCU_CTX"] = ctx
47
- CONFIG.snapshot()
48
47
  ctx.check_browser_initialized = partial(check_browser_initialized, ctx)
48
+ ctx.worker_custom_data = {}
49
+
50
+ CONFIG["WORKER_RUN_ID"] = generate_short_id()
51
+ CONFIG.snapshot("before_all")
52
+
49
53
  for hook in CONFIG["__CUCU_BEFORE_ALL_HOOKS"]:
50
54
  hook(ctx)
51
55
 
@@ -55,14 +59,16 @@ def after_all(ctx):
55
59
  for hook in CONFIG["__CUCU_AFTER_ALL_HOOKS"]:
56
60
  hook(ctx)
57
61
 
62
+ CONFIG.restore(with_pop=True)
63
+
58
64
 
59
65
  def before_feature(ctx, feature):
66
+ feature.feature_run_id = generate_short_id()
67
+ feature.custom_data = {}
68
+
60
69
  if config.CONFIG["CUCU_RESULTS_DIR"] is not None:
61
- results_dir = config.CONFIG["CUCU_RESULTS_DIR"]
62
- ctx.feature_dir = os.path.join(
63
- results_dir, ellipsize_filename(feature.name)
64
- )
65
- CONFIG["FEATURE_RESULTS_DIR"] = ctx.feature_dir
70
+ results_dir = Path(config.CONFIG["CUCU_RESULTS_DIR"])
71
+ ctx.feature_dir = results_dir / ellipsize_filename(feature.name)
66
72
 
67
73
 
68
74
  def after_feature(ctx, feature):
@@ -81,50 +87,54 @@ def before_scenario(ctx, scenario):
81
87
 
82
88
  init_scenario_hook_variables()
83
89
 
90
+ scenario.custom_data = {}
84
91
  ctx.scenario = scenario
85
92
  ctx.step_index = 0
93
+ ctx.scenario_index = ctx.feature.scenarios.index(scenario) + 1
86
94
  ctx.browsers = []
87
95
  ctx.browser = None
96
+ ctx.section_step_stack = []
88
97
 
89
98
  # reset the step timer dictionary
90
99
  ctx.step_timers = {}
100
+ scenario.start_at = datetime.now().isoformat()[:-3]
91
101
 
92
102
  if config.CONFIG["CUCU_RESULTS_DIR"] is not None:
93
- ctx.scenario_dir = os.path.join(
94
- ctx.feature_dir, ellipsize_filename(scenario.name)
95
- )
103
+ ctx.scenario_dir = ctx.feature_dir / ellipsize_filename(scenario.name)
96
104
  CONFIG["SCENARIO_RESULTS_DIR"] = ctx.scenario_dir
97
- os.makedirs(ctx.scenario_dir, exist_ok=True)
105
+ ctx.scenario_dir.mkdir(parents=True, exist_ok=True)
98
106
 
99
- ctx.scenario_downloads_dir = os.path.join(
100
- ctx.scenario_dir, "downloads"
101
- )
107
+ ctx.scenario_downloads_dir = ctx.scenario_dir / "downloads"
102
108
  CONFIG["SCENARIO_DOWNLOADS_DIR"] = ctx.scenario_downloads_dir
103
- os.makedirs(ctx.scenario_downloads_dir, exist_ok=True)
109
+ ctx.scenario_downloads_dir.mkdir(parents=True, exist_ok=True)
104
110
 
105
- ctx.scenario_logs_dir = os.path.join(ctx.scenario_dir, "logs")
111
+ ctx.scenario_logs_dir = ctx.scenario_dir / "logs"
106
112
  CONFIG["SCENARIO_LOGS_DIR"] = ctx.scenario_logs_dir
107
- os.makedirs(ctx.scenario_logs_dir, exist_ok=True)
113
+ ctx.scenario_logs_dir.mkdir(parents=True, exist_ok=True)
108
114
 
109
- cucu_debug_filepath = os.path.join(
110
- ctx.scenario_logs_dir, "cucu.debug.console.log"
111
- )
115
+ cucu_debug_log_path = ctx.scenario_logs_dir / "cucu.debug.console.log"
112
116
  ctx.scenario_debug_log_file = open(
113
- cucu_debug_filepath, "w", encoding=sys.stdout.encoding
117
+ cucu_debug_log_path, "w", encoding=sys.stdout.encoding
114
118
  )
119
+ ctx.scenario_debug_log_tee = TeeStream(ctx.scenario_debug_log_file)
115
120
 
116
121
  # redirect stdout, stderr and setup a logger at debug level to fill
117
122
  # the scenario cucu.debug.log file which makes it possible to have
118
123
  # debug logging for every single scenario run without polluting the
119
124
  # console logs at runtime.
120
- sys.stdout.set_other_stream(ctx.scenario_debug_log_file)
121
- sys.stderr.set_other_stream(ctx.scenario_debug_log_file)
122
- logger.init_debug_logger(ctx.scenario_debug_log_file)
125
+ sys.stdout.set_other_stream(ctx.scenario_debug_log_tee)
126
+ sys.stderr.set_other_stream(ctx.scenario_debug_log_tee)
127
+ logger.init_debug_logger(ctx.scenario_debug_log_tee)
128
+
129
+ # capture browser logs using TeeStream since each call clears the log
130
+ ctx.browser_log_file = open(
131
+ ctx.scenario_logs_dir / "browser_console.log.txt",
132
+ "w",
133
+ encoding="utf-8",
134
+ )
135
+ ctx.browser_log_tee = TeeStream(ctx.browser_log_file)
123
136
 
124
- # internal cucu config variables
125
- CONFIG["SCENARIO_RUN_ID"] = hashlib.sha256(
126
- str(time.perf_counter()).encode("utf-8")
127
- ).hexdigest()[:7]
137
+ CONFIG["SCENARIO_RUN_ID"] = scenario.scenario_run_id = generate_short_id()
128
138
 
129
139
  # run before all scenario hooks
130
140
  for hook in CONFIG["__CUCU_BEFORE_SCENARIO_HOOKS"]:
@@ -179,14 +189,18 @@ def after_scenario(ctx, scenario):
179
189
  if len(ctx.browsers) != 0:
180
190
  logger.debug("quitting browser between sessions")
181
191
 
182
- run_after_scenario_hook(ctx, scenario, download_browser_logs)
192
+ run_after_scenario_hook(ctx, scenario, download_browser_log)
183
193
 
184
- cucu_config_filepath = os.path.join(
185
- ctx.scenario_logs_dir, "cucu.config.yaml.txt"
186
- )
187
- with open(cucu_config_filepath, "w") as config_file:
194
+ cucu_config_path = ctx.scenario_logs_dir / "cucu.config.yaml.txt"
195
+ with open(cucu_config_path, "w") as config_file:
188
196
  config_file.write(CONFIG.to_yaml_without_secrets())
189
197
 
198
+ scenario.cucu_config_json = json.dumps(
199
+ yaml.safe_load(CONFIG.to_yaml_without_secrets())
200
+ )
201
+
202
+ scenario.end_at = datetime.now().isoformat()[:-3]
203
+
190
204
 
191
205
  def download_mht_data(ctx):
192
206
  if not ctx.browsers:
@@ -198,45 +212,42 @@ def download_mht_data(ctx):
198
212
  mht_filename = (
199
213
  f"browser{index if len(ctx.browsers) > 1 else ''}_snapshot.mht"
200
214
  )
201
- mht_pathname = os.path.join(
202
- CONFIG["SCENARIO_LOGS_DIR"],
203
- mht_filename,
204
- )
215
+ mht_pathname = CONFIG["SCENARIO_LOGS_DIR"] / mht_filename
205
216
  logger.debug(f"Saving MHT webpage snapshot: {mht_filename}")
206
217
  browser.download_mht(mht_pathname)
207
218
 
208
219
 
209
- def download_browser_logs(ctx):
220
+ def download_browser_log(ctx):
210
221
  # close the browser unless someone has set the keep browser alive
211
222
  # environment variable which allows tests to reuse the same browser
212
223
  # session
213
224
 
214
225
  for browser in ctx.browsers:
215
- # save the browser logs to the current scenarios results directory
216
- browser_log_filepath = os.path.join(
217
- ctx.scenario_logs_dir, "browser_console.log.txt"
218
- )
219
-
220
- os.makedirs(os.path.dirname(browser_log_filepath), exist_ok=True)
221
- with open(browser_log_filepath, "w") as output:
222
- for log in browser.get_log():
223
- output.write(f"{json.dumps(log)}\n")
224
-
225
226
  browser.quit()
226
227
 
228
+ ctx.browser_log_file.close()
229
+
227
230
  ctx.browsers = []
228
231
 
229
232
 
230
233
  def before_step(ctx, step):
231
- # trims the last 3 digits of the microseconds
232
- step.start_timestamp = datetime.datetime.now().isoformat()[:-3]
234
+ step.step_run_id = generate_short_id()
235
+ step.start_at = datetime.now().isoformat()[:-3]
233
236
 
234
237
  sys.stdout.captured()
235
238
  sys.stderr.captured()
236
239
 
240
+ # Reset the debug log buffer for this step
241
+ if hasattr(ctx, "scenario_debug_log_tee"):
242
+ ctx.scenario_debug_log_tee.clear()
243
+
237
244
  ctx.current_step = step
238
245
  ctx.current_step.has_substeps = False
239
- ctx.start_time = time.monotonic()
246
+ ctx.section_level = None
247
+ step.seq = ctx.step_index + 1
248
+ step.parent_seq = (
249
+ ctx.section_step_stack[-1].seq if ctx.section_step_stack else 0
250
+ )
240
251
 
241
252
  CONFIG["__STEP_SCREENSHOT_COUNT"] = 0
242
253
 
@@ -249,8 +260,18 @@ def after_step(ctx, step):
249
260
  step.stdout = sys.stdout.captured()
250
261
  step.stderr = sys.stderr.captured()
251
262
 
252
- ctx.end_time = time.monotonic()
253
- ctx.previous_step_duration = ctx.end_time - ctx.start_time
263
+ # Capture debug output from the TeeStream for this step
264
+ if hasattr(ctx, "scenario_debug_log_tee"):
265
+ step.debug_output = ctx.scenario_debug_log_tee.getvalue()
266
+ else:
267
+ step.debug_output = ""
268
+
269
+ step.end_at = datetime.now().isoformat()[:-3]
270
+
271
+ # calculate duration from ISO timestamps
272
+ start_at = datetime.fromisoformat(step.start_at)
273
+ end_at = datetime.fromisoformat(step.end_at)
274
+ ctx.previous_step_duration = (end_at - start_at).total_seconds()
254
275
 
255
276
  # when set this means we're running in parallel mode using --workers and
256
277
  # we want to see progress reported using simply dots
@@ -296,3 +317,26 @@ def after_step(ctx, step):
296
317
  # run after all step hooks
297
318
  for hook in CONFIG["__CUCU_AFTER_STEP_HOOKS"]:
298
319
  hook(ctx)
320
+
321
+ # Capture browser logs and info for this step
322
+ step.browser_logs = ""
323
+
324
+ browser_info = {"has_browser": False}
325
+ if ctx.browser:
326
+ browser_logs = []
327
+ for log in ctx.browser.get_log():
328
+ log_entry = json.dumps(log)
329
+ browser_logs.append(log_entry)
330
+ ctx.browser_log_tee.write(f"{log_entry}\n")
331
+ step.browser_logs = "\n".join(browser_logs)
332
+
333
+ tab_info = ctx.browser.get_tab_info()
334
+ all_tabs = ctx.browser.get_all_tabs_info()
335
+
336
+ browser_info = {
337
+ "current_tab_index": tab_info["index"],
338
+ "all_tabs": all_tabs,
339
+ "browser_type": ctx.browser.driver.name,
340
+ }
341
+
342
+ step.browser_info = json.dumps(browser_info)
@@ -185,7 +185,7 @@ class CucuFormatter(Formatter):
185
185
  max_line_length = self.calculate_max_line_length()
186
186
  status_text = ""
187
187
  if self.show_timings:
188
- start = step.start_timestamp
188
+ start = step.start_at
189
189
  duration = f"{step.duration:.3f}"
190
190
  status_text += f" # started at {start} took {duration}s"
191
191
 
@@ -157,7 +157,7 @@ class CucuJSONFormatter(Formatter):
157
157
 
158
158
  timestamp = None
159
159
  if step.status.name in ["passed", "failed"]:
160
- timestamp = step.start_timestamp
160
+ timestamp = step.start_at
161
161
 
162
162
  step_variables = CONFIG.expand(step.name)
163
163
 
@@ -75,7 +75,7 @@
75
75
  {% endif %}
76
76
  </td>
77
77
  <td style="text-align: right; margin-top: auto;" class="col-2">
78
- <pre style="display: inline; color: gray;">Offset and Duration (s)</pre>
78
+ <pre style="display: inline; color: gray;">Start Time and Duration (s)</pre>
79
79
  </td>
80
80
  </tr>
81
81
  {% for step in steps %}
@@ -88,7 +88,7 @@
88
88
  {% if step['result'] is defined %}
89
89
  {% set step_status = step['result']['status'] %}
90
90
  {% if step['result']['status'] == 'failed' or step['result']['status'] == 'passed' %}
91
- {% set step_timing = "{} for {:.3f}s".format(step["result"]["time_offset"].strftime("%H:%M:%S"), step["result"]["duration"]) %}
91
+ {% set step_timing = "{} for {:.3f}s".format(step["result"]["timestamp"].strftime("%H:%M:%S"), step["result"]["duration"]) %}
92
92
  {% set step_start = step["result"]["timestamp"] %}
93
93
  {% endif %}
94
94
  {% else %}