cumulusci-plus 5.0.21__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 (121) 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/sourcetracking.py +1 -1
  71. cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
  72. cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
  73. cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
  74. cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
  75. cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
  76. cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
  77. cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
  78. cumulusci/tasks/salesforce/update_dependencies.py +2 -2
  79. cumulusci/tasks/salesforce/update_external_credential.py +562 -0
  80. cumulusci/tasks/salesforce/update_named_credential.py +441 -0
  81. cumulusci/tasks/salesforce/update_profile.py +17 -13
  82. cumulusci/tasks/salesforce/users/permsets.py +62 -5
  83. cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
  84. cumulusci/tasks/sfdmu/__init__.py +0 -0
  85. cumulusci/tasks/sfdmu/sfdmu.py +363 -0
  86. cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
  87. cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
  88. cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
  89. cumulusci/tasks/tests/test_create_package_version.py +716 -1
  90. cumulusci/tasks/tests/test_util.py +42 -0
  91. cumulusci/tasks/util.py +37 -1
  92. cumulusci/tasks/utility/copyContents.py +402 -0
  93. cumulusci/tasks/utility/credentialManager.py +256 -0
  94. cumulusci/tasks/utility/directoryRecreator.py +30 -0
  95. cumulusci/tasks/utility/env_management.py +1 -1
  96. cumulusci/tasks/utility/secretsToEnv.py +135 -0
  97. cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
  98. cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
  99. cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
  100. cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
  101. cumulusci/tests/test_integration_infrastructure.py +3 -1
  102. cumulusci/tests/test_utils.py +70 -6
  103. cumulusci/utils/__init__.py +54 -9
  104. cumulusci/utils/classutils.py +5 -2
  105. cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
  106. cumulusci/utils/options.py +23 -1
  107. cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
  108. cumulusci/utils/yaml/cumulusci_yml.py +7 -3
  109. cumulusci/utils/yaml/model_parser.py +2 -2
  110. cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
  111. cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
  112. cumulusci/vcs/base.py +23 -15
  113. cumulusci/vcs/bootstrap.py +5 -4
  114. cumulusci/vcs/utils/list_modified_files.py +189 -0
  115. cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
  116. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
  117. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +121 -96
  118. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
  119. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
  120. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
  121. {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.35.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,143 @@ 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
+ )
251
+ base_ref: Optional[str] = Field(
252
+ None,
253
+ description="Git reference (branch, tag, or commit) to compare against for delta changes. "
254
+ "If not set, uses the default branch of the repository. Only used when dynamic_filter is 'delta_changes'.",
225
255
  )
226
256
 
227
- self.verbose = process_bool_arg(self.options.get("verbose") or False)
257
+ parsed_options: Options
228
258
 
259
+ def _init_options(self, kwargs):
260
+ super(RunApexTests, self)._init_options(kwargs)
261
+
262
+ # Set defaults from project config
263
+ if self.parsed_options.test_name_match is None:
264
+ self.parsed_options.test_name_match = ListOfStringsOption.from_str(
265
+ self.project_config.project__test__name_match
266
+ )
267
+ if self.parsed_options.test_name_exclude is None:
268
+ self.parsed_options.test_name_exclude = ListOfStringsOption.from_str(
269
+ self.project_config.project__test__name_exclude
270
+ )
271
+ if self.parsed_options.test_suite_names is None:
272
+ self.parsed_options.test_suite_names = ListOfStringsOption.from_str(
273
+ self.project_config.project__test__suite__names
274
+ )
275
+ if self.parsed_options.namespace is None:
276
+ self.parsed_options.namespace = (
277
+ self.project_config.project__package__namespace
278
+ )
279
+
280
+ self.verbose = self.parsed_options.verbose
229
281
  self.counts = {}
230
282
 
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
283
+ self.code_coverage_level = (
284
+ self.parsed_options.required_org_code_coverage_percent
285
+ )
286
+
287
+ self.required_per_class_code_coverage_percent = (
288
+ self.parsed_options.required_per_class_code_coverage_percent
289
+ )
242
290
 
