pybuilder-integration 109__tar.gz → 112__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.
Files changed (18) hide show
  1. {pybuilder_integration-109 → pybuilder_integration-112}/PKG-INFO +1 -1
  2. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration/properties.py +2 -0
  3. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration/tasks.py +456 -69
  4. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration/tool_utility.py +6 -6
  5. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration.egg-info/PKG-INFO +1 -1
  6. {pybuilder_integration-109 → pybuilder_integration-112}/setup.py +1 -1
  7. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration/__init__.py +0 -0
  8. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration/artifact_manager.py +0 -0
  9. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration/cloudwatchlogs_utility.py +0 -0
  10. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration/directory_utility.py +0 -0
  11. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration/exec_utility.py +0 -0
  12. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration.egg-info/SOURCES.txt +0 -0
  13. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration.egg-info/dependency_links.txt +0 -0
  14. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration.egg-info/namespace_packages.txt +0 -0
  15. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration.egg-info/requires.txt +0 -0
  16. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration.egg-info/top_level.txt +0 -0
  17. {pybuilder_integration-109 → pybuilder_integration-112}/pybuilder_integration.egg-info/zip-safe +0 -0
  18. {pybuilder_integration-109 → pybuilder_integration-112}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybuilder-integration
3
- Version: 109
3
+ Version: 112
4
4
  Summary: A pybuilder plugin that runs integration tests (Tavern & Cypress) against a target.
5
5
  Home-page: https://github.com/rspitler/pybuilder-integration
6
6
  Author:
@@ -22,5 +22,7 @@ TESTING_SCOPE = "testing_scope"
22
22
  PARALLEL_VERIFY_ENVIRONMENT = "parallel_verify_environment"
23
23
  PARALLEL_TAVERN_SUITES = "parallel_tavern_suites"
24
24
  PARALLEL_TAVERN_SUITES_WORKERS = "parallel_tavern_suites_workers"
25
+ PARALLEL_CYPRESS_SUITES = "parallel_cypress_suites"
26
+ PARALLEL_CYPRESS_SUITES_WORKERS = "parallel_cypress_suites_workers"
25
27
  SUBPROCESS_PROPERTIES_FILE_ENV = "PYBUILDER_INTEGRATION_PROPERTIES_FILE"
26
28
  CYPRESS_CACHE_FOLDER = "cypress_cache_folder"
@@ -1,5 +1,6 @@
1
1
  import atexit
2
2
  import errno
3
+ import hashlib
3
4
  import json
4
5
  import os
5
6
  import re
@@ -51,13 +52,22 @@ _VERIFY_ENVIRONMENT_SUBPROCESS_PROPERTIES = [
51
52
  TESTING_SCOPE,
52
53
  PARALLEL_TAVERN_SUITES,
53
54
  PARALLEL_TAVERN_SUITES_WORKERS,
55
+ PARALLEL_CYPRESS_SUITES,
56
+ PARALLEL_CYPRESS_SUITES_WORKERS,
54
57
  "abort_upload",
55
58
  "record_cypress",
56
59
  "verbose",
57
60
  ]
58
61
 
59
62
  _TAVERN_INSTALL_LOCK = threading.Lock()
63
+ _CYPRESS_TARGET_COPY_LOCK = threading.Lock()
60
64
  _DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS = 8
65
+ _DEFAULT_PARALLEL_CYPRESS_SUITE_WORKERS = 4
66
+ _CYPRESS_SPEC_SUFFIXES = (
67
+ ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".coffee", ".feature",
68
+ )
69
+ _CYPRESS_SPEC_SKIP_DIRS = {"node_modules", "dist", "target", "videos", "screenshots", ".git"}
70
+ _CYPRESS_PARALLEL_FAILURES_FILE = "cypress_parallel_failures.json"
61
71
 
62
72
  def integration_artifact_push(project: Project, logger: Logger, reactor: Reactor):
63
73
  logger.info("Starting upload of integration artifacts")
@@ -157,8 +167,8 @@ def _run_verify_environment_passes_in_subprocesses(project: Project, logger: Log
157
167
  process.terminate()
158
168
  process.wait()
159
169
  _close_subprocess_log(process)
160
- _merge_parallel_verify_environment_outputs(project, passes)
161
- _write_cypress_summary(project, logger)
170
+ cypress_failures = _merge_parallel_verify_environment_outputs(project, passes) or []
171
+ _write_cypress_summary(project, logger, extra_failures=cypress_failures)
162
172
  if os.path.exists(properties_file):
163
173
  os.remove(properties_file)
164
174
 
@@ -376,6 +386,27 @@ def _first_child_cypress_log(stage_target):
376
386
  return None
377
387
 
378
388
 
389
+ def _child_parallel_cypress_failures(stage_target, pass_name):
390
+ for source_root in _child_output_source_roots(stage_target):
391
+ candidate = os.path.join(
392
+ source_root, "logs", "integration", _CYPRESS_PARALLEL_FAILURES_FILE
393
+ )
394
+ if not os.path.isfile(candidate):
395
+ continue
396
+ try:
397
+ with open(candidate, encoding="utf-8") as failures_file:
398
+ failures = json.load(failures_file)
399
+ if not isinstance(failures, list) or not all(isinstance(failure, str) for failure in failures):
400
+ raise ValueError("expected a list of strings")
401
+ return [
402
+ f"{pass_name}: {_redact_secret_log_text(failure).strip()}"
403
+ for failure in failures
404
+ ]
405
+ except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
406
+ return [f"{pass_name}: could not read Cypress parallel failure details: {error}"]
407
+ return []
408
+
409
+
379
410
  def _copy_maybe_redacted_file(src, dst, *, follow_symlinks=True):
380
411
  if os.path.splitext(src)[1].lower() in _REDACTED_COPY_EXTENSIONS:
381
412
  _copy_redacted_text_file(src, dst)
@@ -579,8 +610,12 @@ def _merge_parallel_verify_environment_outputs(project: Project, passes):
579
610
  }
