pybuilder-integration 111__tar.gz → 113__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-111 → pybuilder_integration-113}/PKG-INFO +1 -1
  2. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration/properties.py +2 -0
  3. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration/tasks.py +355 -55
  4. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration/tool_utility.py +6 -6
  5. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration.egg-info/PKG-INFO +1 -1
  6. {pybuilder_integration-111 → pybuilder_integration-113}/setup.py +1 -1
  7. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration/__init__.py +0 -0
  8. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration/artifact_manager.py +0 -0
  9. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration/cloudwatchlogs_utility.py +0 -0
  10. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration/directory_utility.py +0 -0
  11. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration/exec_utility.py +0 -0
  12. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration.egg-info/SOURCES.txt +0 -0
  13. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration.egg-info/dependency_links.txt +0 -0
  14. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration.egg-info/namespace_packages.txt +0 -0
  15. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration.egg-info/requires.txt +0 -0
  16. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration.egg-info/top_level.txt +0 -0
  17. {pybuilder_integration-111 → pybuilder_integration-113}/pybuilder_integration.egg-info/zip-safe +0 -0
  18. {pybuilder_integration-111 → pybuilder_integration-113}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybuilder-integration
3
- Version: 111
3
+ Version: 113
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"
@@ -52,13 +52,23 @@ _VERIFY_ENVIRONMENT_SUBPROCESS_PROPERTIES = [
52
52
  TESTING_SCOPE,
53
53
  PARALLEL_TAVERN_SUITES,
54
54
  PARALLEL_TAVERN_SUITES_WORKERS,
55
+ PARALLEL_CYPRESS_SUITES,
56
+ PARALLEL_CYPRESS_SUITES_WORKERS,
55
57
  "abort_upload",
56
58
  "record_cypress",
57
59
  "verbose",
58
60
  ]
59
61
 
60
62
  _TAVERN_INSTALL_LOCK = threading.Lock()
63
+ _CYPRESS_TARGET_COPY_LOCK = threading.Lock()
61
64
  _DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS = 8
65
+ _DEFAULT_PARALLEL_CYPRESS_SUITE_WORKERS = 4
66
+ _CYPRESS_XVFB_DISPLAY_START = 100
67
+ _CYPRESS_SPEC_SUFFIXES = (
68
+ ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".coffee", ".feature",
69
+ )
70
+ _CYPRESS_SPEC_SKIP_DIRS = {"node_modules", "dist", "target", "videos", "screenshots", ".git"}
71
+ _CYPRESS_PARALLEL_FAILURES_FILE = "cypress_parallel_failures.json"
62
72
 
63
73
  def integration_artifact_push(project: Project, logger: Logger, reactor: Reactor):
64
74
  logger.info("Starting upload of integration artifacts")
@@ -158,8 +168,8 @@ def _run_verify_environment_passes_in_subprocesses(project: Project, logger: Log
158
168
  process.terminate()
159
169
  process.wait()
160
170
  _close_subprocess_log(process)
161
- _merge_parallel_verify_environment_outputs(project, passes)
162
- _write_cypress_summary(project, logger)
171
+ cypress_failures = _merge_parallel_verify_environment_outputs(project, passes) or []
172
+ _write_cypress_summary(project, logger, extra_failures=cypress_failures)
163
173
  if os.path.exists(properties_file):
164
174
  os.remove(properties_file)
165
175
 
@@ -377,6 +387,27 @@ def _first_child_cypress_log(stage_target):
377
387
  return None
378
388
 
379
389
 
390
+ def _child_parallel_cypress_failures(stage_target, pass_name):
391
+ for source_root in _child_output_source_roots(stage_target):
392
+ candidate = os.path.join(
393
+ source_root, "logs", "integration", _CYPRESS_PARALLEL_FAILURES_FILE
394
+ )
395
+ if not os.path.isfile(candidate):
396
+ continue
397
+ try:
398
+ with open(candidate, encoding="utf-8") as failures_file:
399
+ failures = json.load(failures_file)
400
+ if not isinstance(failures, list) or not all(isinstance(failure, str) for failure in failures):
401
+ raise ValueError("expected a list of strings")
402
+ return [
403
+ f"{pass_name}: {_redact_secret_log_text(failure).strip()}"
404
+ for failure in failures
405
+ ]
406
+ except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
407
+ return [f"{pass_name}: could not read Cypress parallel failure details: {error}"]
408
+ return []
409
+
410
+
380
411
  def _copy_maybe_redacted_file(src, dst, *, follow_symlinks=True):
381
412
  if os.path.splitext(src)[1].lower() in _REDACTED_COPY_EXTENSIONS:
382
413
  _copy_redacted_text_file(src, dst)
@@ -580,8 +611,12 @@ def _merge_parallel_verify_environment_outputs(project: Project, passes):
580
611
  }