243
- self.required_per_class_code_coverage_percent = int(
244
- self.options.get("required_per_class_code_coverage_percent", 0)
291
+ # Parse individual class coverage requirements
292
+ # Validator already converted values to int, so just use it directly
293
+ self.required_individual_class_code_coverage_percent = (
294
+ self.parsed_options.required_individual_class_code_coverage_percent
245
295
  )
296
+
246
297
  # 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%"
298
+ if self.parsed_options.test_suite_names and not (
299
+ any(
300
+ pattern in ["%_TEST%", "%TEST%"]
301
+ for pattern in self.parsed_options.test_name_match
302
+ )
250
303
  ):
251
304
  raise TaskOptionsError(
252
305
  "Both test_suite_names and test_name_match cannot be passed simultaneously"
@@ -263,9 +316,9 @@ class RunApexTests(BaseSalesforceApiTask):
263
316
 
264
317
  def _get_namespace_filter(self):
265
318
 
266
- if self.options.get("managed"):
319
+ if self.parsed_options.managed:
267
320
 
268
- namespace = self.options.get("namespace")
321
+ namespace = self.parsed_options.namespace
269
322
 
270
323
  if not namespace:
271
324
  raise TaskOptionsError(
@@ -282,15 +335,13 @@ class RunApexTests(BaseSalesforceApiTask):
282
335
  def _get_test_class_query(self):
283
336
  namespace = self._get_namespace_filter()
284
337
  # Split by commas to allow multiple class name matching options
285
- test_name_match = self.options["test_name_match"]
286
338
  included_tests = []
287
- for pattern in test_name_match.split(","):
339
+ for pattern in self.parsed_options.test_name_match:
288
340
  if pattern:
289
341
  included_tests.append("Name LIKE '{}'".format(pattern))
290
342
  # Add any excludes to the where clause
291
- test_name_exclude = self.options.get("test_name_exclude", "")
292
343
  excluded_tests = []
293
- for pattern in test_name_exclude.split(","):
344
+ for pattern in self.parsed_options.test_name_exclude:
294
345
  if pattern:
295
346
  excluded_tests.append("(NOT Name LIKE '{}')".format(pattern))
296
347
  # Get all test classes for namespace
@@ -306,7 +357,7 @@ class RunApexTests(BaseSalesforceApiTask):
306
357
 
307
358
  def _get_test_classes(self):
308
359
  # 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"]:
360
+ if self.parsed_options.test_suite_names:
310
361
  test_classes_from_test_suite_names = (
311
362
  self._get_test_classes_from_test_suite_names()
312
363
  )
@@ -345,13 +396,12 @@ class RunApexTests(BaseSalesforceApiTask):
345
396
  if len(testSuiteIds_formatted) == 0:
346
397
  testSuiteIds_formatted = "''"
347
398
 
348
- test_name_exclude_arg = self.options["test_name_exclude"]
349
399
  condition = ""
350
400
 
351
401
  # Check if test_name_exclude is provided. Append to query string if the former is specified.
352
- if test_name_exclude_arg:
402
+ if self.parsed_options.test_name_exclude:
353
403
  test_name_exclude = self._get_comma_separated_string_of_items(
354
- test_name_exclude_arg.split(",")
404
+ self.parsed_options.test_name_exclude
355
405
  )
356
406
  condition = f"AND Name NOT IN ({test_name_exclude})"
357
407
 
@@ -360,9 +410,8 @@ class RunApexTests(BaseSalesforceApiTask):
360
410
 
361
411
  def _get_test_classes_from_test_suite_names(self):
362
412
  # 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
413
  query1 = self._get_test_suite_ids_from_test_suite_names_query(
365
- test_suite_names_arg
414
+ self.parsed_options.test_suite_names
366
415
  )
367
416
  self.logger.info("Fetching test suite metadata...")
368
417
  result = self.tooling.query_all(query1)
@@ -377,6 +426,187 @@ class RunApexTests(BaseSalesforceApiTask):
377
426
  self.logger.info("Found {} test classes".format(result["totalSize"]))
378
427
  return result
379
428
 
429
+ def _class_exists_in_package(self, class_name):
430
+ """Check if an Apex class exists in the default package directory."""
431
+ package_path = self.project_config.default_package_path
432
+
433
+ # Walk through the package directory to find .cls files
434
+ for root, dirs, files in os.walk(package_path):
435
+ for file in files:
436
+ if file.endswith(".cls"):
437
+ # Extract class name from filename (remove .cls extension)
438
+ file_class_name = file[:-4]
439
+ if file_class_name == class_name:
440
+ return True
441
+ return False
442
+
443
+ def _filter_package_classes(self, test_classes):
444
+ """Filter test classes to only include those that exist in the package directory."""
445
+ if self.parsed_options.dynamic_filter is None:
446
+ return test_classes
447
+
448
+ filtered_records = []
449
+ match self.parsed_options.dynamic_filter:
450
+ case "package_only":
451
+ filtered_records = self._filter_test_classes_to_package_only(
452
+ test_classes
453
+ )
454
+ case "delta_changes":
455
+ filtered_records = self._filter_test_classes_to_delta_changes(
456
+ test_classes
457
+ )
458
+ case _:
459
+ raise TaskOptionsError(
460
+ f"Unsupported dynamic filter: {self.parsed_options.dynamic_filter}"
461
+ )
462
+
463
+ # Update the result with filtered records
464
+ filtered_result = {
465
+ "totalSize": len(filtered_records),
466
+ "records": filtered_records,
467
+ "done": test_classes.get("done", True),
468
+ }
469
+
470
+ return filtered_result
471
+
472
+ def _filter_test_classes_to_package_only(self, test_classes):
473
+ """Filter test classes to only include those that exist in the package directory."""
474
+ filtered_records = []
475
+ excluded_count = 0
476
+
477
+ for record in test_classes["records"]:
478
+ class_name = record["Name"]
479
+ if self._class_exists_in_package(class_name):
480
+ filtered_records.append(record)
481
+ else:
482
+ excluded_count += 1
483
+ self.logger.debug(
484
+ f"Excluding test class '{class_name}' - not found in package directory"
485
+ )
486
+
487
+ if excluded_count > 0:
488
+ self.logger.info(
489
+ f"Excluded {excluded_count} test class(es) not in package directory"
490
+ )
491
+ return filtered_records
492
+
493
+ def _filter_test_classes_to_delta_changes(self, test_classes):
494
+ """Filter test classes to only include those that are affected by the delta changes in the current branch."""
495
+ # Check if the current base folder has git. (project_config.repo)
496
+ if self.project_config.get_repo() is None:
497
+ self.logger.info("No git repository found. Returning all test classes.")
498
+ return test_classes["records"]
499
+
500
+ self.logger.info("")
501
+ self.logger.info("Getting the list of committed files in the current branch.")
502
+ # Get the list of modified files in the current branch.
503
+ task = ListModifiedFiles(
504
+ self.project_config,
505
+ TaskConfig(
506
+ {
507
+ "options": {
508
+ "base_ref": self.parsed_options.base_ref,
509
+ "file_extensions": ["cls", "flow-meta.xml", "trigger"],
510
+ "directories": ["force-app", "src"],
511
+ }
512
+ }
513
+ ),
514
+ org_config=None,
515
+ )
516
+ task()
517
+
518
+ branch_return_values = task.return_values.copy()
519
+
520
+ # Get the list of modified files which are not yet committed.
521
+ self.logger.info("")
522
+ self.logger.info("Getting the list of uncommitted files in the current branch.")
523
+ task.parsed_options.base_ref = "HEAD"
524
+ task()
525
+ uncommitted_return_values = task.return_values.copy()
526
+
527
+ # Get the list of changed files.
528
+ branch_files = set(branch_return_values.get("files", []))
529
+ uncommitted_files = set(uncommitted_return_values.get("files", []))
530
+ changed_files = branch_files.union(uncommitted_files)
531
+
532
+ if not changed_files:
533
+ self.logger.info(
534
+ "No changed files found in package directories (force-app/ or src/)."
535
+ )
536
+ return []
537
+
538
+ # Extract class names from changed files
539
+ affected_class_names = branch_return_values.get("file_names", set()).union(
540
+ uncommitted_return_values.get("file_names", set())
541
+ )
542
+
543
+ if not affected_class_names:
544
+ self.logger.info("No file names found in changed files.")
545
+ return []
546
+
547
+ self.logger.info(
548
+ f"Found {len(affected_class_names)} affected class(es): {', '.join(sorted(affected_class_names))}"
549
+ )
550
+
551
+ # Filter test classes to only include those affected by the delta changes
552
+ filtered_records = []
553
+ excluded_count = 0
554
+
555
+ affected_class_names_lower = [name.lower() for name in affected_class_names]
556
+
557
+ for record in test_classes["records"]:
558
+ test_class_name = record["Name"]
559
+ if self._is_test_class_affected(
560
+ test_class_name.lower(), affected_class_names_lower
561
+ ):
562
+ filtered_records.append(record)
563
+ else:
564
+ excluded_count += 1
565
+ self.logger.debug(
566
+ f"Excluding test class '{test_class_name}' - not affected by delta changes"
567
+ )
568
+
569
+ if excluded_count > 0:
570
+ self.logger.info(
571
+ f"Excluded {excluded_count} test class(es) not affected by delta changes"
572
+ )
573
+
574
+ if not filtered_records:
575
+ self.logger.info(
576
+ "No test classes found that are affected by delta changes."
577
+ )
578
+ return []
579
+
580
+ self.logger.info(
581
+ f"Running {len(filtered_records)} test class(es) that are affected by delta changes. Test classes: {', '.join([record['Name'] for record in filtered_records])}"
582
+ )
583
+
584
+ return filtered_records
585
+
586
+ def _is_test_class_affected(self, test_class_name, affected_class_names):
587
+ """Check if a test class is affected by the changed classes."""
588
+ # Direct match: test class name matches a changed class
589
+ if test_class_name in affected_class_names:
590
+ return True
591
+
592
+ # Check if test class name corresponds to an affected class
593
+ # Common patterns:
594
+ # - Account.cls changed -> AccountTest.cls should run
595
+ # - MyService.cls changed -> MyServiceTest.cls should run
596
+ # - AccountHandler.cls changed -> AccountHandlerTest.cls should run
597
+ for affected_class in affected_class_names:
598
+ # Check if test class name follows common test naming patterns
599
+ if (
600
+ test_class_name == f"{affected_class}test"
601
+ or test_class_name == f"test{affected_class}"
602
+ or test_class_name.startswith(f"{affected_class}_")
603
+ or test_class_name.startswith(f"test{affected_class}_")
604
+ or test_class_name == f"{affected_class.replace('_', '')}test"
605
+ ):
606
+ return True
607
+
608
+ return False
609
+
380
610
  def _get_test_methods_for_class(self, class_name):
381
611
  result = self.tooling.query(
382
612
  f"SELECT SymbolTable FROM ApexClass WHERE Name='{class_name}'"
@@ -399,7 +629,7 @@ class RunApexTests(BaseSalesforceApiTask):
399
629
 
400
630
  def _is_retriable_error_message(self, error_message):
401
631
  return any(
402
- [reg.search(error_message) for reg in self.options["retry_failures"]]
632
+ [reg.search(error_message) for reg in self.parsed_options.retry_failures]
403
633
  )
404
634
 
405
635
  def _is_retriable_failure(self, test_result):
@@ -445,7 +675,7 @@ class RunApexTests(BaseSalesforceApiTask):
445
675
  )
446
676
 
447
677
  # In Spring '20, we cannot get symbol tables for managed classes.
448
- if self.options.get("managed"):
678
+ if self.parsed_options.managed:
449
679
  self.logger.error(
450
680
  f"Cannot access symbol table for managed class {class_name}. Failure will not be retried."
451
681
  )
@@ -484,7 +714,7 @@ class RunApexTests(BaseSalesforceApiTask):
484
714
  # Even if this failure is not retriable per se,
485
715
  # persist its details if we might end up retrying
486
716
  # all failures.
487
- if self.options["retry_always"] or can_retry_this_failure:
717
+ if self.parsed_options.retry_always or can_retry_this_failure:
488
718
  self.retry_details.setdefault(
489
719
  test_result["ApexClassId"], []
490
720
  ).append(test_result["MethodName"])
@@ -609,12 +839,18 @@ class RunApexTests(BaseSalesforceApiTask):
609
839
 
610
840
  def _init_task(self):
611
841
  super()._init_task()
612
- self.options["managed"] = determine_managed_mode(
842
+
843
+ self.parsed_options.managed = determine_managed_mode(
613
844
  self.options, self.project_config, self.org_config
614
845
  )
615
846
 
616
847
  def _run_task(self):
617
848
  result = self._get_test_classes()
849
+
850
+ # Apply dynamic filters if enabled
851
+ if self.parsed_options.dynamic_filter:
852
+ result = self._filter_package_classes(result)
853
+
618
854
  if result["totalSize"] == 0:
619
855
  return
620
856
  for test_class in result["records"]:
@@ -640,7 +876,9 @@ class RunApexTests(BaseSalesforceApiTask):
640
876
  # Did we get back retriable test results? Check our retry policy,
641
877
  # then enqueue new runs individually, until either (a) all retriable
642
878
  # tests succeed or (b) a test fails.
643
- able_to_retry = (self.counts["Retriable"] and self.options["retry_always"]) or (
879
+ able_to_retry = (
880
+ self.counts["Retriable"] and self.parsed_options.retry_always
881
+ ) or (
644
882
  self.counts["Retriable"] and self.counts["Retriable"] == self.counts["Fail"]
645
883
  )
646
884
  if not able_to_retry:
@@ -659,7 +897,7 @@ class RunApexTests(BaseSalesforceApiTask):
659
897
  )
660
898
 
661
899
  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:
900
+ if self.parsed_options.namespace not in self.org_config.installed_packages:
663
901
  self._check_code_coverage()
664
902
  else:
665
903
  self.logger.info(
@@ -675,13 +913,17 @@ class RunApexTests(BaseSalesforceApiTask):
675
913
  class_level_coverage_failures = {}
676
914
 
677
915
  # Query for Class level code coverage using the aggregate
678
- if self.required_per_class_code_coverage_percent:
916
+ if (
917
+ self.required_per_class_code_coverage_percent
918
+ or self.required_individual_class_code_coverage_percent
919
+ ):
679
920
  test_classes = self.tooling.query(
680
921
  "SELECT ApexClassOrTrigger.Name, ApexClassOrTriggerId, NumLinesCovered, NumLinesUncovered FROM ApexCodeCoverageAggregate ORDER BY ApexClassOrTrigger.Name ASC"
681
922
  )["records"]
682
923
 
683
924
  coverage_percentage = 0
684
925
  for class_level in test_classes:
926
+ class_name = class_level["ApexClassOrTrigger"]["Name"]
685
927
  total = (
686
928
  class_level["NumLinesCovered"] + class_level["NumLinesUncovered"]
687
929
  )
@@ -693,26 +935,58 @@ class RunApexTests(BaseSalesforceApiTask):
693
935
  2,
694
936
  )
695
937
 
696
- if coverage_percentage < self.required_per_class_code_coverage_percent:
697
- class_level_coverage_failures[
698
- class_level["ApexClassOrTrigger"]["Name"]
699
- ] = coverage_percentage
938
+ # Determine the required coverage for this class using fallback logic
939
+ required_coverage = None
940
+ if class_name in self.required_individual_class_code_coverage_percent:
941
+ # Individual class requirement takes priority
942
+ required_coverage = (
943
+ self.required_individual_class_code_coverage_percent[class_name]
944
+ )
945
+ elif self.required_per_class_code_coverage_percent:
946
+ # Fall back to global per-class requirement
947
+ required_coverage = self.required_per_class_code_coverage_percent
948
+
949
+ # Only check if a requirement is defined for this class
950
+ if (
951
+ required_coverage is not None
952
+ and coverage_percentage < required_coverage
953
+ ):
954
+ class_level_coverage_failures[class_name] = {
955
+ "actual": coverage_percentage,
956
+ "required": required_coverage,
957
+ }
700
958
 
701
959
  # Query for OrgWide coverage
702
960
  result = self.tooling.query("SELECT PercentCovered FROM ApexOrgWideCoverage")
703
961
  coverage = result["records"][0]["PercentCovered"]
704
962
 
705
963
  errors = []
706
- if self.required_per_class_code_coverage_percent:
964
+ if (
965
+ self.required_per_class_code_coverage_percent
966
+ or self.required_individual_class_code_coverage_percent
967
+ ):
707
968
  if class_level_coverage_failures:
708
- for class_name in class_level_coverage_failures.keys():
969
+ for class_name, coverage_info in class_level_coverage_failures.items():
709
970
  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}."
971
+ f"{class_name}'s code coverage of {coverage_info['actual']}% is below required level of {coverage_info['required']}%."
711
972
  )
712
973
  else:
713
- self.logger.info(
714
- f"All classes meet code coverage expectations of {self.required_per_class_code_coverage_percent}% ."
715
- )
974
+ # Build a message about what requirements were met
975
+ if (
976
+ self.required_per_class_code_coverage_percent
977
+ and self.required_individual_class_code_coverage_percent
978
+ ):
979
+ self.logger.info(
980
+ f"All classes meet code coverage expectations (global: {self.required_per_class_code_coverage_percent}%, individual class requirements also satisfied)."
981
+ )
982
+ elif self.required_per_class_code_coverage_percent:
983
+ self.logger.info(
984
+ f"All classes meet code coverage expectations of {self.required_per_class_code_coverage_percent}%."
985
+ )
986
+ elif self.required_individual_class_code_coverage_percent:
987
+ self.logger.info(
988
+ "All classes with individual coverage requirements meet their expectations."
989
+ )
716
990
 
717
991
  if coverage < self.code_coverage_level:
718
992
  errors.append(
@@ -754,7 +1028,7 @@ class RunApexTests(BaseSalesforceApiTask):
754
1028
 
755
1029
  def _wait_for_tests(self):
756
1030
  self.poll_complete = False
757
- self.poll_interval_s = int(self.options.get("poll_interval", 1))
1031
+ self.poll_interval_s = self.parsed_options.poll_interval
758
1032
  self.poll_count = 0
759
1033
  self._poll()
760
1034
 
@@ -797,7 +1071,7 @@ class RunApexTests(BaseSalesforceApiTask):
797
1071
  self.poll_complete = True
798
1072
 
799
1073
  def _write_output(self, test_results):
800
- junit_output = self.options["junit_output"]
1074
+ junit_output = self.parsed_options.junit_output
801
1075
  if junit_output:
802
1076
  with io.open(junit_output, mode="w", encoding="utf-8") as f:
803
1077
  f.write('<testsuite tests="{}">\n'.format(len(test_results)))
@@ -829,7 +1103,7 @@ class RunApexTests(BaseSalesforceApiTask):
829
1103
  f.write(str(s))
830
1104
  f.write("</testsuite>")
831
1105
 
832
- json_output = self.options["json_output"]
1106
+ json_output = self.parsed_options.json_output
833
1107
  if json_output:
834
1108
  with io.open(json_output, mode="w", encoding="utf-8") as f:
835
1109
  f.write(str(json.dumps(test_results, indent=4)))