580
611
  parent_cypress_log = os.path.join(parent_logs_directory, "cypress_run.log")
581
612
  has_parent_cypress_log = False
613
+ cypress_failures = []
582
614
  for pass_config in passes:
583
615
  stage_target = os.path.join(parent_target, "verify_environment", pass_config["name"])
616
+ cypress_failures.extend(
617
+ _child_parallel_cypress_failures(stage_target, pass_config["name"])
618
+ )
584
619
  for source_root in _child_output_source_roots(stage_target):
585
620
  for destination_kind, source_dirname in _CHILD_OUTPUT_SOURCES:
586
621
  source = os.path.join(source_root, source_dirname, "integration")
@@ -597,6 +632,7 @@ def _merge_parallel_verify_environment_outputs(project: Project, passes):
597
632
  if cypress_log:
598
633
  _copy_cypress_log(cypress_log, parent_cypress_log, has_parent_cypress_log)
599
634
  has_parent_cypress_log = True
635
+ return cypress_failures
600
636
 
601
637
 
602
638
  def _copy_cypress_log(source, destination, append):
@@ -867,9 +903,11 @@ def _run_cypress_tests_in_dist_dir(dist_directory, latest, logger, project, reac
867
903
  logger.debug(f"Run cypress tests in directory files: {os.listdir(cypress_test_path)} ")
868
904
  logger.info(f"Found cypress tests - starting run latest: {latest}")
869
905
  if latest:
870
- for test_dir in os.listdir(cypress_test_path):
871
- directory_to_test = f"{cypress_test_path}/{test_dir}"
872
- if os.path.isdir(directory_to_test) and _should_run_latest(test_dir, project):
906
+ suite_dirs = list(_iter_latest_cypress_suite_dirs(cypress_test_path, project))
907
+ if _get_bool_property(project, PARALLEL_CYPRESS_SUITES, False) and suite_dirs:
908
+ _run_cypress_suites_parallel(suite_dirs, logger, project, reactor)
909
+ else:
910
+ for directory_to_test in suite_dirs:
873
911
  logger.info(f"Running {directory_to_test}")
874
912
  _run_cypress_tests_in_directory(work_dir=directory_to_test,
875
913
  logger=logger,
@@ -919,17 +957,42 @@ def _iter_latest_tavern_suite_dirs(tavern_test_path, project):
919
957
  yield tavern_test_directory
920
958
 
921
959
 
922
- def _parallel_tavern_suite_workers(project, suite_count):
923
- configured = project.get_property(PARALLEL_TAVERN_SUITES_WORKERS, None)
960
+ def _iter_latest_cypress_suite_dirs(cypress_test_path, project):
961
+ for test_dir in sorted(os.listdir(cypress_test_path)):
962
+ directory_to_test = os.path.join(cypress_test_path, test_dir)
963
+ if os.path.isdir(directory_to_test) and _should_run_latest(test_dir, project):
964
+ yield directory_to_test
965
+
966
+
967
+ def _parallel_suite_workers(project, suite_count, property_name, default_workers):
968
+ configured = project.get_property(property_name, None)
924
969
  if configured is None or configured == "":
925
- return max(1, min(_DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS, suite_count))
970
+ return max(1, min(default_workers, suite_count))
926
971
  try:
927
972
  workers = int(configured)
928
973
  except (TypeError, ValueError):
929
- workers = _DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS
974
+ workers = default_workers
930
975
  return max(1, min(workers, suite_count))
931
976
 
932
977
 
978
+ def _parallel_tavern_suite_workers(project, suite_count):
979
+ return _parallel_suite_workers(
980
+ project,
981
+ suite_count,
982
+ PARALLEL_TAVERN_SUITES_WORKERS,
983
+ _DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS,
984
+ )
985
+
986
+
987
+ def _parallel_cypress_suite_workers(project, suite_count):
988
+ return _parallel_suite_workers(
989
+ project,
990
+ suite_count,
991
+ PARALLEL_CYPRESS_SUITES_WORKERS,
992
+ _DEFAULT_PARALLEL_CYPRESS_SUITE_WORKERS,
993
+ )
994
+
995
+
933
996
  def _run_tavern_suites_parallel(suite_dirs, logger, project, reactor):
934
997
  prepare_reports_directory(project)
935
998
  runnable = []
@@ -957,19 +1020,33 @@ def _run_tavern_suites_parallel(suite_dirs, logger, project, reactor):
957
1020
  )
958
1021
  return
959
1022
 
960
- suite_sites = {}
961
- for tavern_test_directory in runnable:
962
- suite_sites[tavern_test_directory] = _install_tavern_suite_site(
963
- tavern_test_directory, logger, project, reactor
964
- )
965
-
966
1023
  workers = _parallel_tavern_suite_workers(project, len(runnable))
967
1024
  logger.info(f"Running {len(runnable)} tavern suites in parallel (workers={workers})")
1025
+ fingerprint_by_dir = {suite_dir: _tavern_requirements_fingerprint(suite_dir) for suite_dir in runnable}
1026
+ plugin_fingerprint = _plugin_venv_requirements_fingerprint(fingerprint_by_dir)
1027
+ installer = _ParallelTavernRequirementInstaller(plugin_fingerprint)
1028
+ if plugin_fingerprint is not None:
1029
+ sample_dir = next(
1030
+ suite_dir for suite_dir, fingerprint in fingerprint_by_dir.items()
1031
+ if fingerprint == plugin_fingerprint
1032
+ )
1033
+ logger.info(
1034
+ f"Installing tavern requirements from {os.path.basename(sample_dir)} "
1035
+ f"into the plugin venv before parallel pytest"
1036
+ )
1037
+ installer.install(sample_dir, plugin_fingerprint, logger, project, reactor)
968
1038
  failures = []
969
1039
 
970
1040
  def run_suite(tavern_test_directory):
971
1041
  role = os.path.basename(tavern_test_directory)
972
1042
  logger.info(f"Running {tavern_test_directory}")
1043
+ site = installer.install(
1044
+ tavern_test_directory,
1045
+ fingerprint_by_dir[tavern_test_directory],
1046
+ logger,
1047
+ project,
1048
+ reactor,
1049
+ )
973
1050
  _run_tavern_tests_in_dir(
974
1051
  test_dir=tavern_test_directory,
975
1052
  logger=logger,
@@ -978,7 +1055,7 @@ def _run_tavern_suites_parallel(suite_dirs, logger, project, reactor):
978
1055
  role=role,
979
1056
  isolated=True,
980
1057
  skip_install=True,
981
- extra_pythonpath=suite_sites[tavern_test_directory],
1058
+ extra_pythonpath=site,
982
1059
  )
983
1060
  return role
984
1061
 
@@ -998,6 +1075,257 @@ def _run_tavern_suites_parallel(suite_dirs, logger, project, reactor):
998
1075
  )
