pybuilder-integration 111__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-111 → pybuilder_integration-112}/PKG-INFO +1 -1
  2. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration/properties.py +2 -0
  3. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration/tasks.py +345 -54
  4. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration/tool_utility.py +6 -6
  5. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration.egg-info/PKG-INFO +1 -1
  6. {pybuilder_integration-111 → pybuilder_integration-112}/setup.py +1 -1
  7. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration/__init__.py +0 -0
  8. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration/artifact_manager.py +0 -0
  9. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration/cloudwatchlogs_utility.py +0 -0
  10. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration/directory_utility.py +0 -0
  11. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration/exec_utility.py +0 -0
  12. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration.egg-info/SOURCES.txt +0 -0
  13. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration.egg-info/dependency_links.txt +0 -0
  14. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration.egg-info/namespace_packages.txt +0 -0
  15. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration.egg-info/requires.txt +0 -0
  16. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration.egg-info/top_level.txt +0 -0
  17. {pybuilder_integration-111 → pybuilder_integration-112}/pybuilder_integration.egg-info/zip-safe +0 -0
  18. {pybuilder_integration-111 → pybuilder_integration-112}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pybuilder-integration
3
- Version: 111
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"
@@ -52,13 +52,22 @@ _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_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"
62
71
 
63
72
  def integration_artifact_push(project: Project, logger: Logger, reactor: Reactor):
64
73
  logger.info("Starting upload of integration artifacts")
@@ -158,8 +167,8 @@ def _run_verify_environment_passes_in_subprocesses(project: Project, logger: Log
158
167
  process.terminate()
159
168
  process.wait()
160
169
  _close_subprocess_log(process)
161
- _merge_parallel_verify_environment_outputs(project, passes)
162
- _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)
163
172
  if os.path.exists(properties_file):
164
173
  os.remove(properties_file)
165
174
 
@@ -377,6 +386,27 @@ def _first_child_cypress_log(stage_target):
377
386
  return None
378
387
 
379
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
+
380
410
  def _copy_maybe_redacted_file(src, dst, *, follow_symlinks=True):
381
411
  if os.path.splitext(src)[1].lower() in _REDACTED_COPY_EXTENSIONS:
382
412
  _copy_redacted_text_file(src, dst)
@@ -580,8 +610,12 @@ def _merge_parallel_verify_environment_outputs(project: Project, passes):
580
610
  }
581
611
  parent_cypress_log = os.path.join(parent_logs_directory, "cypress_run.log")
582
612
  has_parent_cypress_log = False
613
+ cypress_failures = []
583
614
  for pass_config in passes:
584
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
+ )
585
619
  for source_root in _child_output_source_roots(stage_target):
586
620
  for destination_kind, source_dirname in _CHILD_OUTPUT_SOURCES:
587
621
  source = os.path.join(source_root, source_dirname, "integration")
@@ -598,6 +632,7 @@ def _merge_parallel_verify_environment_outputs(project: Project, passes):
598
632
  if cypress_log:
599
633
  _copy_cypress_log(cypress_log, parent_cypress_log, has_parent_cypress_log)
600
634
  has_parent_cypress_log = True
635
+ return cypress_failures
601
636
 
602
637
 
603
638
  def _copy_cypress_log(source, destination, append):
@@ -868,9 +903,11 @@ def _run_cypress_tests_in_dist_dir(dist_directory, latest, logger, project, reac
868
903
  logger.debug(f"Run cypress tests in directory files: {os.listdir(cypress_test_path)} ")
869
904
  logger.info(f"Found cypress tests - starting run latest: {latest}")
870
905
  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):
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:
874
911
  logger.info(f"Running {directory_to_test}")
875
912
  _run_cypress_tests_in_directory(work_dir=directory_to_test,
876
913
  logger=logger,
@@ -920,17 +957,42 @@ def _iter_latest_tavern_suite_dirs(tavern_test_path, project):
920
957
  yield tavern_test_directory
921
958
 
922
959
 
923
- def _parallel_tavern_suite_workers(project, suite_count):
924
- 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)
925
969
  if configured is None or configured == "":
926
- return max(1, min(_DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS, suite_count))
970
+ return max(1, min(default_workers, suite_count))
927
971
  try:
928
972
  workers = int(configured)
929
973
  except (TypeError, ValueError):
930
- workers = _DEFAULT_PARALLEL_TAVERN_SUITE_WORKERS
974
+ workers = default_workers
931
975
  return max(1, min(workers, suite_count))
932
976
 
933
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
+
934
996
  def _run_tavern_suites_parallel(suite_dirs, logger, project, reactor):
935
997
  prepare_reports_directory(project)
936
998
  runnable = []
@@ -1078,6 +1140,192 @@ def _plugin_venv_requirements_fingerprint(fingerprint_by_dir):
1078
1140
  return max(order, key=lambda fingerprint: (counts[fingerprint], -order.index(fingerprint)))
1079
1141
 
1080
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
+
1081
1329
  def verify_cypress(project: Project, logger: Logger, reactor: Reactor):