581
612
  parent_cypress_log = os.path.join(parent_logs_directory, "cypress_run.log")
582
613
  has_parent_cypress_log = False
614
+ cypress_failures = []
583
615
  for pass_config in passes:
584
616
  stage_target = os.path.join(parent_target, "verify_environment", pass_config["name"])
617
+ cypress_failures.extend(
618
+ _child_parallel_cypress_failures(stage_target, pass_config["name"])
619
+ )
585
620
  for source_root in _child_output_source_roots(stage_target):
586
621
  for destination_kind, source_dirname in _CHILD_OUTPUT_SOURCES:
587
622
  source = os.path.join(source_root, source_dirname, "integration")
@@ -598,6 +633,7 @@ def _merge_parallel_verify_environment_outputs(project: Project, passes):
598
633
  if cypress_log:
599
634
  _copy_cypress_log(cypress_log, parent_cypress_log, has_parent_cypress_log)
600
635
  has_parent_cypress_log = True
636
+ return cypress_failures
601
637
 
602
638
 
603
639
  def _copy_cypress_log(source, destination, append):
@@ -868,9 +904,11 @@ def _run_cypress_tests_in_dist_dir(dist_directory, latest, logger, project, reac
868
904
  logger.debug(f"Run cypress tests in directory files: {os.listdir(cypress_test_path)} ")
869
905
  logger.info(f"Found cypress tests - starting run latest: {latest}")
870
906
  if latest:
871
- for test_dir in os.listdir(cypress_test_path):
872
- directory_to_test = f"{cypress_test_path}/{test_dir}"
873
- if os.path.isdir(directory_to_test) and _should_run_latest(test_dir, project):
907
+ suite_dirs = list(_iter_latest_cypress_suite_dirs(cypress_test_path, project))
908
+ if _get_bool_property(project, PARALLEL_CYPRESS_SUITES, False) and suite_dirs:
909
+ _run_cypress_suites_parallel(suite_dirs, logger, project, reactor)
910
+ else:
911
+ for directory_to_test in suite_dirs:
874
912
  logger.info(f"Running {directory_to_test}")
875
913
  _run_cypress_tests_in_directory(work_dir=directory_to_test,
876
914
  logger=logger,
@@ -920,17 +958,42 @@ def _iter_latest_tavern_suite_dirs(tavern_test_path, project):
920
958
  yield tavern_test_directory
921
959
 
922
960
 
923
- def _parallel_tavern_suite_workers(project, suite_count):
924
- configured = project.get_property(PARALLEL_TAVERN_SUITES_WORKERS, None)
961
+ def _iter_latest_cypress_suite_dirs(cypress_test_path, project):
962
+ for test_dir in sorted(os.listdir(cypress_test_path)):
963
+ directory_to_test = os.path.join(cypress_test_path, test_dir)
964
+ if os.path.isdir(directory_to_test) and _should_run_latest(test_dir, project):
965
+ yield directory_to_test
966
+
967
+
968
+ def _parallel_suite_workers(project, suite_count, property_name, default_workers):
969
+ configured = project.get_property(property_name, None)
925
970
  if configured is None or configured == "":
926
- return max(1, min(_DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS, suite_count))
971
+ return max(1, min(default_workers, suite_count))
927
972
  try:
928
973
  workers = int(configured)
929
974
  except (TypeError, ValueError):
930
- workers = _DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS
975
+ workers = default_workers
931
976
  return max(1, min(workers, suite_count))
932
977
 
933
978
 
979
+ def _parallel_tavern_suite_workers(project, suite_count):
980
+ return _parallel_suite_workers(
981
+ project,
982
+ suite_count,
983
+ PARALLEL_TAVERN_SUITES_WORKERS,
984
+ _DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS,
985
+ )
986
+
987
+
988
+ def _parallel_cypress_suite_workers(project, suite_count):
989
+ return _parallel_suite_workers(
990
+ project,
991
+ suite_count,
992
+ PARALLEL_CYPRESS_SUITES_WORKERS,
993
+ _DEFAULT_PARALLEL_CYPRESS_SUITE_WORKERS,
994
+ )
995
+
996
+
934
997
  def _run_tavern_suites_parallel(suite_dirs, logger, project, reactor):
935
998
  prepare_reports_directory(project)
936
999
  runnable = []
@@ -1078,6 +1141,198 @@ def _plugin_venv_requirements_fingerprint(fingerprint_by_dir):
1078
1141
  return max(order, key=lambda fingerprint: (counts[fingerprint], -order.index(fingerprint)))
1079
1142
 
1080
1143
 
1144
+ def _run_cypress_suites_parallel(suite_dirs, logger, project, reactor):
1145
+ if not suite_dirs:
1146
+ return
1147
+ _write_parallel_cypress_failures(project, [])
1148
+ failures = []
1149
+ prepared = []
1150
+ for suite_dir in suite_dirs:
1151
+ role = os.path.basename(suite_dir)
1152
+ try:
1153
+ logger.info(f"Installing cypress dependencies for {role} before parallel run")
1154
+ _install_cypress_suite_dependencies(
1155
+ suite_dir, logger, project, reactor, suite_name=role
1156
+ )
1157
+ prepared.append(suite_dir)
1158
+ except Exception as error:
1159
+ failures.append(f"{role}: {error}")
1160
+
1161
+ work_items = _cypress_parallel_work_items(prepared, project)
1162
+ if not work_items:
1163
+ _merge_parallel_cypress_suite_logs(project, logger, [], extra_failures=failures)
1164
+ if failures:
1165
+ raise BuildFailedException(
1166
+ "Parallel cypress suites failed: " + "; ".join(sorted(failures))
1167
+ )
1168
+ return
1169
+
1170
+ if len(work_items) == 1 and not failures:
1171
+ suite_dir, name, spec_files = work_items[0]
1172
+ logger.info(f"Running {suite_dir}")
1173
+ try:
1174
+ _run_cypress_tests_in_directory(
1175
+ work_dir=suite_dir,
1176
+ logger=logger,
1177
+ project=project,
1178
+ reactor=reactor,
1179
+ write_summary=False,
1180
+ skip_install=True,
1181
+ )
1182
+ except Exception as error:
1183
+ failures.append(f"{name}: {error}")
1184
+ _write_parallel_cypress_failures(project, failures)
1185
+ _write_cypress_summary(project, logger, extra_failures=failures)
1186
+ raise
1187
+ _write_cypress_summary(project, logger)
1188
+ return
1189
+
1190
+ workers = _parallel_cypress_suite_workers(project, len(work_items))
1191
+ logger.info(
1192
+ f"Running {len(work_items)} cypress shards in parallel (workers={workers})"
1193
+ )
1194
+ xvfb_display_numbers = {
1195
+ name: _CYPRESS_XVFB_DISPLAY_START + index
1196
+ for index, (_suite_dir, name, _spec_files) in enumerate(work_items)
1197
+ }
1198
+
1199
+ def run_item(work_item):
1200
+ suite_dir, name, spec_files = work_item
1201
+ logger.info(f"Running {suite_dir} [{name}]")
1202
+ try:
1203
+ _run_cypress_tests_in_directory(
1204
+ work_dir=suite_dir,
1205
+ logger=logger,
1206
+ project=project,
1207
+ reactor=reactor,
1208
+ suite_name=name,
1209
+ write_summary=False,
1210
+ spec_files=spec_files,
1211
+ skip_install=True,
1212
+ record=False,
1213
+ xvfb_display_num=xvfb_display_numbers[name],
1214
+ )
1215
+ return "ran"
1216
+ except BuildFailedException:
1217
+ if spec_files and _cypress_shard_had_no_matching_specs(project, name):
1218
+ logger.info(
1219
+ f"Cypress shard {name} had no specs after applying configured filters"
1220
+ )
1221
+ return "empty"
1222
+ raise
1223
+
1224
+ ran_items = 0
1225
+ empty_items = 0
1226
+ try:
1227
+ with ThreadPoolExecutor(max_workers=workers) as executor:
1228
+ futures = {executor.submit(run_item, item): item for item in work_items}
1229
+ for future in as_completed(futures):
1230
+ suite_dir, name, _spec_files = futures[future]
1231
+ try:
1232
+ result = future.result()
1233
+ if result == "empty":
1234
+ empty_items += 1
1235
+ else:
1236
+ ran_items += 1
1237
+ except Exception as error:
1238
+ failures.append(f"{name}: {error}")
1239
+ finally:
1240
+ if not failures and empty_items and not ran_items:
1241
+ failures.append(
1242
+ "No Cypress specs matched the configured specPattern and test filters"
1243
+ )
1244
+ shard_names = [name for _suite_dir, name, _spec_files in work_items]
1245
+ _merge_parallel_cypress_suite_logs(
1246
+ project, logger, shard_names, extra_failures=failures
1247
+ )
1248
+
1249
+ if failures:
1250
+ raise BuildFailedException(
1251
+ "Parallel cypress suites failed: " + "; ".join(sorted(failures))
1252
+ )
1253
+
1254
+
1255
+ def _cypress_parallel_work_items(suite_dirs, project):
1256
+ items = []
1257
+ for suite_dir in suite_dirs:
1258
+ role = os.path.basename(suite_dir)
1259
+ specs = _iter_cypress_spec_files(suite_dir)
1260
+ if len(specs) > 1:
1261
+ workers = _parallel_cypress_suite_workers(project, len(specs))
1262
+ chunks = _partition_cypress_specs(specs, workers)
1263
+ for index, chunk in enumerate(chunks):
1264
+ name = role if len(chunks) == 1 else f"{role}-{index}"
1265
+ items.append((suite_dir, name, chunk))
1266
+ else:
1267
+ items.append((suite_dir, role, None))
1268
+ return items
1269
+
1270
+
1271
+ def _iter_cypress_spec_files(work_dir):
1272
+ specs = []
1273
+ for root, dirs, files in os.walk(work_dir):
1274
+ dirs[:] = [directory for directory in dirs if directory not in _CYPRESS_SPEC_SKIP_DIRS]
1275
+ for filename in files:
1276
+ lower = filename.lower()
1277
+ if any(lower.endswith(suffix) for suffix in _CYPRESS_SPEC_SUFFIXES):
1278
+ relative = os.path.relpath(os.path.join(root, filename), work_dir)
1279
+ specs.append(relative.replace(os.sep, "/"))
1280
+ return sorted(specs)
1281
+
1282
+
1283
+ def _cypress_shard_had_no_matching_specs(project, shard_name):
1284
+ log_path = os.path.join(
1285
+ prepare_logs_directory(project), f"cypress_run_{shard_name}.log"
1286
+ )
1287
+ for candidate in (log_path, f"{log_path}.err"):
1288
+ try:
1289
+ with open(candidate, encoding="utf-8", errors="replace") as log_file:
1290
+ output = log_file.read()
1291
+ except OSError:
1292
+ continue
1293
+ plain_output = _ANSI_ESCAPE.sub("", output)
1294
+ if re.search(r"no spec files were found|Specs:\s*0 found", plain_output, re.IGNORECASE):
1295
+ return True
1296
+ return False
1297
+
1298
+
1299
+ def _partition_cypress_specs(specs, worker_count):
1300
+ workers = max(1, min(worker_count, len(specs)))
1301
+ chunks = [[] for _ in range(workers)]
1302
+ for index, spec in enumerate(specs):
1303
+ chunks[index % workers].append(spec)
1304
+ return [chunk for chunk in chunks if chunk]
1305
+
1306
+
1307
+ def _merge_parallel_cypress_suite_logs(project, logger, shard_names, extra_failures=None):
1308
+ parent_cypress_log = os.path.join(prepare_logs_directory(project), "cypress_run.log")
1309
+ appended = False
1310
+ for name in sorted(shard_names):
1311
+ source = os.path.join(prepare_logs_directory(project), f"cypress_run_{name}.log")
1312
+ if os.path.isfile(source) and os.path.getsize(source) > 0:
1313
+ _copy_cypress_log(source, parent_cypress_log, appended)
1314
+ appended = True
1315
+ _write_parallel_cypress_failures(project, extra_failures or [])
1316
+ _write_cypress_summary(project, logger, extra_failures=extra_failures)
1317
+
1318
+
1319
+ def _write_parallel_cypress_failures(project, failures):
1320
+ path = os.path.join(prepare_logs_directory(project), _CYPRESS_PARALLEL_FAILURES_FILE)
1321
+ if not failures:
1322
+ if os.path.isfile(path):
1323
+ os.remove(path)
1324
+ return
1325
+ redacted_failures = [
1326
+ _redact_secret_log_text(str(failure)).strip() for failure in failures
1327
+ ]
1328
+ with os.fdopen(
1329
+ os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600),
1330
+ "w",
1331
+ encoding="utf-8",
1332
+ ) as failures_file:
1333
+ json.dump(redacted_failures, failures_file)
1334
+
1335
+
1081
1336
  def verify_cypress(project: Project, logger: Logger, reactor: Reactor):
1082
1337
  # Get directories with test and cypress executable
1083
1338
  work_dir = project.expand_path(f"${CYPRESS_TEST_DIR}")
@@ -1085,7 +1340,23 @@ def verify_cypress(project: Project, logger: Logger, reactor: Reactor):
1085
1340
  package_artifacts(project, work_dir, "cypress", project.get_property(ROLE))
1086
1341
 
1087
1342
 
1088
- def _run_cypress_tests_in_directory(work_dir, logger, project, reactor: Reactor):
1343
+ def _install_cypress_suite_dependencies(work_dir, logger, project, reactor, suite_name=None):
1344
+ npm_log_file = f"package_json_npm_install_{suite_name}.log" if suite_name else "package_json_npm_install.log"
1345
+ cypress_npm_log_file = f"cypress_npm_install_{suite_name}.log" if suite_name else "cypress_npm_install.log"
1346
+ package_json = os.path.join(work_dir, "package.json")
1347
+ if os.path.exists(package_json):
1348
+ logger.info("Found package.json installing dependencies")
1349
+ tool_utility.install_npm_dependencies(
1350
+ work_dir, project=project, logger=logger, reactor=reactor, log_file_name=npm_log_file
1351
+ )
1352
+ else:
1353
+ install_cypress(
1354
+ logger=logger, project=project, reactor=reactor, work_dir=work_dir, log_file_name=cypress_npm_log_file
1355
+ )
1356
+
1357
+
1358
+ def _run_cypress_tests_in_directory(work_dir, logger, project, reactor: Reactor, suite_name=None, write_summary=True,
1359
+ spec_files=None, skip_install=False, record=None, xvfb_display_num=None):
1089
1360
  total_time = Timer.start()
1090
1361
  target_url = project.get_mandatory_property(INTEGRATION_TARGET_URL)
1091
1362
  environment = project.get_mandatory_property(ENVIRONMENT)
@@ -1094,64 +1365,79 @@ def _run_cypress_tests_in_directory(work_dir, logger, project, reactor: Reactor)
1094
1365
  return False
1095
1366
  logger.info(f"Found {len(os.listdir(work_dir))} files in cypress test directory")
1096
1367
  logger.debug(f"Files: {os.listdir(work_dir)} ")
1097
- # Validate NPM install and Install cypress
1098
- package_json = os.path.join(work_dir, "package.json")
1099
- if os.path.exists(package_json):
1100
- logger.info("Found package.json installing dependencies")
1101
- tool_utility.install_npm_dependencies(work_dir, project=project, logger=logger, reactor=reactor)
1102
- else:
1103
- install_cypress(logger=logger, project=project, reactor=reactor, work_dir=work_dir)
1368
+ if not skip_install:
1369
+ _install_cypress_suite_dependencies(work_dir, logger, project, reactor, suite_name=suite_name)
1104
1370
  total_time.stop()
1105
1371
  logger.info(f"Configured Cypress Environment: {total_time.get_millis()}")
1106
1372
  total_time = Timer.start()
1107
1373
  executable = os.path.join(work_dir, "node_modules/cypress/bin/cypress")
1108
- results_file, run_name = get_test_report_file(project=project, test_dir=work_dir, tool="cypress")
1374
+ report_name = suite_name or os.path.basename(work_dir)
1375
+ results_file = os.path.join(prepare_reports_directory(project), f"cypress-{report_name}.out.xml")
1109
1376
  # Run the actual tests against the baseURL provided by ${integration_target}
1110
1377
  test_report_folder = directory_utility.prepare_reports_directory(project)
1378
+ videos_folder = f"{test_report_folder}/videos"
1379
+ screenshots_folder = f"{test_report_folder}/screenshots"
1380
+ if suite_name:
1381
+ videos_folder = f"{videos_folder}/{suite_name}"
1382
+ screenshots_folder = f"{screenshots_folder}/{suite_name}"
1111
1383
  args = ["run", "--config",
1112
1384
  f"baseUrl={target_url},"
1113
- f"videosFolder={test_report_folder}/videos,"
1114
- f"screenshotsFolder={test_report_folder}/screenshots",
1385
+ f"videosFolder={videos_folder},"
1386
+ f"screenshotsFolder={screenshots_folder}",
1115
1387
  "--reporter-options",
1116
1388
  f"mochaFile={results_file}"]
1117
- if _get_bool_property(project, "record_cypress", True):
1389
+ should_record = _get_bool_property(project, "record_cypress", True) if record is None else bool(record)
1390
+ if should_record:
1118
1391
  args.append('--record')
1392
+ if spec_files:
1393
+ args.extend(["--spec", ",".join(spec_files)])
1119
1394
  _add_config_file(logger, project, args, environment, work_dir)
1120
- environment_variables = project.get_property(ENVIRONMENT_VARIABLES, {})
1395
+ environment_variables = dict(project.get_property(ENVIRONMENT_VARIABLES, {}) or {})
1396
+ if xvfb_display_num is not None:
1397
+ environment_variables["XVFB_DISPLAY_NUM"] = str(xvfb_display_num)
1121
1398
  logger.info(f"Running cypress on host: {target_url}")
1399
+ log_file_name = f"cypress_run_{suite_name}.log" if suite_name else "cypress_run.log"
1122
1400
  try:
1123
1401
  exec_utility.exec_command(command_name=executable, args=args,
1124
- failure_message="Failed to execute cypress tests", log_file_name='cypress_run.log',
1402
+ failure_message="Failed to execute cypress tests", log_file_name=log_file_name,
1125
1403
  project=project, reactor=reactor, logger=logger, working_dir=work_dir, report=False,
1126
1404
  env_vars=environment_variables)
1127
1405
  # workaround but cypress output are relative to location of cypress.json, so we need to collapse
1128
1406
  if os.path.exists(f"{work_dir}/target"):
1129
- shutil.copytree(f"{work_dir}/target", project.expand_path("$dir_target"), dirs_exist_ok=True)
1407
+ with _CYPRESS_TARGET_COPY_LOCK:
1408
+ shutil.copytree(f"{work_dir}/target", project.expand_path("$dir_target"), dirs_exist_ok=True)
1130
1409
  finally:
1131
- _write_cypress_summary(project, logger)
1410
+ if write_summary:
1411
+ _write_cypress_summary(project, logger)
1132
1412
  total_time.stop()
1133
1413
  logger.info(f"Ran Cypress Tests: {total_time.get_millis()}")
1134
1414
  return True
1135
1415
 
1136
1416
 
1137
- def _write_cypress_summary(project, logger):
1417
+ def _write_cypress_summary(project, logger, extra_failures=None):
1138
1418
  """Parse cypress_run.log and write a portable Markdown summary artifact.
1139
1419
 
1140
- No-op when Cypress did not run (missing or empty cypress_run.log), so
1141
- verify_environment jobs without Cypress do not emit a false warning.
1142
- Parallel child subprocesses also skip it; the parent writes one summary
1143
- after merging child Cypress logs.
1420
+ No-op when Cypress did not run (missing or empty cypress_run.log) and
1421
+ there are no extra_failures, so verify_environment jobs without Cypress
1422
+ do not emit a false warning. Parallel child subprocesses also skip it;
1423
+ the parent writes one summary after merging child Cypress logs.
1424
+ extra_failures keeps the summary from reporting PASS when a parallel
1425
+ shard failed before producing JUnit.
1144
1426
  """
1427
+ extra_failures = extra_failures or []
1145
1428
  log_path = os.path.join(prepare_logs_directory(project), "cypress_run.log")
1146
1429
  if os.environ.get(SUBPROCESS_PROPERTIES_FILE_ENV):
1147
1430
  logger.debug("Skipping Cypress summary in verify_environment subprocess")
1148
1431
  return None
1149
- if not os.path.isfile(log_path) or os.path.getsize(log_path) == 0:
1432
+ has_log = os.path.isfile(log_path) and os.path.getsize(log_path) > 0
1433
+ if not has_log and not extra_failures:
1150
1434
  logger.debug("Skipping Cypress summary: cypress_run.log not found")
1151
1435
  return None
1152
1436
  summary_path = os.path.join(prepare_reports_directory(project), "cypress-summary.md")
1153
1437
  try:
1154
- output = _redact_secret_log_text(_build_cypress_summary(log_path))
1438
+ output = _redact_secret_log_text(
1439
+ _build_cypress_summary(log_path if has_log else None, extra_failures)
1440
+ )
1155
1441
  with os.fdopen(
1156
1442
  os.open(summary_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600),
1157
1443
  "w",
@@ -1172,33 +1458,40 @@ def _write_cypress_summary(project, logger):
1172
1458
  logger.warn(f"[cypress_summary] failed to generate summary: {e}")
1173
1459
 
1174
1460
 
1175
- def _build_cypress_summary(log_path):
1176
- if not os.path.exists(log_path):
1177
- return "## Cypress Test Results\n\n> warning `cypress_run.log` not found\n"
1178
-
1179
- raw = open(log_path, errors="replace").read()
1180
- clean = re.sub(r"\x1b\[[0-9;]*m", "", raw)
1181
-
1461
+ def _build_cypress_summary(log_path, extra_failures=None):
1462
+ extra_failures = extra_failures or []
1182
1463
  total = failed = skipped = 0
1183
1464
  failures = []
1184
- for xml_block in re.findall(r"<testsuites[\s\S]*?</testsuites>", clean):
1185
- try:
1186
- root = ET.fromstring(xml_block)
1187
- total += int(root.get("tests", 0))
1188
- failed += int(root.get("failures", 0))
1189
- skipped += int(root.get("skipped", 0) or 0)
1190
- for tc in root.iter("testcase"):
1191
- failure = tc.find("failure")
1192
- if failure is not None:
1193
- suite = tc.get("classname", "").split(".")[-1]
1194
- name = tc.get("name", "")
1195
- msg = (failure.get("message") or failure.text or "").split("\n")[0][:120]
1196
- failures.append((f"{suite} > {name}", msg))
1197
- except (ET.ParseError, DefusedXmlException):
1198
- pass
1465
+ if log_path and os.path.exists(log_path):
1466
+ raw = open(log_path, errors="replace").read()
1467
+ clean = re.sub(r"\x1b\[[0-9;]*m", "", raw)
1468
+
1469
+ for xml_block in re.findall(r"<testsuites[\s\S]*?</testsuites>", clean):
1470
+ try:
1471
+ root = ET.fromstring(xml_block)
1472
+ total += int(root.get("tests", 0))
1473
+ failed += int(root.get("failures", 0))
1474
+ skipped += int(root.get("skipped", 0) or 0)
1475
+ for tc in root.iter("testcase"):
1476
+ failure = tc.find("failure")
1477
+ if failure is not None:
1478
+ suite = tc.get("classname", "").split(".")[-1]
1479
+ name = tc.get("name", "")
1480
+ msg = (failure.get("message") or failure.text or "").split("\n")[0][:120]
1481
+ failures.append((f"{suite} > {name}", msg))
1482
+ except (ET.ParseError, DefusedXmlException):
1483
+ pass
1484
+ elif not extra_failures:
1485
+ return "## Cypress Test Results\n\n> warning `cypress_run.log` not found\n"
1199
1486
 
1200
1487
  passed = total - failed - skipped
1201
- if total == 0:
1488
+ if extra_failures:
1489
+ icon = "FAIL"
1490
+ if failed:
1491
+ label = f"{failed} test(s) failed; {len(extra_failures)} suite(s) failed"
1492
+ else:
1493
+ label = f"{len(extra_failures)} parallel suite(s) failed"
1494
+ elif total == 0:
1202
1495
  icon = "WARN"
1203
1496
  label = "No tests ran - check if Cypress found any spec files"
1204
1497
  elif failed == 0:
@@ -1225,6 +1518,13 @@ def _build_cypress_summary(log_path):
1225
1518
  if len(failures) > 20:
1226
1519
  lines.append(f"\n_...and {len(failures) - 20} more_")
1227
1520
 
1521
+ if extra_failures:
1522
+ lines.append("\n### Parallel Suite Failures\n")
1523
+ for extra in extra_failures[:20]:
1524
+ lines.append(f"- **{extra}**")
1525
+ if len(extra_failures) > 20:
1526
+ lines.append(f"\n_...and {len(extra_failures) - 20} more_")
1527
+
1228
1528
  return "\n".join(lines) + "\n"
1229
1529
 
1230
1530
 
@@ -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: 111
3
+ Version: 113
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 = '111',
24
+ version = '113',
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,