999
1076
 
1000
1077
 
1078
+ class _ParallelTavernRequirementInstaller:
1079
+ def __init__(self, plugin_venv_fingerprint):
1080
+ self._plugin_venv_fingerprint = plugin_venv_fingerprint
1081
+ self._lock = threading.Lock()
1082
+ self._events = {}
1083
+ self._results = {}
1084
+ self._errors = {}
1085
+
1086
+ def install(self, test_dir, fingerprint, logger, project, reactor):
1087
+ if fingerprint is None:
1088
+ return None
1089
+ with self._lock:
1090
+ if fingerprint in self._results:
1091
+ return self._results[fingerprint]
1092
+ if fingerprint in self._errors:
1093
+ raise self._errors[fingerprint]
1094
+ event = self._events.get(fingerprint)
1095
+ if event is None:
1096
+ event = threading.Event()
1097
+ self._events[fingerprint] = event
1098
+ leader = True
1099
+ else:
1100
+ leader = False
1101
+ if not leader:
1102
+ event.wait()
1103
+ with self._lock:
1104
+ if fingerprint in self._errors:
1105
+ raise self._errors[fingerprint]
1106
+ if fingerprint in self._results:
1107
+ return self._results[fingerprint]
1108
+ raise BuildFailedException(
1109
+ f"Tavern requirements install for {os.path.basename(test_dir)} finished without a result"
1110
+ )
1111
+ try:
1112
+ if fingerprint == self._plugin_venv_fingerprint:
1113
+ _install_tavern_requirements(test_dir, logger, project, reactor)
1114
+ site = None
1115
+ else:
1116
+ site = _install_tavern_suite_site(test_dir, logger, project, reactor, fingerprint)
1117
+ with self._lock:
1118
+ self._results[fingerprint] = site
1119
+ return site
1120
+ except Exception as error:
1121
+ with self._lock:
1122
+ self._errors[fingerprint] = error
1123
+ raise
1124
+ finally:
1125
+ event.set()
1126
+
1127
+
1128
+ def _plugin_venv_requirements_fingerprint(fingerprint_by_dir):
1129
+ counts = {}
1130
+ order = []
1131
+ for fingerprint in fingerprint_by_dir.values():
1132
+ if fingerprint is None:
1133
+ continue
1134
+ if fingerprint not in counts:
1135
+ order.append(fingerprint)
1136
+ counts[fingerprint] = 0
1137
+ counts[fingerprint] += 1
1138
+ if not order:
1139
+ return None
1140
+ return max(order, key=lambda fingerprint: (counts[fingerprint], -order.index(fingerprint)))
1141
+
1142
+
1143
+ def _run_cypress_suites_parallel(suite_dirs, logger, project, reactor):
1144
+ if not suite_dirs:
1145
+ return
1146
+ _write_parallel_cypress_failures(project, [])
1147
+ failures = []
1148
+ prepared = []
1149
+ for suite_dir in suite_dirs:
1150
+ role = os.path.basename(suite_dir)
1151
+ try:
1152
+ logger.info(f"Installing cypress dependencies for {role} before parallel run")
1153
+ _install_cypress_suite_dependencies(
1154
+ suite_dir, logger, project, reactor, suite_name=role
1155
+ )
1156
+ prepared.append(suite_dir)
1157
+ except Exception as error:
1158
+ failures.append(f"{role}: {error}")
1159
+
1160
+ work_items = _cypress_parallel_work_items(prepared, project)
1161
+ if not work_items:
1162
+ _merge_parallel_cypress_suite_logs(project, logger, [], extra_failures=failures)
1163
+ if failures:
1164
+ raise BuildFailedException(
1165
+ "Parallel cypress suites failed: " + "; ".join(sorted(failures))
1166
+ )
1167
+ return
1168
+
1169
+ if len(work_items) == 1 and not failures:
1170
+ suite_dir, name, spec_files = work_items[0]
1171
+ logger.info(f"Running {suite_dir}")
1172
+ try:
1173
+ _run_cypress_tests_in_directory(
1174
+ work_dir=suite_dir,
1175
+ logger=logger,
1176
+ project=project,
1177
+ reactor=reactor,
1178
+ write_summary=False,
1179
+ skip_install=True,
1180
+ )
1181
+ except Exception as error:
1182
+ failures.append(f"{name}: {error}")
1183
+ _write_parallel_cypress_failures(project, failures)
1184
+ _write_cypress_summary(project, logger, extra_failures=failures)
1185
+ raise
1186
+ _write_cypress_summary(project, logger)
1187
+ return
1188
+
1189
+ workers = _parallel_cypress_suite_workers(project, len(work_items))
1190
+ logger.info(
1191
+ f"Running {len(work_items)} cypress shards in parallel (workers={workers})"
1192
+ )
1193
+
1194
+ def run_item(work_item):
1195
+ suite_dir, name, spec_files = work_item
1196
+ logger.info(f"Running {suite_dir} [{name}]")
1197
+ try:
1198
+ _run_cypress_tests_in_directory(
1199
+ work_dir=suite_dir,
1200
+ logger=logger,
1201
+ project=project,
1202
+ reactor=reactor,
1203
+ suite_name=name,
1204
+ write_summary=False,
1205
+ spec_files=spec_files,
1206
+ skip_install=True,
1207
+ record=False,
1208
+ )
1209
+ return "ran"
1210
+ except BuildFailedException:
1211
+ if spec_files and _cypress_shard_had_no_matching_specs(project, name):
1212
+ logger.info(
1213
+ f"Cypress shard {name} had no specs after applying configured filters"
1214
+ )
1215
+ return "empty"
1216
+ raise
1217
+
1218
+ ran_items = 0
1219
+ empty_items = 0
1220
+ try:
1221
+ with ThreadPoolExecutor(max_workers=workers) as executor:
1222
+ futures = {executor.submit(run_item, item): item for item in work_items}
1223
+ for future in as_completed(futures):
1224
+ suite_dir, name, _spec_files = futures[future]
1225
+ try:
1226
+ result = future.result()
1227
+ if result == "empty":
1228
+ empty_items += 1
1229
+ else:
1230
+ ran_items += 1
1231
+ except Exception as error:
1232
+ failures.append(f"{name}: {error}")
1233
+ finally:
1234
+ if not failures and empty_items and not ran_items:
1235
+ failures.append(
1236
+ "No Cypress specs matched the configured specPattern and test filters"
1237
+ )
1238
+ shard_names = [name for _suite_dir, name, _spec_files in work_items]
1239
+ _merge_parallel_cypress_suite_logs(
1240
+ project, logger, shard_names, extra_failures=failures
1241
+ )
1242
+
1243
+ if failures:
1244
+ raise BuildFailedException(
1245
+ "Parallel cypress suites failed: " + "; ".join(sorted(failures))
1246
+ )
1247
+
1248
+
1249
+ def _cypress_parallel_work_items(suite_dirs, project):
1250
+ items = []
1251
+ for suite_dir in suite_dirs:
1252
+ role = os.path.basename(suite_dir)
1253
+ specs = _iter_cypress_spec_files(suite_dir)
1254
+ if len(specs) > 1:
1255
+ workers = _parallel_cypress_suite_workers(project, len(specs))
1256
+ chunks = _partition_cypress_specs(specs, workers)
1257
+ for index, chunk in enumerate(chunks):
1258
+ name = role if len(chunks) == 1 else f"{role}-{index}"
1259
+ items.append((suite_dir, name, chunk))
1260
+ else:
1261
+ items.append((suite_dir, role, None))
1262
+ return items
1263
+
1264
+
1265
+ def _iter_cypress_spec_files(work_dir):
1266
+ specs = []
1267
+ for root, dirs, files in os.walk(work_dir):
1268
+ dirs[:] = [directory for directory in dirs if directory not in _CYPRESS_SPEC_SKIP_DIRS]
1269
+ for filename in files:
1270
+ lower = filename.lower()
1271
+ if any(lower.endswith(suffix) for suffix in _CYPRESS_SPEC_SUFFIXES):
1272
+ relative = os.path.relpath(os.path.join(root, filename), work_dir)
1273
+ specs.append(relative.replace(os.sep, "/"))
1274
+ return sorted(specs)
1275
+
1276
+
1277
+ def _cypress_shard_had_no_matching_specs(project, shard_name):
1278
+ log_path = os.path.join(
1279
+ prepare_logs_directory(project), f"cypress_run_{shard_name}.log"
1280
+ )
1281
+ for candidate in (log_path, f"{log_path}.err"):
1282
+ try:
1283
+ with open(candidate, encoding="utf-8", errors="replace") as log_file:
1284
+ output = log_file.read()
1285
+ except OSError:
1286
+ continue
1287
+ if re.search(r"no spec files were found|Specs:\s*0 found", output, re.IGNORECASE):
1288
+ return True
1289
+ return False
1290
+
1291
+
1292
+ def _partition_cypress_specs(specs, worker_count):
1293
+ workers = max(1, min(worker_count, len(specs)))
1294
+ chunks = [[] for _ in range(workers)]
1295
+ for index, spec in enumerate(specs):
1296
+ chunks[index % workers].append(spec)
1297
+ return [chunk for chunk in chunks if chunk]
1298
+
1299
+
1300
+ def _merge_parallel_cypress_suite_logs(project, logger, shard_names, extra_failures=None):
1301
+ parent_cypress_log = os.path.join(prepare_logs_directory(project), "cypress_run.log")
1302
+ appended = False
1303
+ for name in sorted(shard_names):
1304
+ source = os.path.join(prepare_logs_directory(project), f"cypress_run_{name}.log")
1305
+ if os.path.isfile(source) and os.path.getsize(source) > 0:
1306
+ _copy_cypress_log(source, parent_cypress_log, appended)
1307
+ appended = True
1308
+ _write_parallel_cypress_failures(project, extra_failures or [])
1309
+ _write_cypress_summary(project, logger, extra_failures=extra_failures)
1310
+
1311
+
1312
+ def _write_parallel_cypress_failures(project, failures):
1313
+ path = os.path.join(prepare_logs_directory(project), _CYPRESS_PARALLEL_FAILURES_FILE)
1314
+ if not failures:
1315
+ if os.path.isfile(path):
1316
+ os.remove(path)
1317
+ return
1318
+ redacted_failures = [
1319
+ _redact_secret_log_text(str(failure)).strip() for failure in failures
1320
+ ]
1321
+ with os.fdopen(
1322
+ os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600),
1323
+ "w",
1324
+ encoding="utf-8",
1325
+ ) as failures_file:
1326
+ json.dump(redacted_failures, failures_file)
1327
+
1328
+
1001
1329
  def verify_cypress(project: Project, logger: Logger, reactor: Reactor):