1082
1330
  # Get directories with test and cypress executable
1083
1331
  work_dir = project.expand_path(f"${CYPRESS_TEST_DIR}")
@@ -1085,7 +1333,23 @@ def verify_cypress(project: Project, logger: Logger, reactor: Reactor):
1085
1333
  package_artifacts(project, work_dir, "cypress", project.get_property(ROLE))
1086
1334
 
1087
1335
 
1088
- 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):
1089
1353
  total_time = Timer.start()
1090
1354
  target_url = project.get_mandatory_property(INTEGRATION_TARGET_URL)
1091
1355
  environment = project.get_mandatory_property(ENVIRONMENT)
@@ -1094,64 +1358,77 @@ def _run_cypress_tests_in_directory(work_dir, logger, project, reactor: Reactor)
1094
1358
  return False
1095
1359
  logger.info(f"Found {len(os.listdir(work_dir))} files in cypress test directory")
1096
1360
  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)
1361
+ if not skip_install:
1362
+ _install_cypress_suite_dependencies(work_dir, logger, project, reactor, suite_name=suite_name)
1104
1363
  total_time.stop()
1105
1364
  logger.info(f"Configured Cypress Environment: {total_time.get_millis()}")
1106
1365
  total_time = Timer.start()
1107
1366
  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")
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")
1109
1369
  # Run the actual tests against the baseURL provided by ${integration_target}
1110
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}"
1111
1376
  args = ["run", "--config",
1112
1377
  f"baseUrl={target_url},"
1113
- f"videosFolder={test_report_folder}/videos,"
1114
- f"screenshotsFolder={test_report_folder}/screenshots",
1378
+ f"videosFolder={videos_folder},"
1379
+ f"screenshotsFolder={screenshots_folder}",
1115
1380
  "--reporter-options",
1116
1381
  f"mochaFile={results_file}"]
1117
- 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:
1118
1384
  args.append('--record')
1385
+ if spec_files:
1386
+ args.extend(["--spec", ",".join(spec_files)])
1119
1387
  _add_config_file(logger, project, args, environment, work_dir)
1120
1388
  environment_variables = project.get_property(ENVIRONMENT_VARIABLES, {})
1121
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"
1122
1391
  try:
1123
1392
  exec_utility.exec_command(command_name=executable, args=args,
1124
- 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,
1125
1394
  project=project, reactor=reactor, logger=logger, working_dir=work_dir, report=False,
1126
1395
  env_vars=environment_variables)
1127
1396
  # workaround but cypress output are relative to location of cypress.json, so we need to collapse
1128
1397
  if os.path.exists(f"{work_dir}/target"):
1129
- 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)
1130
1400
  finally:
1131
- _write_cypress_summary(project, logger)
1401
+ if write_summary:
1402
+ _write_cypress_summary(project, logger)
1132
1403
  total_time.stop()
1133
1404
  logger.info(f"Ran Cypress Tests: {total_time.get_millis()}")
1134
1405
  return True
1135
1406
 
1136
1407
 
1137
- def _write_cypress_summary(project, logger):
1408
+ def _write_cypress_summary(project, logger, extra_failures=None):
1138
1409
  """Parse cypress_run.log and write a portable Markdown summary artifact.
1139
1410
 
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.
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.
1144
1417
  """
1418
+ extra_failures = extra_failures or []
1145
1419
  log_path = os.path.join(prepare_logs_directory(project), "cypress_run.log")
1146
1420
  if os.environ.get(SUBPROCESS_PROPERTIES_FILE_ENV):
1147
1421
  logger.debug("Skipping Cypress summary in verify_environment subprocess")
1148
1422
  return None
1149
- 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:
1150
1425
  logger.debug("Skipping Cypress summary: cypress_run.log not found")
1151
1426
  return None
1152
1427
  summary_path = os.path.join(prepare_reports_directory(project), "cypress-summary.md")
1153
1428
  try:
1154
- 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
+ )
1155
1432
  with os.fdopen(
1156
1433
  os.open(summary_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600),
1157
1434
  "w",
@@ -1172,33 +1449,40 @@ def _write_cypress_summary(project, logger):
1172
1449
  logger.warn(f"[cypress_summary] failed to generate summary: {e}")
1173
1450
 
1174
1451
 
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
-
1452
+ def _build_cypress_summary(log_path, extra_failures=None):
1453
+ extra_failures = extra_failures or []
1182
1454
  total = failed = skipped = 0
1183
1455
  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
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"
1199
1477
 
1200
1478
  passed = total - failed - skipped
1201
- 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:
1202
1486
  icon = "WARN"
1203
1487
  label = "No tests ran - check if Cypress found any spec files"
1204
1488
  elif failed == 0:
@@ -1225,6 +1509,13 @@ def _build_cypress_summary(log_path):
1225
1509
  if len(failures) > 20:
1226
1510
  lines.append(f"\n_...and {len(failures) - 20} more_")
1227
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
+
1228
1519
  return "\n".join(lines) + "\n"
1229
1520
 
1230
1521
 
@@ -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: 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 = '111',
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,