cumulusci-plus 5.0.21__py3-none-any.whl → 5.0.43__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 (135) 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 +19 -3
  5. cumulusci/cli/tests/test_error.py +3 -1
  6. cumulusci/cli/tests/test_flow.py +279 -2
  7. cumulusci/cli/tests/test_org.py +5 -0
  8. cumulusci/cli/tests/test_service.py +15 -12
  9. cumulusci/cli/tests/test_task.py +122 -2
  10. cumulusci/cli/tests/utils.py +1 -4
  11. cumulusci/core/config/__init__.py +1 -0
  12. cumulusci/core/config/base_task_flow_config.py +26 -1
  13. cumulusci/core/config/org_config.py +2 -1
  14. cumulusci/core/config/project_config.py +14 -20
  15. cumulusci/core/config/scratch_org_config.py +12 -0
  16. cumulusci/core/config/tests/test_config.py +1 -0
  17. cumulusci/core/config/tests/test_config_expensive.py +9 -3
  18. cumulusci/core/config/universal_config.py +3 -4
  19. cumulusci/core/dependencies/base.py +5 -1
  20. cumulusci/core/dependencies/dependencies.py +1 -1
  21. cumulusci/core/dependencies/github.py +1 -2
  22. cumulusci/core/dependencies/resolvers.py +1 -1
  23. cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
  24. cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
  25. cumulusci/core/flowrunner.py +90 -6
  26. cumulusci/core/github.py +1 -1
  27. cumulusci/core/sfdx.py +3 -1
  28. cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
  29. cumulusci/core/source_transforms/transforms.py +1 -1
  30. cumulusci/core/tasks.py +13 -2
  31. cumulusci/core/tests/test_flowrunner.py +100 -0
  32. cumulusci/core/tests/test_tasks.py +65 -0
  33. cumulusci/core/utils.py +3 -1
  34. cumulusci/core/versions.py +1 -1
  35. cumulusci/cumulusci.yml +73 -1
  36. cumulusci/oauth/client.py +1 -1
  37. cumulusci/plugins/plugin_base.py +5 -3
  38. cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
  39. cumulusci/salesforce_api/rest_deploy.py +1 -1
  40. cumulusci/schema/cumulusci.jsonschema.json +69 -0
  41. cumulusci/tasks/apex/anon.py +1 -1
  42. cumulusci/tasks/apex/testrunner.py +421 -144
  43. cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
  44. cumulusci/tasks/bulkdata/extract.py +0 -1
  45. cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
  46. cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
  47. cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
  48. cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
  49. cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
  50. cumulusci/tasks/bulkdata/select_utils.py +1 -1
  51. cumulusci/tasks/bulkdata/snowfakery.py +100 -25
  52. cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
  53. cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
  54. cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
  55. cumulusci/tasks/bulkdata/tests/test_select_utils.py +46 -0
  56. cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
  57. cumulusci/tasks/create_package_version.py +190 -16
  58. cumulusci/tasks/datadictionary.py +1 -1
  59. cumulusci/tasks/metadata_etl/__init__.py +2 -0
  60. cumulusci/tasks/metadata_etl/applications.py +256 -0
  61. cumulusci/tasks/metadata_etl/base.py +7 -3
  62. cumulusci/tasks/metadata_etl/layouts.py +1 -1
  63. cumulusci/tasks/metadata_etl/permissions.py +1 -1
  64. cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
  65. cumulusci/tasks/metadata_etl/tests/test_applications.py +710 -0
  66. cumulusci/tasks/push/README.md +15 -17
  67. cumulusci/tasks/release_notes/README.md +13 -13
  68. cumulusci/tasks/release_notes/generator.py +13 -8
  69. cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
  70. cumulusci/tasks/salesforce/Deploy.py +53 -2
  71. cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
  72. cumulusci/tasks/salesforce/__init__.py +1 -0
  73. cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
  74. cumulusci/tasks/salesforce/composite.py +1 -1
  75. cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
  76. cumulusci/tasks/salesforce/enable_prediction.py +5 -1
  77. cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
  78. cumulusci/tasks/salesforce/insert_record.py +18 -19
  79. cumulusci/tasks/salesforce/sourcetracking.py +1 -1
  80. cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
  81. cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
  82. cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
  83. cumulusci/tasks/salesforce/tests/test_enable_prediction.py +4 -2
  84. cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
  85. cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
  86. cumulusci/tasks/salesforce/tests/test_update_external_auth_identity_provider.py +927 -0
  87. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +1427 -0
  88. cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
  89. cumulusci/tasks/salesforce/tests/test_update_record.py +512 -0
  90. cumulusci/tasks/salesforce/update_dependencies.py +2 -2
  91. cumulusci/tasks/salesforce/update_external_auth_identity_provider.py +551 -0
  92. cumulusci/tasks/salesforce/update_external_credential.py +647 -0
  93. cumulusci/tasks/salesforce/update_named_credential.py +441 -0
  94. cumulusci/tasks/salesforce/update_profile.py +17 -13
  95. cumulusci/tasks/salesforce/update_record.py +217 -0
  96. cumulusci/tasks/salesforce/users/permsets.py +62 -5
  97. cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
  98. cumulusci/tasks/sfdmu/__init__.py +0 -0
  99. cumulusci/tasks/sfdmu/sfdmu.py +376 -0
  100. cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
  101. cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
  102. cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
  103. cumulusci/tasks/tests/test_create_package_version.py +716 -1
  104. cumulusci/tasks/tests/test_util.py +42 -0
  105. cumulusci/tasks/util.py +37 -1
  106. cumulusci/tasks/utility/copyContents.py +402 -0
  107. cumulusci/tasks/utility/credentialManager.py +302 -0
  108. cumulusci/tasks/utility/directoryRecreator.py +30 -0
  109. cumulusci/tasks/utility/env_management.py +1 -1
  110. cumulusci/tasks/utility/secretsToEnv.py +135 -0
  111. cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
  112. cumulusci/tasks/utility/tests/test_credentialManager.py +1150 -0
  113. cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
  114. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1118 -0
  115. cumulusci/tests/test_integration_infrastructure.py +3 -1
  116. cumulusci/tests/test_utils.py +70 -6
  117. cumulusci/utils/__init__.py +54 -9
  118. cumulusci/utils/classutils.py +5 -2
  119. cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
  120. cumulusci/utils/options.py +23 -1
  121. cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
  122. cumulusci/utils/yaml/cumulusci_yml.py +8 -3
  123. cumulusci/utils/yaml/model_parser.py +2 -2
  124. cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
  125. cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
  126. cumulusci/vcs/base.py +23 -15
  127. cumulusci/vcs/bootstrap.py +5 -4
  128. cumulusci/vcs/utils/list_modified_files.py +189 -0
  129. cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
  130. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/METADATA +11 -10
  131. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/RECORD +135 -104
  132. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/WHEEL +1 -1
  133. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/entry_points.txt +0 -0
  134. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/AUTHORS.rst +0 -0
  135. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/LICENSE +0 -0