1002
1330
  # Get directories with test and cypress executable
1003
1331
  work_dir = project.expand_path(f"${CYPRESS_TEST_DIR}")
@@ -1005,7 +1333,23 @@ def verify_cypress(project: Project, logger: Logger, reactor: Reactor):
1005
1333
  package_artifacts(project, work_dir, "cypress", project.get_property(ROLE))
1006
1334
 
1007
1335
 
1008
- def _run_cypress_tests_in_directory(work_dir, logger, project, reactor: Reactor):
1336
+ def _install_cypress_suite_dependencies(work_dir, logger, project, reactor, suite_name=None):
1337
+ npm_log_file = f"package_json_npm_install_{suite_name}.log" if suite_name else "package_json_npm_install.log"
1338
+ cypress_npm_log_file = f"cypress_npm_install_{suite_name}.log" if suite_name else "cypress_npm_install.log"
1339
+ package_json = os.path.join(work_dir, "package.json")
1340
+ if os.path.exists(package_json):
1341
+ logger.info("Found package.json installing dependencies")
1342
+ tool_utility.install_npm_dependencies(
1343
+ work_dir, project=project, logger=logger, reactor=reactor, log_file_name=npm_log_file
1344
+ )
1345
+ else:
1346
+ install_cypress(
1347
+ logger=logger, project=project, reactor=reactor, work_dir=work_dir, log_file_name=cypress_npm_log_file
1348
+ )
1349
+
1350
+
1351
+ def _run_cypress_tests_in_directory(work_dir, logger, project, reactor: Reactor, suite_name=None, write_summary=True,
1352
+ spec_files=None, skip_install=False, record=None):
1009
1353
  total_time = Timer.start()
