cumulusci-plus 5.0.19__py3-none-any.whl → 5.0.35__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. cumulusci/__about__.py +1 -1
  2. cumulusci/cli/logger.py +2 -2
  3. cumulusci/cli/service.py +20 -0
  4. cumulusci/cli/task.py +17 -0
  5. cumulusci/cli/tests/test_error.py +3 -1
  6. cumulusci/cli/tests/test_flow.py +279 -2
  7. cumulusci/cli/tests/test_service.py +15 -12
  8. cumulusci/cli/tests/test_task.py +88 -2
  9. cumulusci/cli/tests/utils.py +1 -4
  10. cumulusci/core/config/base_task_flow_config.py +26 -1
  11. cumulusci/core/config/project_config.py +2 -20
  12. cumulusci/core/config/tests/test_config_expensive.py +9 -3
  13. cumulusci/core/config/universal_config.py +3 -4
  14. cumulusci/core/dependencies/base.py +1 -1
  15. cumulusci/core/dependencies/dependencies.py +1 -1
  16. cumulusci/core/dependencies/github.py +1 -2
  17. cumulusci/core/dependencies/resolvers.py +1 -1
  18. cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
  19. cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
  20. cumulusci/core/flowrunner.py +90 -6
  21. cumulusci/core/github.py +1 -1
  22. cumulusci/core/sfdx.py +3 -1
  23. cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
  24. cumulusci/core/source_transforms/transforms.py +1 -1
  25. cumulusci/core/tasks.py +13 -2
  26. cumulusci/core/tests/test_flowrunner.py +100 -0
  27. cumulusci/core/tests/test_tasks.py +65 -0
  28. cumulusci/core/utils.py +3 -1
  29. cumulusci/core/versions.py +1 -1
  30. cumulusci/cumulusci.yml +55 -0
  31. cumulusci/oauth/client.py +1 -1
  32. cumulusci/plugins/plugin_base.py +5 -3
  33. cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
  34. cumulusci/salesforce_api/rest_deploy.py +1 -1
  35. cumulusci/schema/cumulusci.jsonschema.json +64 -0
  36. cumulusci/tasks/apex/anon.py +1 -1
  37. cumulusci/tasks/apex/testrunner.py +416 -142
  38. cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
  39. cumulusci/tasks/bulkdata/extract.py +0 -1
  40. cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
  41. cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
  42. cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
  43. cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
  44. cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
  45. cumulusci/tasks/bulkdata/select_utils.py +1 -1
  46. cumulusci/tasks/bulkdata/snowfakery.py +100 -25
  47. cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
  48. cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
  49. cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
  50. cumulusci/tasks/bulkdata/tests/test_select_utils.py +26 -0
  51. cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
  52. cumulusci/tasks/create_package_version.py +190 -16
  53. cumulusci/tasks/datadictionary.py +1 -1
  54. cumulusci/tasks/metadata_etl/base.py +7 -3
  55. cumulusci/tasks/metadata_etl/layouts.py +1 -1
  56. cumulusci/tasks/metadata_etl/permissions.py +1 -1
  57. cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
  58. cumulusci/tasks/push/README.md +15 -17
  59. cumulusci/tasks/release_notes/README.md +13 -13
  60. cumulusci/tasks/release_notes/generator.py +13 -8
  61. cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
  62. cumulusci/tasks/salesforce/Deploy.py +53 -2
  63. cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
  64. cumulusci/tasks/salesforce/__init__.py +1 -0
  65. cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
  66. cumulusci/tasks/salesforce/composite.py +1 -1
  67. cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
  68. cumulusci/tasks/salesforce/enable_prediction.py +5 -1
  69. cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
  70. cumulusci/tasks/salesforce/profiles.py +13 -9
  71. cumulusci/tasks/salesforce/sourcetracking.py +1 -1
  72. cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
  73. cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
  74. cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
  75. cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
  76. cumulusci/tasks/salesforce/tests/test_profiles.py +43 -3
  77. cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
  78. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
  79. cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
  80. cumulusci/tasks/salesforce/update_dependencies.py +2 -2
  81. cumulusci/tasks/salesforce/update_external_credential.py +562 -0
  82. cumulusci/tasks/salesforce/update_named_credential.py +441 -0
  83. cumulusci/tasks/salesforce/update_profile.py +17 -13
  84. cumulusci/tasks/salesforce/users/permsets.py +62 -5
  85. cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
  86. cumulusci/tasks/sfdmu/__init__.py +0 -0
  87. cumulusci/tasks/sfdmu/sfdmu.py +363 -0
  88. cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
  89. cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
  90. cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
  91. cumulusci/tasks/tests/test_create_package_version.py +716 -1
  92. cumulusci/tasks/tests/test_util.py +42 -0
  93. cumulusci/tasks/util.py +37 -1
  94. cumulusci/tasks/utility/copyContents.py +402 -0
  95. cumulusci/tasks/utility/credentialManager.py +256 -0
  96. cumulusci/tasks/utility/directoryRecreator.py +30 -0
  97. cumulusci/tasks/utility/env_management.py +1 -1
  98. cumulusci/tasks/utility/secretsToEnv.py +135 -0
  99. cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
  100. cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
  101. cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
  102. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
  103. cumulusci/tests/test_integration_infrastructure.py +3 -1
  104. cumulusci/tests/test_utils.py +70 -6
  105. cumulusci/utils/__init__.py +54 -9
  106. cumulusci/utils/classutils.py +5 -2
  107. cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
  108. cumulusci/utils/options.py +23 -1
  109. cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
  110. cumulusci/utils/yaml/cumulusci_yml.py +7 -3
  111. cumulusci/utils/yaml/model_parser.py +2 -2
  112. cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
  113. cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
  114. cumulusci/vcs/base.py +23 -15
  115. cumulusci/vcs/bootstrap.py +5 -4
  116. cumulusci/vcs/utils/list_modified_files.py +189 -0
  117. cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
  118. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
  119. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +123 -98
  120. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
  121. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
  122. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
  123. {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/LICENSE +0 -0
@@ -813,7 +813,9 @@ class TestRunApexTests(MockLoggerMixin):
813
813
  task._init_options(task_config.config["options"])
814
814
 
815
815
  assert (
816
- task.options["retry_failures"][0].search("UNABLE_TO_LOCK_ROW: test failed")
816
+ task.parsed_options.retry_failures[0].search(
817
+ "UNABLE_TO_LOCK_ROW: test failed"
818
+ )
817
819
  is not None
818
820
  )
819
821
 
@@ -971,6 +973,920 @@ class TestRunApexTests(MockLoggerMixin):
971
973
  task()
972
974
  assert task.result is None
973
975
 
976
+ def test_package_only_filter__filters_classes_not_in_package(self):
977
+ """Test that dynamic_filter='package_only' filters out classes not in the package directory"""
978
+ from unittest.mock import PropertyMock
979
+
980
+ # Create temporary directory structure with some test class files
981
+ tmpdir = tempfile.mkdtemp()
982
+ try:
983
+ # Create a mock force-app directory structure
984
+ classes_dir = os.path.join(tmpdir, "main", "default", "classes")
985
+ os.makedirs(classes_dir)
986
+
987
+ # Create two test class files
988
+ with open(os.path.join(classes_dir, "TestClass1.cls"), "w") as f:
989
+ f.write("public class TestClass1 {}")
990
+ with open(os.path.join(classes_dir, "TestClass2.cls"), "w") as f:
991
+ f.write("public class TestClass2 {}")
992
+
993
+ # Setup task with dynamic_filter='package_only' option
994
+ task_config = TaskConfig(
995
+ {
996
+ "options": {
997
+ "test_name_match": "%_TEST",
998
+ "dynamic_filter": "package_only",
999
+ }
1000
+ }
1001
+ )
1002
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1003
+
1004
+ # Mock the default_package_path property to point to our temp directory
1005
+ with patch.object(
1006
+ type(task.project_config),
1007
+ "default_package_path",
1008
+ new_callable=PropertyMock,
1009
+ ) as mock_path:
1010
+ mock_path.return_value = tmpdir
1011
+
1012
+ # Create mock test classes result with 3 classes, but only 2 exist in package
1013
+ mock_classes = {
1014
+ "totalSize": 3,
1015
+ "done": True,
1016
+ "records": [
1017
+ {"Id": "01p000001", "Name": "TestClass1"},
1018
+ {"Id": "01p000002", "Name": "TestClass2"},
1019
+ {"Id": "01p000003", "Name": "TestClass3"}, # Not in package
1020
+ ],
1021
+ }
1022
+
1023
+ # Test the filter method
1024
+ filtered = task._filter_package_classes(mock_classes)
1025
+
1026
+ # Verify only 2 classes are returned
1027
+ assert filtered["totalSize"] == 2
1028
+ assert len(filtered["records"]) == 2
1029
+
1030
+ # Verify the correct classes were kept
1031
+ filtered_names = [record["Name"] for record in filtered["records"]]
1032
+ assert "TestClass1" in filtered_names
1033
+ assert "TestClass2" in filtered_names
1034
+ assert "TestClass3" not in filtered_names
1035
+ finally:
1036
+ shutil.rmtree(tmpdir)
1037
+
1038
+ def test_package_only_filter__disabled_returns_all_classes(self):
1039
+ """Test that when dynamic_filter is not set to 'package_only', all classes are returned"""
1040
+ task_config = TaskConfig({"options": {"test_name_match": "%_TEST"}})
1041
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1042
+
1043
+ mock_classes = {
1044
+ "totalSize": 3,
1045
+ "done": True,
1046
+ "records": [
1047
+ {"Id": "01p000001", "Name": "TestClass1"},
1048
+ {"Id": "01p000002", "Name": "TestClass2"},
1049
+ {"Id": "01p000003", "Name": "TestClass3"},
1050
+ ],
1051
+ }
1052
+
1053
+ # Test the filter method - should return all classes unchanged
1054
+ filtered = task._filter_package_classes(mock_classes)
1055
+
1056
+ assert filtered["totalSize"] == 3
1057
+ assert len(filtered["records"]) == 3
1058
+
1059
+ def test_class_exists_in_package__finds_class(self):
1060
+ """Test that _class_exists_in_package correctly finds classes in subdirectories"""
1061
+ from unittest.mock import PropertyMock
1062
+
1063
+ tmpdir = tempfile.mkdtemp()
1064
+ try:
1065
+ # Create nested directory structure
1066
+ classes_dir = os.path.join(tmpdir, "main", "default", "classes")
1067
+ os.makedirs(classes_dir)
1068
+
1069
+ # Create a test class file
1070
+ with open(os.path.join(classes_dir, "MyTestClass.cls"), "w") as f:
1071
+ f.write("public class MyTestClass {}")
1072
+
1073
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1074
+
1075
+ with patch.object(
1076
+ type(task.project_config),
1077
+ "default_package_path",
1078
+ new_callable=PropertyMock,
1079
+ ) as mock_path:
1080
+ mock_path.return_value = tmpdir
1081
+
1082
+ # Should find the class
1083
+ assert task._class_exists_in_package("MyTestClass") is True
1084
+ # Should not find non-existent class
1085
+ assert task._class_exists_in_package("NonExistentClass") is False
1086
+ finally:
1087
+ shutil.rmtree(tmpdir)
1088
+
1089
+ def test_individual_class_coverage__valid_dict(self):
1090
+ """Test that individual class coverage requirements are parsed correctly"""
1091
+ task_config = TaskConfig(
1092
+ {
1093
+ "options": {
1094
+ "test_name_match": "%_TEST",
1095
+ "required_individual_class_code_coverage_percent": {
1096
+ "TestClass1": 75,
1097
+ "TestClass2": "80",
1098
+ },
1099
+ }
1100
+ }
1101
+ )
1102
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1103
+
1104
+ assert task.required_individual_class_code_coverage_percent == {
1105
+ "TestClass1": 75,
1106
+ "TestClass2": 80,
1107
+ }
1108
+
1109
+ def test_individual_class_coverage__invalid_not_dict(self):
1110
+ """Test that non-dictionary values raise TaskOptionsError"""
1111
+ task_config = TaskConfig(
1112
+ {
1113
+ "options": {
1114
+ "test_name_match": "%_TEST",
1115
+ "required_individual_class_code_coverage_percent": "not a dict",
1116
+ }
1117
+ }
1118
+ )
1119
+ with pytest.raises(TaskOptionsError) as e:
1120
+ RunApexTests(self.project_config, task_config, self.org_config)
1121
+ assert "Var is not a name/value pair: not a dict" in str(e.value)
1122
+
1123
+ def test_individual_class_coverage__invalid_value(self):
1124
+ """Test that invalid values in dictionary raise TaskOptionsError"""
1125
+ task_config = TaskConfig(
1126
+ {
1127
+ "options": {
1128
+ "test_name_match": "%_TEST",
1129
+ "required_individual_class_code_coverage_percent": {
1130
+ "TestClass1": "not a number",
1131
+ },
1132
+ }
1133
+ }
1134
+ )
1135
+ with pytest.raises(TaskOptionsError) as e:
1136
+ RunApexTests(self.project_config, task_config, self.org_config)
1137
+ assert "value is not a valid integer" in str(e.value)
1138
+
1139
+ def test_check_code_coverage__individual_takes_priority(self):
1140
+ """Test that individual class requirements override global per-class requirements"""
1141
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1142
+ task.required_per_class_code_coverage_percent = 50
1143
+ task.required_individual_class_code_coverage_percent = {
1144
+ "TestClass1": 75, # Higher than global
1145
+ "TestClass2": 30, # Lower than global
1146
+ }
1147
+ task.tooling = Mock()
1148
+
1149
+ # Mock class coverage: TestClass1 has 60% (fails individual req of 75%)
1150
+ # TestClass2 has 40% (passes individual req of 30%)
1151
+ # TestClass3 has 45% (fails global req of 50%)
1152
+ task.tooling.query.side_effect = [
1153
+ {
1154
+ "records": [
1155
+ {
1156
+ "ApexClassOrTrigger": {"Name": "TestClass1"},
1157
+ "NumLinesCovered": 60,
1158
+ "NumLinesUncovered": 40,
1159
+ },
1160
+ {
1161
+ "ApexClassOrTrigger": {"Name": "TestClass2"},
1162
+ "NumLinesCovered": 40,
1163
+ "NumLinesUncovered": 60,
1164
+ },
1165
+ {
1166
+ "ApexClassOrTrigger": {"Name": "TestClass3"},
1167
+ "NumLinesCovered": 45,
1168
+ "NumLinesUncovered": 55,
1169
+ },
1170
+ ],
1171
+ },
1172
+ {"records": [{"PercentCovered": 90}]}, # Org-wide coverage
1173
+ ]
1174
+
1175
+ with pytest.raises(ApexTestException) as e:
1176
+ task._check_code_coverage()
1177
+
1178
+ error_message = str(e.value)
1179
+ # TestClass1 should fail with 60% vs required 75% (individual)
1180
+ assert "TestClass1" in error_message
1181
+ assert "60.0%" in error_message
1182
+ assert "75%" in error_message
1183
+
1184
+ # TestClass2 should pass (40% >= 30% individual requirement)
1185
+ assert "TestClass2" not in error_message
1186
+
1187
+ # TestClass3 should fail with 45% vs required 50% (global)
1188
+ assert "TestClass3" in error_message
1189
+ assert "45.0%" in error_message
1190
+ assert "50%" in error_message
1191
+
1192
+ def test_check_code_coverage__fallback_to_global(self):
1193
+ """Test that global per-class requirement is used when no individual requirement exists"""
1194
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1195
+ task.required_per_class_code_coverage_percent = 60
1196
+ task.required_individual_class_code_coverage_percent = {}
1197
+ task.tooling = Mock()
1198
+
1199
+ # TestClass1 has 55% coverage - should fail global requirement of 60%
1200
+ task.tooling.query.side_effect = [
1201
+ {
1202
+ "records": [
1203
+ {
1204
+ "ApexClassOrTrigger": {"Name": "TestClass1"},
1205
+ "NumLinesCovered": 55,
1206
+ "NumLinesUncovered": 45,
1207
+ },
1208
+ ],
1209
+ },
1210
+ {"records": [{"PercentCovered": 90}]},
1211
+ ]
1212
+
1213
+ with pytest.raises(ApexTestException) as e:
1214
+ task._check_code_coverage()
1215
+
1216
+ error_message = str(e.value)
1217
+ assert "TestClass1" in error_message
1218
+ assert "55.0%" in error_message
1219
+ assert "60%" in error_message
1220
+
1221
+ def test_check_code_coverage__no_requirement_no_check(self):
1222
+ """Test that classes without requirements are not checked"""
1223
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1224
+ task.required_per_class_code_coverage_percent = 0 # No global requirement
1225
+ task.required_individual_class_code_coverage_percent = {
1226
+ "TestClass1": 75 # Only TestClass1 has requirement
1227
+ }
1228
+ task.tooling = Mock()
1229
+
1230
+ # TestClass1 has 80% (passes), TestClass2 has 10% (no requirement, should not fail)
1231
+ task.tooling.query.side_effect = [
1232
+ {
1233
+ "records": [
1234
+ {
1235
+ "ApexClassOrTrigger": {"Name": "TestClass1"},
1236
+ "NumLinesCovered": 80,
1237
+ "NumLinesUncovered": 20,
1238
+ },
1239
+ {
1240
+ "ApexClassOrTrigger": {"Name": "TestClass2"},
1241
+ "NumLinesCovered": 10,
1242
+ "NumLinesUncovered": 90,
1243
+ },
1244
+ ],
1245
+ },
1246
+ {"records": [{"PercentCovered": 90}]},
1247
+ ]
1248
+
1249
+ # Should not raise an exception because TestClass2 has no requirement
1250
+ task._check_code_coverage()
1251
+
1252
+ def test_check_code_coverage__individual_only_success_message(self):
1253
+ """Test success message when only individual requirements are set"""
1254
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1255
+ task.required_per_class_code_coverage_percent = 0
1256
+ task.required_individual_class_code_coverage_percent = {"TestClass1": 75}
1257
+ task.tooling = Mock()
1258
+
1259
+ task.tooling.query.side_effect = [
1260
+ {
1261
+ "records": [
1262
+ {
1263
+ "ApexClassOrTrigger": {"Name": "TestClass1"},
1264
+ "NumLinesCovered": 80,
1265
+ "NumLinesUncovered": 20,
1266
+ },
1267
+ ],
1268
+ },
1269
+ {"records": [{"PercentCovered": 90}]},
1270
+ ]
1271
+
1272
+ task._check_code_coverage()
1273
+
1274
+ # Check that appropriate success message was logged
1275
+ log = self._task_log_handler.messages
1276
+ assert any("individual coverage requirements" in msg for msg in log["info"])
1277
+
1278
+ def test_check_code_coverage__both_requirements_success_message(self):
1279
+ """Test success message when both global and individual requirements are set"""
1280
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1281
+ task.required_per_class_code_coverage_percent = 50
1282
+ task.required_individual_class_code_coverage_percent = {"TestClass1": 75}
1283
+ task.tooling = Mock()
1284
+
1285
+ task.tooling.query.side_effect = [
1286
+ {
1287
+ "records": [
1288
+ {
1289
+ "ApexClassOrTrigger": {"Name": "TestClass1"},
1290
+ "NumLinesCovered": 80,
1291
+ "NumLinesUncovered": 20,
1292
+ },
1293
+ {
1294
+ "ApexClassOrTrigger": {"Name": "TestClass2"},
1295
+ "NumLinesCovered": 60,
1296
+ "NumLinesUncovered": 40,
1297
+ },
1298
+ ],
1299
+ },
1300
+ {"records": [{"PercentCovered": 90}]},
1301
+ ]
1302
+
1303
+ task._check_code_coverage()
1304
+
1305
+ # Check that appropriate success message was logged
1306
+ log = self._task_log_handler.messages
1307
+ assert any("global: 50%" in msg and "individual" in msg for msg in log["info"])
1308
+
1309
+ def test_check_code_coverage__nonexistent_class_in_individual_requirements(self):
1310
+ """Test that specifying a non-existent class in individual requirements doesn't cause errors"""
1311
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1312
+ task.required_per_class_code_coverage_percent = 60
1313
+ task.required_individual_class_code_coverage_percent = {
1314
+ "ExistingClass_TEST": 80,
1315
+ "NonExistentClass_TEST": 90, # This class doesn't exist in the org
1316
+ "AnotherMissingClass_TEST": 95, # This also doesn't exist
1317
+ }
1318
+ task.tooling = Mock()
1319
+
1320
+ # Mock query returns only ExistingClass_TEST (the other two don't exist)
1321
+ task.tooling.query.side_effect = [
1322
+ {
1323
+ "records": [
1324
+ {
1325
+ "ApexClassOrTrigger": {"Name": "ExistingClass_TEST"},
1326
+ "NumLinesCovered": 85,
1327
+ "NumLinesUncovered": 15,
1328
+ },
1329
+ {
1330
+ "ApexClassOrTrigger": {"Name": "OtherClass_TEST"},
1331
+ "NumLinesCovered": 70,
1332
+ "NumLinesUncovered": 30,
1333
+ },
1334
+ ],
1335
+ },
1336
+ {"records": [{"PercentCovered": 90}]},
1337
+ ]
1338
+
1339
+ # Should not raise any exception even though NonExistentClass_TEST and
1340
+ # AnotherMissingClass_TEST are in individual requirements but not in the org
1341
+ task._check_code_coverage()
1342
+
1343
+ # Verify the existing classes were checked correctly
1344
+ # ExistingClass_TEST: 85% >= 80% (individual req) - Pass
1345
+ # OtherClass_TEST: 70% >= 60% (global req) - Pass
1346
+ # NonExistentClass_TEST and AnotherMissingClass_TEST are simply ignored
1347
+
1348
+ @patch.object(BaseProjectConfig, "get_repo")
1349
+ def test_delta_changes_filter__no_git_repo(self, mock_get_repo):
1350
+ """Test that delta_changes filter returns all classes when no git repo exists"""
1351
+ task_config = TaskConfig(
1352
+ {
1353
+ "options": {
1354
+ "test_name_match": "%_TEST",
1355
+ "dynamic_filter": "delta_changes",
1356
+ }
1357
+ }
1358
+ )
1359
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1360
+ mock_get_repo.return_value = None
1361
+
1362
+ mock_classes = {
1363
+ "totalSize": 2,
1364
+ "done": True,
1365
+ "records": [
1366
+ {"Id": "01p000001", "Name": "TestClass1"},
1367
+ {"Id": "01p000002", "Name": "TestClass2"},
1368
+ ],
1369
+ }
1370
+
1371
+ filtered = task._filter_test_classes_to_delta_changes(mock_classes)
1372
+
1373
+ # Should return all classes when no git repo
1374
+ assert len(filtered) == 2
1375
+ assert filtered == mock_classes["records"]
1376
+ log = self._task_log_handler.messages
1377
+ assert any("No git repository found" in msg for msg in log["info"])
1378
+
1379
+ @patch.object(BaseProjectConfig, "get_repo")
1380
+ @patch("cumulusci.tasks.apex.testrunner.ListModifiedFiles")
1381
+ def test_delta_changes_filter__list_modified_files_returns_none(
1382
+ self, mock_list_modified_files, mock_get_repo
1383
+ ):
1384
+ """Test that delta_changes filter handles None return from ListModifiedFiles"""
1385
+ task_config = TaskConfig(
1386
+ {
1387
+ "options": {
1388
+ "test_name_match": "%_TEST",
1389
+ "dynamic_filter": "delta_changes",
1390
+ }
1391
+ }
1392
+ )
1393
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1394
+ mock_get_repo.return_value = Mock()
1395
+
1396
+ # Mock ListModifiedFiles task - it's instantiated and then called
1397
+ # Create a simple object with return_values attribute
1398
+ class MockTaskInstance:
1399
+ def __init__(self):
1400
+ self.return_values = {"files": set(), "file_names": set()}
1401
+ self.parsed_options = Mock()
1402
+ self.parsed_options.base_ref = None
1403
+
1404
+ def __call__(self):
1405
+ pass
1406
+
1407
+ mock_list_modified_files.return_value = MockTaskInstance()
1408
+
1409
+ mock_classes = {
1410
+ "totalSize": 2,
1411
+ "done": True,
1412
+ "records": [
1413
+ {"Id": "01p000001", "Name": "TestClass1"},
1414
+ {"Id": "01p000002", "Name": "TestClass2"},
1415
+ ],
1416
+ }
1417
+
1418
+ filtered = task._filter_test_classes_to_delta_changes(mock_classes)
1419
+
1420
+ # Should return empty list when no files changed
1421
+ assert filtered == []
1422
+ log = self._task_log_handler.messages
1423
+ assert any("No changed files found" in msg for msg in log["info"])
1424
+ # Verify ListModifiedFiles was instantiated
1425
+ mock_list_modified_files.assert_called_once()
1426
+
1427
+ @patch.object(BaseProjectConfig, "get_repo")
1428
+ @patch("cumulusci.tasks.apex.testrunner.ListModifiedFiles")
1429
+ def test_delta_changes_filter__no_changed_files(
1430
+ self, mock_list_modified_files, mock_get_repo
1431
+ ):
1432
+ """Test that delta_changes filter handles empty changed files list"""
1433
+ task_config = TaskConfig(
1434
+ {
1435
+ "options": {
1436
+ "test_name_match": "%_TEST",
1437
+ "dynamic_filter": "delta_changes",
1438
+ }
1439
+ }
1440
+ )
1441
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1442
+ mock_get_repo.return_value = Mock()
1443
+
1444
+ # Mock ListModifiedFiles task
1445
+ # Create a simple object with return_values attribute
1446
+ class MockTaskInstance:
1447
+ def __init__(self):
1448
+ self.return_values = {"files": [], "file_names": None}
1449
+ self.parsed_options = Mock()
1450
+ self.parsed_options.base_ref = None
1451
+
1452
+ def __call__(self):
1453
+ pass
1454
+
1455
+ mock_list_modified_files.return_value = MockTaskInstance()
1456
+
1457
+ mock_classes = {
1458
+ "totalSize": 2,
1459
+ "done": True,
1460
+ "records": [
1461
+ {"Id": "01p000001", "Name": "TestClass1"},
1462
+ {"Id": "01p000002", "Name": "TestClass2"},
1463
+ ],
1464
+ }
1465
+
1466
+ filtered = task._filter_test_classes_to_delta_changes(mock_classes)
1467
+
1468
+ # Should return empty list when no files changed
1469
+ assert filtered == []
1470
+ log = self._task_log_handler.messages
1471
+ assert any("No changed files found" in msg for msg in log["info"])
1472
+ # Verify ListModifiedFiles was instantiated
1473
+ mock_list_modified_files.assert_called_once()
1474
+
1475
+ @patch.object(BaseProjectConfig, "get_repo")
1476
+ @patch("cumulusci.tasks.apex.testrunner.ListModifiedFiles")
1477
+ def test_delta_changes_filter__no_file_names(
1478
+ self, mock_list_modified_files, mock_get_repo
1479
+ ):
1480
+ """Test that delta_changes filter handles missing file_names in return_values"""
1481
+ task_config = TaskConfig(
1482
+ {
1483
+ "options": {
1484
+ "test_name_match": "%_TEST",
1485
+ "dynamic_filter": "delta_changes",
1486
+ }
1487
+ }
1488
+ )
1489
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1490
+ mock_get_repo.return_value = Mock()
1491
+
1492
+ # Mock ListModifiedFiles task - files exist but no file_names
1493
+ # Create a simple object with return_values attribute
1494
+ class MockTaskInstance:
1495
+ def __init__(self):
1496
+ self.return_values = {
1497
+ "files": ["force-app/main/default/classes/MyClass.cls"],
1498
+ "file_names": set(), # Empty set simulates no file names found
1499
+ }
1500
+ self.parsed_options = Mock()
1501
+ self.parsed_options.base_ref = None
1502
+
1503
+ def __call__(self):
1504
+ pass
1505
+
1506
+ mock_list_modified_files.return_value = MockTaskInstance()
1507
+
1508
+ mock_classes = {
1509
+ "totalSize": 2,
1510
+ "done": True,
1511
+ "records": [
1512
+ {"Id": "01p000001", "Name": "TestClass1"},
1513
+ {"Id": "01p000002", "Name": "TestClass2"},
1514
+ ],
1515
+ }
1516
+
1517
+ filtered = task._filter_test_classes_to_delta_changes(mock_classes)
1518
+
1519
+ # Should return empty list when no file names found
1520
+ assert filtered == []
1521
+ log = self._task_log_handler.messages
1522
+ assert any("No file names found" in msg for msg in log["info"])
1523
+ # Verify ListModifiedFiles was instantiated
1524
+ mock_list_modified_files.assert_called_once()
1525
+
1526
+ @patch.object(BaseProjectConfig, "get_repo")
1527
+ @patch("cumulusci.tasks.apex.testrunner.ListModifiedFiles")
1528
+ def test_delta_changes_filter__filters_test_classes(
1529
+ self, mock_list_modified_files, mock_get_repo
1530
+ ):
1531
+ """Test that delta_changes filter correctly filters test classes based on affected classes"""
1532
+ task_config = TaskConfig(
1533
+ {
1534
+ "options": {
1535
+ "test_name_match": "%_TEST",
1536
+ "dynamic_filter": "delta_changes",
1537
+ }
1538
+ }
1539
+ )
1540
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1541
+ mock_get_repo.return_value = Mock()
1542
+
1543
+ # Mock ListModifiedFiles task
1544
+ # Create a simple object with return_values attribute
1545
+ class MockTaskInstance:
1546
+ def __init__(self):
1547
+ self.return_values = {
1548
+ "files": ["force-app/main/default/classes/Account.cls"],
1549
+ "file_names": {"Account"},
1550
+ }
1551
+ self.parsed_options = Mock()
1552
+ self.parsed_options.base_ref = None
1553
+
1554
+ def __call__(self):
1555
+ pass
1556
+
1557
+ mock_list_modified_files.return_value = MockTaskInstance()
1558
+
1559
+ mock_classes = {
1560
+ "totalSize": 4,
1561
+ "done": True,
1562
+ "records": [
1563
+ {"Id": "01p000001", "Name": "Account"}, # Direct match
1564
+ {"Id": "01p000002", "Name": "AccountTest"}, # Affected class + Test
1565
+ {"Id": "01p000003", "Name": "TestAccount"}, # Test + Affected class
1566
+ {"Id": "01p000004", "Name": "ContactTest"}, # Not affected
1567
+ ],
1568
+ }
1569
+
1570
+ filtered = task._filter_test_classes_to_delta_changes(mock_classes)
1571
+
1572
+ # Should return only affected test classes
1573
+ assert len(filtered) == 3
1574
+ filtered_names = [record["Name"] for record in filtered]
1575
+ assert "Account" in filtered_names
1576
+ assert "AccountTest" in filtered_names
1577
+ assert "TestAccount" in filtered_names
1578
+ assert "ContactTest" not in filtered_names
1579
+ # Verify ListModifiedFiles was instantiated
1580
+ mock_list_modified_files.assert_called_once()
1581
+ # Verify logging for excluded classes
1582
+ log = self._task_log_handler.messages
1583
+ assert any(
1584
+ "Excluded" in msg and "not affected by delta changes" in msg
1585
+ for msg in log["info"]
1586
+ )
1587
+
1588
+ @patch.object(BaseProjectConfig, "get_repo")
1589
+ @patch("cumulusci.tasks.apex.testrunner.ListModifiedFiles")
1590
+ def test_delta_changes_filter__with_custom_base_ref(
1591
+ self, mock_list_modified_files, mock_get_repo
1592
+ ):
1593
+ """Test that delta_changes filter uses custom base_ref when provided"""
1594
+ task_config = TaskConfig(
1595
+ {
1596
+ "options": {
1597
+ "test_name_match": "%_TEST",
1598
+ "dynamic_filter": "delta_changes",
1599
+ "base_ref": "origin/develop",
1600
+ }
1601
+ }
1602
+ )
1603
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1604
+ mock_get_repo.return_value = Mock()
1605
+
1606
+ # Mock ListModifiedFiles task
1607
+ # Create a simple object with return_values attribute
1608
+ class MockTaskInstance:
1609
+ def __init__(self):
1610
+ self.return_values = {
1611
+ "files": ["force-app/main/default/classes/MyClass.cls"],
1612
+ "file_names": {"MyClass"},
1613
+ }
1614
+ self.parsed_options = Mock()
1615
+ self.parsed_options.base_ref = None
1616
+
1617
+ def __call__(self):
1618
+ pass
1619
+
1620
+ mock_list_modified_files.return_value = MockTaskInstance()
1621
+
1622
+ mock_classes = {
1623
+ "totalSize": 1,
1624
+ "done": True,
1625
+ "records": [{"Id": "01p000001", "Name": "MyClassTest"}],
1626
+ }
1627
+
1628
+ filtered = task._filter_test_classes_to_delta_changes(mock_classes)
1629
+
1630
+ # Verify ListModifiedFiles was called with correct base_ref
1631
+ mock_list_modified_files.assert_called_once()
1632
+ call_args = mock_list_modified_files.call_args
1633
+ task_config_arg = call_args[0][1]
1634
+ assert task_config_arg.config["options"]["base_ref"] == "origin/develop"
1635
+ assert len(filtered) == 1
1636
+ # Verify logging for affected classes
1637
+ log = self._task_log_handler.messages
1638
+ assert any("Found" in msg and "affected class" in msg for msg in log["info"])
1639
+
1640
+ @patch.object(BaseProjectConfig, "get_repo")
1641
+ @patch("cumulusci.tasks.apex.testrunner.ListModifiedFiles")
1642
+ def test_delta_changes_filter__default_base_ref_none(
1643
+ self, mock_list_modified_files, mock_get_repo
1644
+ ):
1645
+ """Test that delta_changes filter uses None base_ref when not provided (default branch)"""
1646
+ task_config = TaskConfig(
1647
+ {
1648
+ "options": {
1649
+ "test_name_match": "%_TEST",
1650
+ "dynamic_filter": "delta_changes",
1651
+ # base_ref not provided, should be None
1652
+ }
1653
+ }
1654
+ )
1655
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1656
+ mock_get_repo.return_value = Mock()
1657
+
1658
+ # Mock ListModifiedFiles task to return None files (to test warning message)
1659
+ # Create a simple object with return_values attribute
1660
+ class MockTaskInstance:
1661
+ def __init__(self):
1662
+ self.return_values = {"files": set(), "file_names": set()}
1663
+ self.parsed_options = Mock()
1664
+ self.parsed_options.base_ref = None
1665
+
1666
+ def __call__(self):
1667
+ pass
1668
+
1669
+ mock_list_modified_files.return_value = MockTaskInstance()
1670
+
1671
+ mock_classes = {
1672
+ "totalSize": 1,
1673
+ "done": True,
1674
+ "records": [{"Id": "01p000001", "Name": "TestClass1"}],
1675
+ }
1676
+
1677
+ task._filter_test_classes_to_delta_changes(mock_classes)
1678
+
1679
+ # Verify ListModifiedFiles was called with None base_ref
1680
+ mock_list_modified_files.assert_called_once()
1681
+ call_args = mock_list_modified_files.call_args
1682
+ task_config_arg = call_args[0][1]
1683
+ assert task_config_arg.config["options"]["base_ref"] is None
1684
+ # Note: Warning about "default branch" would come from ListModifiedFiles
1685
+ # implementation, but since we're mocking it, that warning won't appear here
1686
+
1687
+ def test_is_test_class_affected__direct_match(self):
1688
+ """Test _is_test_class_affected with direct match"""
1689
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1690
+ affected_class_names = ["account", "contact"]
1691
+
1692
+ assert task._is_test_class_affected("account", affected_class_names) is True
1693
+ assert task._is_test_class_affected("contact", affected_class_names) is True
1694
+ assert (
1695
+ task._is_test_class_affected("opportunity", affected_class_names) is False
1696
+ )
1697
+
1698
+ def test_is_test_class_affected__suffix_test_pattern(self):
1699
+ """Test _is_test_class_affected with ClassNameTest pattern"""
1700
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1701
+ affected_class_names = ["account", "myservice"]
1702
+
1703
+ assert task._is_test_class_affected("accounttest", affected_class_names) is True
1704
+ assert (
1705
+ task._is_test_class_affected("myservicetest", affected_class_names) is True
1706
+ )
1707
+ assert (
1708
+ task._is_test_class_affected("contacttest", affected_class_names) is False
1709
+ )
1710
+
1711
+ def test_is_test_class_affected__prefix_test_pattern(self):
1712
+ """Test _is_test_class_affected with TestClassName pattern"""
1713
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1714
+ affected_class_names = ["account", "handler"]
1715
+
1716
+ assert task._is_test_class_affected("testaccount", affected_class_names) is True
1717
+ assert task._is_test_class_affected("testhandler", affected_class_names) is True
1718
+ assert (
1719
+ task._is_test_class_affected("testcontact", affected_class_names) is False
1720
+ )
1721
+
1722
+ def test_is_test_class_affected__underscore_suffix_pattern(self):
1723
+ """Test _is_test_class_affected with ClassName_* pattern"""
1724
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1725
+ affected_class_names = ["account", "service"]
1726
+
1727
+ assert (
1728
+ task._is_test_class_affected("account_test", affected_class_names) is True
1729
+ )
1730
+ assert (
1731
+ task._is_test_class_affected("account_test_method", affected_class_names)
1732
+ is True
1733
+ )
1734
+ assert (
1735
+ task._is_test_class_affected("service_integration", affected_class_names)
1736
+ is True
1737
+ )
1738
+ assert (
1739
+ task._is_test_class_affected("contact_test", affected_class_names) is False
1740
+ )
1741
+
1742
+ def test_is_test_class_affected__test_underscore_prefix_pattern(self):
1743
+ """Test _is_test_class_affected with TestClassName_* pattern"""
1744
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1745
+ affected_class_names = ["account", "handler"]
1746
+
1747
+ assert (
1748
+ task._is_test_class_affected("testaccount_unit", affected_class_names)
1749
+ is True
1750
+ )
1751
+ assert (
1752
+ task._is_test_class_affected(
1753
+ "testhandler_integration", affected_class_names
1754
+ )
1755
+ is True
1756
+ )
1757
+ assert (
1758
+ task._is_test_class_affected("testcontact_unit", affected_class_names)
1759
+ is False
1760
+ )
1761
+
1762
+ def test_is_test_class_affected__case_insensitive(self):
1763
+ """Test _is_test_class_affected works with lowercase inputs (as called in practice)"""
1764
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1765
+ # Method is called with lowercase strings in practice
1766
+ affected_class_names = ["account", "myservice"]
1767
+
1768
+ assert task._is_test_class_affected("accounttest", affected_class_names) is True
1769
+ assert (
1770
+ task._is_test_class_affected("myservicetest", affected_class_names) is True
1771
+ )
1772
+ assert (
1773
+ task._is_test_class_affected("contacttest", affected_class_names) is False
1774
+ )
1775
+
1776
+ def test_is_test_class_affected__multiple_patterns(self):
1777
+ """Test _is_test_class_affected with multiple affected classes and patterns"""
1778
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1779
+ affected_class_names = ["account", "contact", "handler"]
1780
+
1781
+ # Test various patterns with multiple affected classes
1782
+ assert task._is_test_class_affected("accounttest", affected_class_names) is True
1783
+ assert task._is_test_class_affected("testcontact", affected_class_names) is True
1784
+ assert (
1785
+ task._is_test_class_affected("handler_integration", affected_class_names)
1786
+ is True
1787
+ )
1788
+ assert (
1789
+ task._is_test_class_affected("testaccount_unit", affected_class_names)
1790
+ is True
1791
+ )
1792
+ assert (
1793
+ task._is_test_class_affected("opportunitytest", affected_class_names)
1794
+ is False
1795
+ )
1796
+
1797
+ def test_is_test_class_affected__underscore_removal_pattern(self):
1798
+ """Test _is_test_class_affected with underscore removal (e.g., Account_Handler -> AccountHandlerTest)"""
1799
+ task = RunApexTests(self.project_config, self.task_config, self.org_config)
1800
+ affected_class_names = ["account_handler", "my_service_class"]
1801
+
1802
+ # Test that AccountHandlerTest matches account_handler
1803
+ assert (
1804
+ task._is_test_class_affected("accounthandlertest", affected_class_names)
1805
+ is True
1806
+ )
1807
+ # Test that MyServiceClassTest matches my_service_class
1808
+ assert (
1809
+ task._is_test_class_affected("myserviceclasstest", affected_class_names)
1810
+ is True
1811
+ )
1812
+ # Test no match
1813
+ assert (
1814
+ task._is_test_class_affected("contacttest", affected_class_names) is False
1815
+ )
1816
+
1817
+ def test_filter_package_classes__unsupported_dynamic_filter(self):
1818
+ """Test that unsupported dynamic_filter values raise TaskOptionsError"""
1819
+ task_config = TaskConfig(
1820
+ {
1821
+ "options": {
1822
+ "test_name_match": "%_TEST",
1823
+ "dynamic_filter": "unsupported_filter",
1824
+ }
1825
+ }
1826
+ )
1827
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1828
+
1829
+ mock_classes = {
1830
+ "totalSize": 1,
1831
+ "done": True,
1832
+ "records": [{"Id": "01p000001", "Name": "TestClass1"}],
1833
+ }
1834
+
1835
+ with pytest.raises(TaskOptionsError) as e:
1836
+ task._filter_package_classes(mock_classes)
1837
+ assert "Unsupported dynamic filter: unsupported_filter" in str(e.value)
1838
+
1839
+ @patch.object(BaseProjectConfig, "get_repo")
1840
+ @patch("cumulusci.tasks.apex.testrunner.ListModifiedFiles")
1841
+ @responses.activate
1842
+ def test_run_task__delta_changes_filter_integration(
1843
+ self, mock_list_modified_files, mock_get_repo
1844
+ ):
1845
+ """Integration test for delta_changes filter in full test run"""
1846
+ self._mock_api_version_discovery()
1847
+ self._mock_get_installpkg_results()
1848
+
1849
+ # Mock ListModifiedFiles task - it's instantiated and then called
1850
+ # Create a simple object with return_values attribute
1851
+ class MockTaskInstance:
1852
+ def __init__(self):
1853
+ self.return_values = {
1854
+ "files": ["force-app/main/default/classes/Account.cls"],
1855
+ "file_names": {"Account"},
1856
+ }
1857
+ self.parsed_options = Mock()
1858
+ self.parsed_options.base_ref = None
1859
+
1860
+ def __call__(self):
1861
+ pass
1862
+
1863
+ mock_list_modified_files.return_value = MockTaskInstance()
1864
+ mock_get_repo.return_value = Mock()
1865
+
1866
+ # Mock ApexClass query to return test classes
1867
+ self._mock_apex_class_query(name="AccountTest")
1868
+ self._mock_run_tests()
1869
+ self._mock_get_failed_test_classes()
1870
+ self._mock_tests_complete()
1871
+ self._mock_get_test_results()
1872
+
1873
+ task_config = TaskConfig(
1874
+ {
1875
+ "options": {
1876
+ "test_name_match": "%_TEST",
1877
+ "dynamic_filter": "delta_changes",
1878
+ "junit_output": "results_junit.xml",
1879
+ "poll_interval": 1,
1880
+ }
1881
+ }
1882
+ )
1883
+ task = RunApexTests(self.project_config, task_config, self.org_config)
1884
+
1885
+ task()
1886
+
1887
+ # Verify ListModifiedFiles was instantiated
1888
+ mock_list_modified_files.assert_called_once()
1889
+
974
1890
 
975
1891
  @patch(
976
1892
  "cumulusci.core.tasks.BaseSalesforceTask._update_credentials",