@@ -3,16 +3,27 @@
3
3
  import html
4
4
  import io
5
5
  import json
6
+ import os
6
7
  import re
8
+ from typing import Dict, List, Optional
7
9
 
10
+ from cumulusci.core.config import TaskConfig
8
11
  from cumulusci.core.exceptions import (
9
12
  ApexTestException,
10
13
  CumulusCIException,
11
14
  TaskOptionsError,
12
15
  )
13
- from cumulusci.core.utils import decode_to_unicode, process_bool_arg, process_list_arg, determine_managed_mode
16
+ from cumulusci.core.utils import decode_to_unicode, determine_managed_mode
14
17
  from cumulusci.tasks.salesforce import BaseSalesforceApiTask
15
18
  from cumulusci.utils.http.requests_utils import safe_json_from_response
19
+ from cumulusci.utils.options import (
20
+ CCIOptions,
21
+ Field,
22
+ ListOfStringsOption,
23
+ MappingOption,
24
+ PercentageOption,
25
+ )
26
+ from cumulusci.vcs.utils.list_modified_files import ListModifiedFiles
16
27
 
17
28
  APEX_LIMITS = {
18
29
  "Soql": {
@@ -78,6 +89,46 @@ WHERE AsyncApexJobId='{}'
78
89
  """
79
90
 
80
91
 
92
+ class MappingIntOption(MappingOption):
93
+ """Parses a Mapping of Str->Int from a string in format a:b,c:d"""
94
+
95
+ @classmethod
96
+ def from_str(cls, v) -> Dict[str, int]:
97
+ """Validate and convert a value.
98
+ If its a string, parse it, else, just validate it.
99
+ """
100
+ try:
101
+ v = {
102
+ key: PercentageOption.from_str(value)
103
+ for key, value in super().from_str(v).items()
104
+ }
105
+ except ValueError:
106
+ raise TaskOptionsError(
107
+ "Value should be a percentage or integer (e.g. 90% or 90)"
108
+ )
109
+ return v
110
+
111
+
112
+ class ListOfRegexPatternsOption(ListOfStringsOption):
113
+ @classmethod
114
+ def validate(cls, v):
115
+ """Validate and convert a value.
116
+ If its a string, parse it, else, just validate it.
117
+ """
118
+ regex_patterns: List[re.Pattern] = []
119
+ for regex in v:
120
+ try:
121
+ regex_patterns.append(re.compile(regex))
122
+ except re.error as e:
123
+ raise TaskOptionsError(
124
+ "An invalid regular expression ({}) was provided ({})".format(
125
+ regex, e
126
+ )
127
+ )
128
+ v = regex_patterns
129
+ return v
130
+
131
+
81
132
  class RunApexTests(BaseSalesforceApiTask):
82
133
  """Task to run Apex tests with the Tooling API and report results.
83
134
 
@@ -112,141 +163,144 @@ class RunApexTests(BaseSalesforceApiTask):
112
163
 
113
164
  api_version = "38.0"
114
165
  name = "RunApexTests"
115
- task_options = {
116
- "test_name_match": {
117
- "description": (
166
+
167
+ class Options(CCIOptions):
168
+ test_name_match: Optional[ListOfStringsOption] = Field(
169
+ None,
170
+ description=(
118
171
  "Pattern to find Apex test classes to run "
119
172
  '("%" is wildcard). Defaults to '
120
173
  "project__test__name_match from project config. "
121
174
  "Comma-separated list for multiple patterns."
122
175
  ),
123
- },
124
- "test_name_exclude": {
125
- "description": (
176
+ )
177
+ test_name_exclude: Optional[ListOfStringsOption] = Field(
178
+ None,
179
+ description=(
126
180
  "Query to find Apex test classes to exclude "
127
181
  '("%" is wildcard). Defaults to '
128
182
  "project__test__name_exclude from project config. "
129
183
  "Comma-separated list for multiple patterns."
130
- )
131
- },
132
- "namespace": {
133
- "description": (
184
+ ),
185
+ )
186
+ namespace: Optional[str] = Field(
187
+ None,
188
+ description=(
134
189
  "Salesforce project namespace. Defaults to "
135
- + "project__package__namespace"
136
- )
137
- },
138
- "managed": {
139
- "description": (
140
- "If True, search for tests in the namespace "
141
- + "only. Defaults to False"
142
- )
143
- },
144
- "poll_interval": {
145
- "description": ("Seconds to wait between polling for Apex test results.")
146
- },
147
- "junit_output": {
148
- "description": "File name for JUnit output. Defaults to test_results.xml"
149
- },
150
- "json_output": {
151
- "description": "File name for json output. Defaults to test_results.json"
152
- },
153
- "retry_failures": {
154
- "description": "A list of regular expression patterns to match against "
190
+ "project__package__namespace"
191
+ ),
192
+ )
193
+ managed: bool = Field(
194
+ False,
195
+ description=(
196
+ "If True, search for tests in the namespace " "only. Defaults to False"
197
+ ),
198
+ )
199
+ poll_interval: int = Field(
200
+ 1,
201
+ description="Seconds to wait between polling for Apex test results.",
202
+ )
203
+ junit_output: Optional[str] = Field(
204
+ "test_results.xml",
205
+ description="File name for JUnit output. Defaults to test_results.xml",
206
+ )
207
+ json_output: Optional[str] = Field(
208
+ "test_results.json",
209
+ description="File name for json output. Defaults to test_results.json",
210
+ )
211
+ retry_failures: ListOfRegexPatternsOption = Field(
212
+ [],
213
+ description="A list of regular expression patterns to match against "
155
214
  "test failures. If failures match, the failing tests are retried in "
156
- "serial mode."
157
- },
158
- "retry_always": {
159
- "description": "By default, all failures must match retry_failures to perform "
160
- "a retry. Set retry_always to True to retry all failed tests if any failure matches."
161
- },
162
- "required_org_code_coverage_percent": {
163
- "description": "Require at least X percent code coverage across the org following the test run.",
164
- "usage": "--required_org_code_coverage_percent PERCENTAGE",
165
- },
166
- "required_per_class_code_coverage_percent": {
167
- "description": "Require at least X percent code coverage for every class in the org.",
168
- },
169
- "verbose": {
170
- "description": "By default, only failures get detailed output. "
171
- "Set verbose to True to see all passed test methods."
172
- },
173
- "test_suite_names": {
174
- "description": "Accepts a comma-separated list of test suite names. Only runs test classes that are part of the test suites specified."
175
- },
176
- }
177
-
178
- def _init_options(self, kwargs):
179
- super(RunApexTests, self)._init_options(kwargs)
180
-
181
- self.options["test_name_match"] = self.options.get(
182
- "test_name_match", self.project_config.project__test__name_match
215
+ "serial mode.",
183
216
  )
184
- self.options["test_name_exclude"] = self.options.get(
185
- "test_name_exclude", self.project_config.project__test__name_exclude
217
+ retry_always: bool = Field(
218
+ False,
219
+ description="By default, all failures must match retry_failures to perform "
220
+ "a retry. Set retry_always to True to retry all failed tests if any failure matches.",
186
221
  )
187
-
188
- self.options["test_suite_names"] = self.options.get(
189
- "test_suite_names", self.project_config.project__test__suite__names
222
+ required_org_code_coverage_percent: PercentageOption = Field(
223
+ 0,
224
+ description="Require at least X percent code coverage across the org following the test run.",
190
225
  )
191
- if self.options["test_name_match"] is None:
192
- self.options["test_name_match"] = ""
193
-
194
- if self.options["test_name_exclude"] is None:
195
- self.options["test_name_exclude"] = ""
196
-
197
- self.options["namespace"] = self.options.get(
198
- "namespace", self.project_config.project__package__namespace
226
+ required_per_class_code_coverage_percent: PercentageOption = Field(
227
+ 0,
228
+ description="Require at least X percent code coverage for every class in the org.",
199
229
  )
200
-
201
- self.options["junit_output"] = self.options.get(
202
- "junit_output", "test_results.xml"
230
+ required_individual_class_code_coverage_percent: MappingIntOption = Field(
231
+ {},
232
+ description="Mapping of class names to their minimum coverage percentage requirements. "
233
+ "Takes priority over required_per_class_code_coverage_percent for specified classes.",
203
234
  )
204
-
205
- self.options["json_output"] = self.options.get(
206
- "json_output", "test_results.json"
235
+ verbose: bool = Field(
236
+ False,
237
+ description="By default, only failures get detailed output. "
238
+ "Set verbose to True to see all passed test methods.",
207
239
  )
208
-
209
- self.options["retry_failures"] = process_list_arg(
210
- self.options.get("retry_failures", [])
240
+ test_suite_names: ListOfStringsOption = Field(
241
+ [],
242
+ description="List of test suite names. Only runs test classes that are part of the test suites specified.",
211
243
  )
212
- compiled_res = []
213
- for regex in self.options["retry_failures"]:
214
- try:
215
- compiled_res.append(re.compile(regex))
216
- except re.error as e:
217
- raise TaskOptionsError(
218
- "An invalid regular expression ({}) was provided ({})".format(
219
- regex, e
220
- )
221
- )
222
- self.options["retry_failures"] = compiled_res
223
- self.options["retry_always"] = process_bool_arg(
224
- self.options.get("retry_always") or False
244
+ dynamic_filter: Optional[str] = Field(
245
+ None,
246
+ description="Defines a dynamic filter to apply to test classes from the org that match test_name_match. Supported values: "
247
+ "'package_only' - only runs test classes that exist in the default package directory (force-app/ or src/),"
248
+ "'delta_changes' - only runs test classes that are affected by the delta changes in the current branch (force-app/ or src/),"
249
+ "Default is None, which means no dynamic filter is applied and all test classes from the org that match test_name_match are run."
250
+ "Setting this option, the org code coverage will not be calculated.",
251
+ )
252
+ base_ref: Optional[str] = Field(
253
+ None,
254
+ description="Git reference (branch, tag, or commit) to compare against for delta changes. "
255
+ "If not set, uses the default branch of the repository. Only used when dynamic_filter is 'delta_changes'.",
225
256
  )
226
257
 
227
- self.verbose = process_bool_arg(self.options.get("verbose") or False)
258
+ parsed_options: Options
259
+
260
+ def _init_options(self, kwargs):
261
+ super(RunApexTests, self)._init_options(kwargs)
262
+
263
+ # Set defaults from project config
264
+ if self.parsed_options.test_name_match is None:
265
+ self.parsed_options.test_name_match = ListOfStringsOption.from_str(
266
+ self.project_config.project__test__name_match
267
+ )
268
+ if self.parsed_options.test_name_exclude is None:
269
+ self.parsed_options.test_name_exclude = ListOfStringsOption.from_str(
270
+ self.project_config.project__test__name_exclude
271
+ )
272
+ if self.parsed_options.test_suite_names is None:
273
+ self.parsed_options.test_suite_names = ListOfStringsOption.from_str(
274
+ self.project_config.project__test__suite__names
275
+ )
276
+ if self.parsed_options.namespace is None:
277
+ self.parsed_options.namespace = (
278
+ self.project_config.project__package__namespace
279
+ )
228
280
 
281
+ self.verbose = self.parsed_options.verbose
229
282
  self.counts = {}
230
283
 
231
- if "required_org_code_coverage_percent" in self.options:
232
- try:
233
- self.code_coverage_level = int(
234
- str(self.options["required_org_code_coverage_percent"]).rstrip("%")
235
- )
236
- except ValueError:
237
- raise TaskOptionsError(
238
- f"Invalid code coverage level {self.options['required_org_code_coverage_percent']}"
239
- )
240
- else:
241
- self.code_coverage_level = 0
284
+ self.code_coverage_level = (
285
+ self.parsed_options.required_org_code_coverage_percent
286
+ )
287
+
288
+ self.required_per_class_code_coverage_percent = (
289
+ self.parsed_options.required_per_class_code_coverage_percent
290
+ )
242
291
 
243
- self.required_per_class_code_coverage_percent = int(
244
- self.options.get("required_per_class_code_coverage_percent", 0)
292
+ # Parse individual class coverage requirements
293
+ # Validator already converted values to int, so just use it directly
294
+ self.required_individual_class_code_coverage_percent = (
295
+ self.parsed_options.required_individual_class_code_coverage_percent
245
296
  )
297
+
246
298
  # Raises a TaskOptionsError when the user provides both test_suite_names and test_name_match.
247
- if (self.options["test_suite_names"]) and (
248
- self.options["test_name_match"] is not None
249
- and self.options["test_name_match"] != "%_TEST%"
299
+ if self.parsed_options.test_suite_names and not (
300
+ any(
301
+ pattern in ["%_TEST%", "%TEST%"]
302
+ for pattern in self.parsed_options.test_name_match
303
+ )
250
304
  ):
251
305
  raise TaskOptionsError(
252
306
  "Both test_suite_names and test_name_match cannot be passed simultaneously"
@@ -263,9 +317,9 @@ class RunApexTests(BaseSalesforceApiTask):
263
317
 
264
318
  def _get_namespace_filter(self):
265
319
 
266
- if self.options.get("managed"):
320
+ if self.parsed_options.managed:
267
321
 
268
- namespace = self.options.get("namespace")
322
+ namespace = self.parsed_options.namespace
269
323
 
270
324
  if not namespace:
271
325
  raise TaskOptionsError(
@@ -282,15 +336,13 @@ class RunApexTests(BaseSalesforceApiTask):
282
336
  def _get_test_class_query(self):
283
337
  namespace = self._get_namespace_filter()
284
338
  # Split by commas to allow multiple class name matching options
285
- test_name_match = self.options["test_name_match"]
286
339
  included_tests = []
287
- for pattern in test_name_match.split(","):
340
+ for pattern in self.parsed_options.test_name_match:
288
341
  if pattern:
289
342
  included_tests.append("Name LIKE '{}'".format(pattern))
290
343
  # Add any excludes to the where clause
291
- test_name_exclude = self.options.get("test_name_exclude", "")
292
344
  excluded_tests = []
293
- for pattern in test_name_exclude.split(","):
345
+ for pattern in self.parsed_options.test_name_exclude:
294
346
  if pattern:
295
347
  excluded_tests.append("(NOT Name LIKE '{}')".format(pattern))
296
348
  # Get all test classes for namespace
@@ -306,7 +358,7 @@ class RunApexTests(BaseSalesforceApiTask):
306
358
 
307
359
  def _get_test_classes(self):
308
360
  # If test_suite_names is provided, execute only tests that are a part of the list of test suites provided.
309
- if self.options["test_suite_names"]:
361
+ if self.parsed_options.test_suite_names:
310
362
  test_classes_from_test_suite_names = (
311
363
  self._get_test_classes_from_test_suite_names()
312
364
  )
@@ -345,13 +397,12 @@ class RunApexTests(BaseSalesforceApiTask):
345
397
  if len(testSuiteIds_formatted) == 0:
346
398
  testSuiteIds_formatted = "''"
347
399
 
348
- test_name_exclude_arg = self.options["test_name_exclude"]
349
400
  condition = ""
350
401
 
351
402
  # Check if test_name_exclude is provided. Append to query string if the former is specified.
352
- if test_name_exclude_arg:
403
+ if self.parsed_options.test_name_exclude:
353
404
  test_name_exclude = self._get_comma_separated_string_of_items(
354
- test_name_exclude_arg.split(",")
405
+ self.parsed_options.test_name_exclude
355
406
  )
356
407
  condition = f"AND Name NOT IN ({test_name_exclude})"
357
408
 
@@ -360,9 +411,8 @@ class RunApexTests(BaseSalesforceApiTask):
360
411
 
361
412
  def _get_test_classes_from_test_suite_names(self):
362
413
  # Returns a list of Apex test classes that belong to the test suite(s) specified. Test classes specified in test_name_exclude are excluded.
363
- test_suite_names_arg = self.options["test_suite_names"]
364
414
  query1 = self._get_test_suite_ids_from_test_suite_names_query(
365
- test_suite_names_arg
415
+ self.parsed_options.test_suite_names
366
416
  )
367
417
  self.logger.info("Fetching test suite metadata...")
368
418
  result = self.tooling.query_all(query1)
@@ -377,6 +427,187 @@ class RunApexTests(BaseSalesforceApiTask):
377
427
  self.logger.info("Found {} test classes".format(result["totalSize"]))
378
428
  return result
379
429
 
430
+ def _class_exists_in_package(self, class_name):
431
+ """Check if an Apex class exists in the default package directory."""
432
+ package_path = self.project_config.default_package_path
433
+
434
+ # Walk through the package directory to find .cls files
435
+ for root, dirs, files in os.walk(package_path):
436
+ for file in files:
437
+ if file.endswith(".cls"):
438
+ # Extract class name from filename (remove .cls extension)
439
+ file_class_name = file[:-4]
440
+ if file_class_name == class_name:
441
+ return True
442
+ return False
443
+
444
+ def _filter_package_classes(self, test_classes):
445
+ """Filter test classes to only include those that exist in the package directory."""
446
+ if self.parsed_options.dynamic_filter is None:
447
+ return test_classes
448
+
449
+ filtered_records = []
450
+ match self.parsed_options.dynamic_filter:
451
+ case "package_only":
452
+ filtered_records = self._filter_test_classes_to_package_only(
453
+ test_classes
454
+ )
455
+ case "delta_changes":
456
+ filtered_records = self._filter_test_classes_to_delta_changes(
457
+ test_classes
458
+ )
459
+ case _:
460
+ raise TaskOptionsError(
461
+ f"Unsupported dynamic filter: {self.parsed_options.dynamic_filter}"
462
+ )
463
+
464
+ # Update the result with filtered records
465
+ filtered_result = {
466
+ "totalSize": len(filtered_records),
467
+ "records": filtered_records,
468
+ "done": test_classes.get("done", True),
469
+ }
470
+
471
+ return filtered_result
472
+
473
+ def _filter_test_classes_to_package_only(self, test_classes):
474
+ """Filter test classes to only include those that exist in the package directory."""
475
+ filtered_records = []
476
+ excluded_count = 0
477
+
478
+ for record in test_classes["records"]:
479
+ class_name = record["Name"]
480
+ if self._class_exists_in_package(class_name):
481
+ filtered_records.append(record)
482
+ else:
483
+ excluded_count += 1
484
+ self.logger.debug(
485
+ f"Excluding test class '{class_name}' - not found in package directory"
486
+ )
487
+
488
+ if excluded_count > 0:
489
+ self.logger.info(
490
+ f"Excluded {excluded_count} test class(es) not in package directory"
491
+ )
492
+ return filtered_records
493
+
494
+ def _filter_test_classes_to_delta_changes(self, test_classes):
495
+ """Filter test classes to only include those that are affected by the delta changes in the current branch."""
496
+ # Check if the current base folder has git. (project_config.repo)
497
+ if self.project_config.get_repo() is None:
498
+ self.logger.info("No git repository found. Returning all test classes.")
499
+ return test_classes["records"]
500
+
501
+ self.logger.info("")
502
+ self.logger.info("Getting the list of committed files in the current branch.")
503
+ # Get the list of modified files in the current branch.
504
+ task = ListModifiedFiles(
505
+ self.project_config,
506
+ TaskConfig(
507
+ {
508
+ "options": {
509
+ "base_ref": self.parsed_options.base_ref,
510
+ "file_extensions": ["cls", "flow-meta.xml", "trigger"],
511
+ "directories": ["force-app", "src"],
512
+ }
513
+ }
514
+ ),
515
+ org_config=None,
516
+ )
517
+ task()
518
+
519
+ branch_return_values = task.return_values.copy()
520
+
521
+ # Get the list of modified files which are not yet committed.
522
+ self.logger.info("")
523
+ self.logger.info("Getting the list of uncommitted files in the current branch.")
524
+ task.parsed_options.base_ref = "HEAD"
525
+ task()
526
+ uncommitted_return_values = task.return_values.copy()
527
+
528
+ # Get the list of changed files.
529
+ branch_files = set(branch_return_values.get("files", []))
530
+ uncommitted_files = set(uncommitted_return_values.get("files", []))
531
+ changed_files = branch_files.union(uncommitted_files)
532
+
533
+ if not changed_files:
534
+ self.logger.info(
535
+ "No changed files found in package directories (force-app/ or src/)."
536
+ )
537
+ return []
538
+
539
+ # Extract class names from changed files
540
+ affected_class_names = branch_return_values.get("file_names", set()).union(
541
+ uncommitted_return_values.get("file_names", set())
542
+ )
543
+
544
+ if not affected_class_names:
545
+ self.logger.info("No file names found in changed files.")
546
+ return []
547
+
548
+ self.logger.info(
549
+ f"Found {len(affected_class_names)} affected class(es): {', '.join(sorted(affected_class_names))}"
550
+ )
551
+
552
+ # Filter test classes to only include those affected by the delta changes
553
+ filtered_records = []
554
+ excluded_count = 0
555
+
556
+ affected_class_names_lower = [name.lower() for name in affected_class_names]
557
+
558
+ for record in test_classes["records"]:
559
+ test_class_name = record["Name"]
560
+ if self._is_test_class_affected(
561
+ test_class_name.lower(), affected_class_names_lower
562
+ ):
563
+ filtered_records.append(record)
564
+ else:
565
+ excluded_count += 1
566
+ self.logger.debug(
567
+ f"Excluding test class '{test_class_name}' - not affected by delta changes"
568
+ )
569
+
570
+ if excluded_count > 0:
571
+ self.logger.info(
572
+ f"Excluded {excluded_count} test class(es) not affected by delta changes"
573
+ )
574
+
575
+ if not filtered_records:
576
+ self.logger.info(
577
+ "No test classes found that are affected by delta changes."
578
+ )
579
+ return []
580
+
581
+ self.logger.info(
582
+ f"Running {len(filtered_records)} test class(es) that are affected by delta changes. Test classes: {', '.join([record['Name'] for record in filtered_records])}"
583
+ )
584
+
585
+ return filtered_records
586
+
587
+ def _is_test_class_affected(self, test_class_name, affected_class_names):
588
+ """Check if a test class is affected by the changed classes."""
589
+ # Direct match: test class name matches a changed class
590
+ if test_class_name in affected_class_names:
591
+ return True
592
+
593
+ # Check if test class name corresponds to an affected class
594
+ # Common patterns:
595
+ # - Account.cls changed -> AccountTest.cls should run
596
+ # - MyService.cls changed -> MyServiceTest.cls should run
597
+ # - AccountHandler.cls changed -> AccountHandlerTest.cls should run
598
+ for affected_class in affected_class_names:
599
+ # Check if test class name follows common test naming patterns
600
+ if (
601
+ test_class_name == f"{affected_class}test"
602
+ or test_class_name == f"test{affected_class}"
603
+ or test_class_name.startswith(f"{affected_class}_")
604
+ or test_class_name.startswith(f"test{affected_class}_")
605
+ or test_class_name == f"{affected_class.replace('_', '')}test"
606
+ ):
607
+ return True
608
+
609
+ return False
610
+
380
611
  def _get_test_methods_for_class(self, class_name):
381
612
  result = self.tooling.query(
382
613
  f"SELECT SymbolTable FROM ApexClass WHERE Name='{class_name}'"
@@ -399,7 +630,7 @@ class RunApexTests(BaseSalesforceApiTask):
399
630
 
400
631
  def _is_retriable_error_message(self, error_message):
401
632
  return any(
402
- [reg.search(error_message) for reg in self.options["retry_failures"]]
633
+ [reg.search(error_message) for reg in self.parsed_options.retry_failures]
403
634
  )
404
635
 
405
636
  def _is_retriable_failure(self, test_result):
@@ -445,7 +676,7 @@ class RunApexTests(BaseSalesforceApiTask):
445
676
  )
446
677
 
447
678
  # In Spring '20, we cannot get symbol tables for managed classes.
448
- if self.options.get("managed"):
679
+ if self.parsed_options.managed:
449
680
  self.logger.error(
450
681
  f"Cannot access symbol table for managed class {class_name}. Failure will not be retried."
451
682
  )
@@ -484,7 +715,7 @@ class RunApexTests(BaseSalesforceApiTask):
484
715
  # Even if this failure is not retriable per se,
485
716
  # persist its details if we might end up retrying
486
717
  # all failures.
487
- if self.options["retry_always"] or can_retry_this_failure:
718
+ if self.parsed_options.retry_always or can_retry_this_failure:
488
719
  self.retry_details.setdefault(
489
720
  test_result["ApexClassId"], []
490
721
  ).append(test_result["MethodName"])
@@ -609,12 +840,18 @@ class RunApexTests(BaseSalesforceApiTask):
609
840
 
610
841
  def _init_task(self):
611
842
  super()._init_task()
612
- self.options["managed"] = determine_managed_mode(
843
+
844
+ self.parsed_options.managed = determine_managed_mode(
613
845
  self.options, self.project_config, self.org_config
614
846
  )
615
847
 
616
848
  def _run_task(self):
617
849
  result = self._get_test_classes()
850
+
851
+ # Apply dynamic filters if enabled
852
+ if self.parsed_options.dynamic_filter:
853
+ result = self._filter_package_classes(result)
854
+
618
855
  if result["totalSize"] == 0:
619
856
  return
620
857
  for test_class in result["records"]:
@@ -640,7 +877,9 @@ class RunApexTests(BaseSalesforceApiTask):
640
877
  # Did we get back retriable test results? Check our retry policy,
641
878
  # then enqueue new runs individually, until either (a) all retriable
642
879
  # tests succeed or (b) a test fails.
643
- able_to_retry = (self.counts["Retriable"] and self.options["retry_always"]) or (
880
+ able_to_retry = (
881
+ self.counts["Retriable"] and self.parsed_options.retry_always
882
+ ) or (
644
883
  self.counts["Retriable"] and self.counts["Retriable"] == self.counts["Fail"]
645
884
  )
646
885
  if not able_to_retry:
@@ -659,12 +898,14 @@ class RunApexTests(BaseSalesforceApiTask):
659
898
  )
660
899
 
661
900
  if self.code_coverage_level or self.required_per_class_code_coverage_percent:
662
- if self.options.get("namespace") not in self.org_config.installed_packages:
663
- self._check_code_coverage()
664
- else:
901
+ if self.parsed_options.managed:
665
902
  self.logger.info(
666
903
  "This org contains a managed installation; not checking code coverage."
667
904
  )
905
+ elif self.parsed_options.dynamic_filter is not None:
906
+ self.logger.info("Dynamic filter is set; not checking code coverage.")
907
+ else:
908
+ self._check_code_coverage()
668
909
  else:
669
910
  self.logger.info(
670
911
  "No code coverage level specified; not checking code coverage."
@@ -675,13 +916,17 @@ class RunApexTests(BaseSalesforceApiTask):
675
916
  class_level_coverage_failures = {}
676
917
 
677
918
  # Query for Class level code coverage using the aggregate
678
- if self.required_per_class_code_coverage_percent:
919
+ if (
920
+ self.required_per_class_code_coverage_percent
921
+ or self.required_individual_class_code_coverage_percent
922
+ ):
679
923
  test_classes = self.tooling.query(
680
924
  "SELECT ApexClassOrTrigger.Name, ApexClassOrTriggerId, NumLinesCovered, NumLinesUncovered FROM ApexCodeCoverageAggregate ORDER BY ApexClassOrTrigger.Name ASC"
681
925
  )["records"]
682
926
 
683
927
  coverage_percentage = 0
684
928
  for class_level in test_classes:
929
+ class_name = class_level["ApexClassOrTrigger"]["Name"]
685
930
  total = (
686
931
  class_level["NumLinesCovered"] + class_level["NumLinesUncovered"]
687
932
  )
@@ -693,26 +938,58 @@ class RunApexTests(BaseSalesforceApiTask):
693
938
  2,
694
939
  )
695
940
 
696
- if coverage_percentage < self.required_per_class_code_coverage_percent:
697
- class_level_coverage_failures[
698
- class_level["ApexClassOrTrigger"]["Name"]
699
- ] = coverage_percentage
941
+ # Determine the required coverage for this class using fallback logic
942
+ required_coverage = None
943
+ if class_name in self.required_individual_class_code_coverage_percent:
944
+ # Individual class requirement takes priority
945
+ required_coverage = (
946
+ self.required_individual_class_code_coverage_percent[class_name]
947
+ )
948
+ elif self.required_per_class_code_coverage_percent:
949
+ # Fall back to global per-class requirement
950
+ required_coverage = self.required_per_class_code_coverage_percent
951
+
952
+ # Only check if a requirement is defined for this class
953
+ if (
954
+ required_coverage is not None
955
+ and coverage_percentage < required_coverage
956
+ ):
957
+ class_level_coverage_failures[class_name] = {
958
+ "actual": coverage_percentage,
959
+ "required": required_coverage,
960
+ }
700
961
 
701
962
  # Query for OrgWide coverage
702
963
  result = self.tooling.query("SELECT PercentCovered FROM ApexOrgWideCoverage")
703
964
  coverage = result["records"][0]["PercentCovered"]
704
965
 
705
966
  errors = []
706
- if self.required_per_class_code_coverage_percent:
967
+ if (
968
+ self.required_per_class_code_coverage_percent
969
+ or self.required_individual_class_code_coverage_percent
970
+ ):
707
971
  if class_level_coverage_failures:
708
- for class_name in class_level_coverage_failures.keys():
972
+ for class_name, coverage_info in class_level_coverage_failures.items():
709
973
  errors.append(
710
- f"{class_name}'s code coverage of {class_level_coverage_failures[class_name]}% is below required level of {self.required_per_class_code_coverage_percent}."
974
+ f"{class_name}'s code coverage of {coverage_info['actual']}% is below required level of {coverage_info['required']}%."
711
975
  )
712
976
  else:
713
- self.logger.info(
714
- f"All classes meet code coverage expectations of {self.required_per_class_code_coverage_percent}% ."
715
- )
977
+ # Build a message about what requirements were met
978
+ if (
979
+ self.required_per_class_code_coverage_percent
980
+ and self.required_individual_class_code_coverage_percent
981
+ ):
982
+ self.logger.info(
983
+ f"All classes meet code coverage expectations (global: {self.required_per_class_code_coverage_percent}%, individual class requirements also satisfied)."
984
+ )
985
+ elif self.required_per_class_code_coverage_percent:
986
+ self.logger.info(
987
+ f"All classes meet code coverage expectations of {self.required_per_class_code_coverage_percent}%."
988
+ )
989
+ elif self.required_individual_class_code_coverage_percent:
990
+ self.logger.info(
991
+ "All classes with individual coverage requirements meet their expectations."
992
+ )
716
993
 
717
994
  if coverage < self.code_coverage_level:
718
995
  errors.append(
@@ -754,7 +1031,7 @@ class RunApexTests(BaseSalesforceApiTask):
754
1031
 
755
1032
  def _wait_for_tests(self):
756
1033
  self.poll_complete = False
757
- self.poll_interval_s = int(self.options.get("poll_interval", 1))
1034
+ self.poll_interval_s = self.parsed_options.poll_interval
758
1035
  self.poll_count = 0
759
1036
  self._poll()
760
1037
 
@@ -797,7 +1074,7 @@ class RunApexTests(BaseSalesforceApiTask):
797
1074
  self.poll_complete = True
798
1075
 
799
1076
  def _write_output(self, test_results):
800
- junit_output = self.options["junit_output"]
1077
+ junit_output = self.parsed_options.junit_output
801
1078
  if junit_output:
802
1079
  with io.open(junit_output, mode="w", encoding="utf-8") as f:
803
1080
  f.write('<testsuite tests="{}">\n'.format(len(test_results)))
@@ -829,7 +1106,7 @@ class RunApexTests(BaseSalesforceApiTask):
829
1106
  f.write(str(s))
830
1107
  f.write("</testsuite>")
831
1108
 
832
- json_output = self.options["json_output"]
1109
+ json_output = self.parsed_options.json_output
833
1110
  if json_output:
834
1111
  with io.open(json_output, mode="w", encoding="utf-8") as f:
835
1112
  f.write(str(json.dumps(test_results, indent=4)))