1010
1354
  target_url = project.get_mandatory_property(INTEGRATION_TARGET_URL)
1011
1355
  environment = project.get_mandatory_property(ENVIRONMENT)
@@ -1014,64 +1358,77 @@ def _run_cypress_tests_in_directory(work_dir, logger, project, reactor: Reactor)
1014
1358
  return False
1015
1359
  logger.info(f"Found {len(os.listdir(work_dir))} files in cypress test directory")
1016
1360
  logger.debug(f"Files: {os.listdir(work_dir)} ")
1017
- # Validate NPM install and Install cypress
1018
- package_json = os.path.join(work_dir, "package.json")
1019
- if os.path.exists(package_json):
1020
- logger.info("Found package.json installing dependencies")
1021
- tool_utility.install_npm_dependencies(work_dir, project=project, logger=logger, reactor=reactor)
1022
- else:
1023
- install_cypress(logger=logger, project=project, reactor=reactor, work_dir=work_dir)
1361
+ if not skip_install:
1362
+ _install_cypress_suite_dependencies(work_dir, logger, project, reactor, suite_name=suite_name)
1024
1363
  total_time.stop()
1025
1364
  logger.info(f"Configured Cypress Environment: {total_time.get_millis()}")
1026
1365
  total_time = Timer.start()
1027
1366
  executable = os.path.join(work_dir, "node_modules/cypress/bin/cypress")
1028
- results_file, run_name = get_test_report_file(project=project, test_dir=work_dir, tool="cypress")
1367
+ report_name = suite_name or os.path.basename(work_dir)
1368
+ results_file = os.path.join(prepare_reports_directory(project), f"cypress-{report_name}.out.xml")
1029
1369
  # Run the actual tests against the baseURL provided by ${integration_target}
1030
1370
  test_report_folder = directory_utility.prepare_reports_directory(project)
1371
+ videos_folder = f"{test_report_folder}/videos"
1372
+ screenshots_folder = f"{test_report_folder}/screenshots"
1373
+ if suite_name:
1374
+ videos_folder = f"{videos_folder}/{suite_name}"
1375
+ screenshots_folder = f"{screenshots_folder}/{suite_name}"
1031
1376
  args = ["run", "--config",
1032
1377
  f"baseUrl={target_url},"
1033
- f"videosFolder={test_report_folder}/videos,"
1034
- f"screenshotsFolder={test_report_folder}/screenshots",
1378
+ f"videosFolder={videos_folder},"
1379
+ f"screenshotsFolder={screenshots_folder}",
1035
1380
  "--reporter-options",
1036
1381
  f"mochaFile={results_file}"]
1037
- if _get_bool_property(project, "record_cypress", True):
1382
+ should_record = _get_bool_property(project, "record_cypress", True) if record is None else bool(record)
1383
+ if should_record:
1038
1384
  args.append('--record')
1385
+ if spec_files:
1386
+ args.extend(["--spec", ",".join(spec_files)])
1039
1387
  _add_config_file(logger, project, args, environment, work_dir)
1040
1388
  environment_variables = project.get_property(ENVIRONMENT_VARIABLES, {})
1041
1389
  logger.info(f"Running cypress on host: {target_url}")
1390
+ log_file_name = f"cypress_run_{suite_name}.log" if suite_name else "cypress_run.log"
1042
1391
  try:
1043
1392
  exec_utility.exec_command(command_name=executable, args=args,
1044
- failure_message="Failed to execute cypress tests", log_file_name='cypress_run.log',
1393
+ failure_message="Failed to execute cypress tests", log_file_name=log_file_name,
1045
1394
  project=project, reactor=reactor, logger=logger, working_dir=work_dir, report=False,
1046
1395
  env_vars=environment_variables)
1047
1396
  # workaround but cypress output are relative to location of cypress.json, so we need to collapse
1048
1397
  if os.path.exists(f"{work_dir}/target"):
1049
- shutil.copytree(f"{work_dir}/target", project.expand_path("$dir_target"), dirs_exist_ok=True)
1398
+ with _CYPRESS_TARGET_COPY_LOCK:
1399
+ shutil.copytree(f"{work_dir}/target", project.expand_path("$dir_target"), dirs_exist_ok=True)
1050
1400
  finally:
1051
- _write_cypress_summary(project, logger)
1401
+ if write_summary:
1402
+ _write_cypress_summary(project, logger)
1052
1403
  total_time.stop()
1053
1404
  logger.info(f"Ran Cypress Tests: {total_time.get_millis()}")
1054
1405
  return True
1055
1406
 
1056
1407
 
1057
- def _write_cypress_summary(project, logger):
1408
+ def _write_cypress_summary(project, logger, extra_failures=None):
1058
1409
  """Parse cypress_run.log and write a portable Markdown summary artifact.
1059
1410
 
1060
- No-op when Cypress did not run (missing or empty cypress_run.log), so
1061
- verify_environment jobs without Cypress do not emit a false warning.
1062
- Parallel child subprocesses also skip it; the parent writes one summary
1063
- after merging child Cypress logs.
1411
+ No-op when Cypress did not run (missing or empty cypress_run.log) and
1412
+ there are no extra_failures, so verify_environment jobs without Cypress
1413
+ do not emit a false warning. Parallel child subprocesses also skip it;
1414
+ the parent writes one summary after merging child Cypress logs.
1415
+ extra_failures keeps the summary from reporting PASS when a parallel
1416
+ shard failed before producing JUnit.
1064
1417
  """
1418
+ extra_failures = extra_failures or []
1065
1419
  log_path = os.path.join(prepare_logs_directory(project), "cypress_run.log")
1066
1420
  if os.environ.get(SUBPROCESS_PROPERTIES_FILE_ENV):
1067
1421
  logger.debug("Skipping Cypress summary in verify_environment subprocess")
1068
1422
  return None
1069
- if not os.path.isfile(log_path) or os.path.getsize(log_path) == 0:
1423
+ has_log = os.path.isfile(log_path) and os.path.getsize(log_path) > 0
1424
+ if not has_log and not extra_failures:
1070
1425
  logger.debug("Skipping Cypress summary: cypress_run.log not found")
1071
1426
  return None
1072
1427
  summary_path = os.path.join(prepare_reports_directory(project), "cypress-summary.md")
1073
1428
  try:
1074
- output = _redact_secret_log_text(_build_cypress_summary(log_path))
1429
+ output = _redact_secret_log_text(
1430
+ _build_cypress_summary(log_path if has_log else None, extra_failures)
1431
+ )
1075
1432
  with os.fdopen(
1076
1433
  os.open(summary_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600),
1077
1434
  "w",
@@ -1092,33 +1449,40 @@ def _write_cypress_summary(project, logger):
1092
1449
  logger.warn(f"[cypress_summary] failed to generate summary: {e}")
1093
1450
 
1094
1451
 
1095
- def _build_cypress_summary(log_path):
1096
- if not os.path.exists(log_path):
1097
- return "## Cypress Test Results\n\n> warning `cypress_run.log` not found\n"
1098
-
1099
- raw = open(log_path, errors="replace").read()
1100
- clean = re.sub(r"\x1b\[[0-9;]*m", "", raw)
1101
-
1452
+ def _build_cypress_summary(log_path, extra_failures=None):
1453
+ extra_failures = extra_failures or []
1102
1454
  total = failed = skipped = 0
1103
1455
  failures = []
1104
- for xml_block in re.findall(r"<testsuites[\s\S]*?</testsuites>", clean):
1105
- try:
1106
- root = ET.fromstring(xml_block)
1107
- total += int(root.get("tests", 0))
1108
- failed += int(root.get("failures", 0))
1109
- skipped += int(root.get("skipped", 0) or 0)
1110
- for tc in root.iter("testcase"):
1111
- failure = tc.find("failure")
1112
- if failure is not None:
1113
- suite = tc.get("classname", "").split(".")[-1]
1114
- name = tc.get("name", "")
1115
- msg = (failure.get("message") or failure.text or "").split("\n")[0][:120]
1116
- failures.append((f"{suite} > {name}", msg))
1117
- except (ET.ParseError, DefusedXmlException):
1118
- pass
1456
+ if log_path and os.path.exists(log_path):
1457
+ raw = open(log_path, errors="replace").read()
1458
+ clean = re.sub(r"\x1b\[[0-9;]*m", "", raw)
1459
+
1460
+ for xml_block in re.findall(r"<testsuites[\s\S]*?</testsuites>", clean):
1461
+ try:
1462
+ root = ET.fromstring(xml_block)
1463
+ total += int(root.get("tests", 0))
1464
+ failed += int(root.get("failures", 0))
1465
+ skipped += int(root.get("skipped", 0) or 0)
1466
+ for tc in root.iter("testcase"):
1467
+ failure = tc.find("failure")
1468
+ if failure is not None:
1469
+ suite = tc.get("classname", "").split(".")[-1]
1470
+ name = tc.get("name", "")
1471
+ msg = (failure.get("message") or failure.text or "").split("\n")[0][:120]
1472
+ failures.append((f"{suite} > {name}", msg))
1473
+ except (ET.ParseError, DefusedXmlException):
1474
+ pass
1475
+ elif not extra_failures:
1476
+ return "## Cypress Test Results\n\n> warning `cypress_run.log` not found\n"
1119
1477
 
1120
1478
  passed = total - failed - skipped
1121
- if total == 0:
1479
+ if extra_failures:
1480
+ icon = "FAIL"
1481
+ if failed:
1482
+ label = f"{failed} test(s) failed; {len(extra_failures)} suite(s) failed"
1483
+ else:
1484
+ label = f"{len(extra_failures)} parallel suite(s) failed"
1485
+ elif total == 0:
1122
1486
  icon = "WARN"
1123
1487
  label = "No tests ran - check if Cypress found any spec files"
1124
1488
  elif failed == 0:
@@ -1145,6 +1509,13 @@ def _build_cypress_summary(log_path):
1145
1509
  if len(failures) > 20:
1146
1510
  lines.append(f"\n_...and {len(failures) - 20} more_")
1147
1511
 
1512
+ if extra_failures:
1513
+ lines.append("\n### Parallel Suite Failures\n")
1514
+ for extra in extra_failures[:20]:
1515
+ lines.append(f"- **{extra}**")
1516
+ if len(extra_failures) > 20:
1517
+ lines.append(f"\n_...and {len(extra_failures) - 20} more_")
1518
+
1148
1519
  return "\n".join(lines) + "\n"
1149
1520
 
1150
1521
 
@@ -1227,10 +1598,18 @@ def _plugin_venv_python(reactor):
1227
1598
  raise BuildFailedException("plugin venv python is missing or not executable")
1228
1599
 
1229
1600
 
1230
- def _tavern_suite_site_directory(project, role):
1601
+ def _tavern_requirements_fingerprint(test_dir):
1602
+ requirements_file = os.path.join(test_dir, "requirements.txt")
1603
+ if not os.path.isfile(requirements_file):
1604
+ return None
1605
+ with open(requirements_file, "rb") as requirements:
1606
+ return hashlib.sha256(requirements.read()).hexdigest()
1607
+
1608
+
1609
+ def _tavern_suite_site_directory(project, fingerprint):
1231
1610
  root = project.expand_path("$dir_target/integration/tavern-sites")
1232
1611
  directory_utility._ensure_directory_exists(root)
1233
- site = os.path.join(root, role)
1612
+ site = os.path.join(root, fingerprint)
1234
1613
  if os.path.islink(site) or os.path.isfile(site):
1235
1614
  os.unlink(site)
1236
1615
  elif os.path.isdir(site):
@@ -1239,37 +1618,45 @@ def _tavern_suite_site_directory(project, role):
1239
1618
  return site
1240
1619
 
1241
1620
 
1621
+ def _tavern_pip_cache_directory(project, fingerprint):
1622
+ cache = os.path.join(project.expand_path("$dir_target/integration/tavern-pip-cache"), fingerprint)
1623
+ return directory_utility._ensure_directory_exists(cache)
1624
+
1625
+
1242
1626
  def _isolated_suite_subprocess_env(extra_pythonpath=None):
1243
1627
  env = os.environ.copy()
1244
1628
  env.pop("GITHUB_STEP_SUMMARY", None)
1245
1629
  env.pop("PYTHONHOME", None)
1246
1630
  env.pop("VIRTUAL_ENV", None)
1247
1631
  env.pop("PYTHONPATH", None)
1632
+ env["PYTHONDONTWRITEBYTECODE"] = "1"
1248
1633
  if extra_pythonpath:
1249
1634
  env["PYTHONPATH"] = extra_pythonpath
1250
1635
  return env
1251
1636
 
1252
1637
 
1253
- def _install_tavern_suite_site(test_dir, logger, project, reactor):
1638
+ def _install_tavern_suite_site(test_dir, logger, project, reactor, fingerprint):
1254
1639
  requirements_file = os.path.join(test_dir, "requirements.txt")
1255
- if not os.path.exists(requirements_file):
1640
+ if not os.path.isfile(requirements_file):
1256
1641
  return None
1257
1642
  role = os.path.basename(test_dir)
1258
- site = _tavern_suite_site_directory(project, role)
1643
+ site = _tavern_suite_site_directory(project, fingerprint)
1644
+ env = _isolated_suite_subprocess_env()
1645
+ env["PIP_CACHE_DIR"] = _tavern_pip_cache_directory(project, fingerprint)
1259
1646
  command = [_plugin_venv_python(reactor), "-m", "pip", "install", "-r", requirements_file, "-t", site]
1260
- logger.info(f"Installing tavern requirements for {role} into {site}")
1647
+ logger.info(f"Installing tavern requirements from {role} into isolated site {site}")
1261
1648
  process = subprocess.Popen(
1262
1649
  command,
1263
1650
  stdout=subprocess.PIPE,
1264
1651
  stderr=subprocess.STDOUT,
1265
1652
  cwd=test_dir,
1266
- env=_isolated_suite_subprocess_env(),
1653
+ env=env,
1267
1654
  shell=False,
1268
1655
  )
1269
1656
  returncode = _stream_subprocess_output(process, logger, role)
1270
1657
  if returncode != 0:
1271
1658
  raise BuildFailedException(
1272
- f"Failed to install tavern requirements for {role} into isolated site {site}"
1659
+ f"Failed to install tavern requirements from {role} into isolated site {site}"
1273
1660
  )
1274
1661
  return site
1275
1662
 
@@ -5,12 +5,12 @@ from pybuilder_integration.exec_utility import exec_command
5
5
  from pybuilder_integration.properties import CYPRESS_CACHE_FOLDER
6
6
 
7
7
 
8
- def install_cypress(logger: Logger, project: Project, reactor: Reactor, work_dir):
8
+ def install_cypress(logger: Logger, project: Project, reactor: Reactor, work_dir, log_file_name=None):
9
9
  _verify_npm(reactor)
10
10
  logger.info(f"Ensuring cypress is installed")
11
11
  exec_command('npm', ['install', "cypress"], f'Failed to install cypress - required for integration tests',
12
- f'{"cypress"}_npm_install.log', project, reactor, logger, report=False, working_dir=work_dir,
13
- env_vars=_get_npm_env(project))
12
+ log_file_name or 'cypress_npm_install.log', project, reactor, logger, report=False,
13
+ working_dir=work_dir, env_vars=_get_npm_env(project))
14
14
 
15
15
 
16
16
  def _verify_npm(reactor):
@@ -18,12 +18,12 @@ def _verify_npm(reactor):
18
18
  command_and_arguments=["npm", "--version"], prerequisite="npm", caller="integration_tests")
19
19
 
20
20
 
21
- def install_npm_dependencies(work_dir, project, logger, reactor):
21
+ def install_npm_dependencies(work_dir, project, logger, reactor, log_file_name=None):
22
22
  _verify_npm(reactor)
23
23
  install_command = 'ci' if _has_package_lock(work_dir) else 'install'
24
24
  exec_command('npm', [install_command], f'Failed to install package.json - required for integration tests',
25
- f'package_json_npm_install.log', project, reactor, logger, report=False, working_dir=work_dir,
26
- env_vars=_get_npm_env(project))
25
+ log_file_name or 'package_json_npm_install.log', project, reactor, logger, report=False,
26
+ working_dir=work_dir, env_vars=_get_npm_env(project))
27
27
 
28
28
 
29
29
  def _has_package_lock(work_dir):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybuilder-integration
3
- Version: 109
3
+ Version: 112
4
4
  Summary: A pybuilder plugin that runs integration tests (Tavern & Cypress) against a target.
5
5
  Home-page: https://github.com/rspitler/pybuilder-integration
6
6
  Author:
@@ -21,7 +21,7 @@ class install(_install):
21
21
  if __name__ == '__main__':
22
22
  setup(
23
23
  name = 'pybuilder-integration',
24
- version = '109',
24
+ version = '112',
25
25
  description = 'A pybuilder plugin that runs integration tests (Tavern & Cypress) against a target.',
26
26
  long_description = 'A pybuilder plugin that runs integration tests against a target. This is intended to be a broader scope than unit-tests encompassing dependant functionality.',
27
27
  long_description_content_type = None,