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.
- cumulusci/__about__.py +1 -1
- cumulusci/cli/logger.py +2 -2
- cumulusci/cli/service.py +20 -0
- cumulusci/cli/task.py +17 -0
- cumulusci/cli/tests/test_error.py +3 -1
- cumulusci/cli/tests/test_flow.py +279 -2
- cumulusci/cli/tests/test_service.py +15 -12
- cumulusci/cli/tests/test_task.py +88 -2
- cumulusci/cli/tests/utils.py +1 -4
- cumulusci/core/config/base_task_flow_config.py +26 -1
- cumulusci/core/config/project_config.py +2 -20
- cumulusci/core/config/tests/test_config_expensive.py +9 -3
- cumulusci/core/config/universal_config.py +3 -4
- cumulusci/core/dependencies/base.py +1 -1
- cumulusci/core/dependencies/dependencies.py +1 -1
- cumulusci/core/dependencies/github.py +1 -2
- cumulusci/core/dependencies/resolvers.py +1 -1
- cumulusci/core/dependencies/tests/test_dependencies.py +1 -1
- cumulusci/core/dependencies/tests/test_resolvers.py +1 -1
- cumulusci/core/flowrunner.py +90 -6
- cumulusci/core/github.py +1 -1
- cumulusci/core/sfdx.py +3 -1
- cumulusci/core/source_transforms/tests/test_transforms.py +1 -1
- cumulusci/core/source_transforms/transforms.py +1 -1
- cumulusci/core/tasks.py +13 -2
- cumulusci/core/tests/test_flowrunner.py +100 -0
- cumulusci/core/tests/test_tasks.py +65 -0
- cumulusci/core/utils.py +3 -1
- cumulusci/core/versions.py +1 -1
- cumulusci/cumulusci.yml +55 -0
- cumulusci/oauth/client.py +1 -1
- cumulusci/plugins/plugin_base.py +5 -3
- cumulusci/robotframework/pageobjects/ObjectManagerPageObject.py +1 -1
- cumulusci/salesforce_api/rest_deploy.py +1 -1
- cumulusci/schema/cumulusci.jsonschema.json +64 -0
- cumulusci/tasks/apex/anon.py +1 -1
- cumulusci/tasks/apex/testrunner.py +416 -142
- cumulusci/tasks/apex/tests/test_apex_tasks.py +917 -1
- cumulusci/tasks/bulkdata/extract.py +0 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/extract_yml.py +1 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/synthesize_extract_declarations.py +1 -1
- cumulusci/tasks/bulkdata/extract_dataset_utils/tests/test_extract_yml.py +1 -1
- cumulusci/tasks/bulkdata/generate_and_load_data.py +136 -12
- cumulusci/tasks/bulkdata/mapping_parser.py +139 -44
- cumulusci/tasks/bulkdata/select_utils.py +1 -1
- cumulusci/tasks/bulkdata/snowfakery.py +100 -25
- cumulusci/tasks/bulkdata/tests/test_generate_and_load.py +159 -0
- cumulusci/tasks/bulkdata/tests/test_load.py +0 -2
- cumulusci/tasks/bulkdata/tests/test_mapping_parser.py +763 -1
- cumulusci/tasks/bulkdata/tests/test_select_utils.py +26 -0
- cumulusci/tasks/bulkdata/tests/test_snowfakery.py +133 -0
- cumulusci/tasks/create_package_version.py +190 -16
- cumulusci/tasks/datadictionary.py +1 -1
- cumulusci/tasks/metadata_etl/base.py +7 -3
- cumulusci/tasks/metadata_etl/layouts.py +1 -1
- cumulusci/tasks/metadata_etl/permissions.py +1 -1
- cumulusci/tasks/metadata_etl/remote_site_settings.py +2 -2
- cumulusci/tasks/push/README.md +15 -17
- cumulusci/tasks/release_notes/README.md +13 -13
- cumulusci/tasks/release_notes/generator.py +13 -8
- cumulusci/tasks/robotframework/tests/test_robotframework.py +6 -1
- cumulusci/tasks/salesforce/Deploy.py +53 -2
- cumulusci/tasks/salesforce/SfPackageCommands.py +363 -0
- cumulusci/tasks/salesforce/__init__.py +1 -0
- cumulusci/tasks/salesforce/assign_ps_psg.py +448 -0
- cumulusci/tasks/salesforce/composite.py +1 -1
- cumulusci/tasks/salesforce/custom_settings_wait.py +1 -1
- cumulusci/tasks/salesforce/enable_prediction.py +5 -1
- cumulusci/tasks/salesforce/getPackageVersion.py +89 -0
- cumulusci/tasks/salesforce/profiles.py +13 -9
- cumulusci/tasks/salesforce/sourcetracking.py +1 -1
- cumulusci/tasks/salesforce/tests/test_Deploy.py +316 -1
- cumulusci/tasks/salesforce/tests/test_SfPackageCommands.py +554 -0
- cumulusci/tasks/salesforce/tests/test_assign_ps_psg.py +1055 -0
- cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
- cumulusci/tasks/salesforce/tests/test_profiles.py +43 -3
- cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
- cumulusci/tasks/salesforce/tests/test_update_external_credential.py +912 -0
- cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
- cumulusci/tasks/salesforce/update_dependencies.py +2 -2
- cumulusci/tasks/salesforce/update_external_credential.py +562 -0
- cumulusci/tasks/salesforce/update_named_credential.py +441 -0
- cumulusci/tasks/salesforce/update_profile.py +17 -13
- cumulusci/tasks/salesforce/users/permsets.py +62 -5
- cumulusci/tasks/salesforce/users/tests/test_permsets.py +237 -11
- cumulusci/tasks/sfdmu/__init__.py +0 -0
- cumulusci/tasks/sfdmu/sfdmu.py +363 -0
- cumulusci/tasks/sfdmu/tests/__init__.py +1 -0
- cumulusci/tasks/sfdmu/tests/test_runner.py +212 -0
- cumulusci/tasks/sfdmu/tests/test_sfdmu.py +1012 -0
- cumulusci/tasks/tests/test_create_package_version.py +716 -1
- cumulusci/tasks/tests/test_util.py +42 -0
- cumulusci/tasks/util.py +37 -1
- cumulusci/tasks/utility/copyContents.py +402 -0
- cumulusci/tasks/utility/credentialManager.py +256 -0
- cumulusci/tasks/utility/directoryRecreator.py +30 -0
- cumulusci/tasks/utility/env_management.py +1 -1
- cumulusci/tasks/utility/secretsToEnv.py +135 -0
- cumulusci/tasks/utility/tests/test_copyContents.py +1719 -0
- cumulusci/tasks/utility/tests/test_credentialManager.py +564 -0
- cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
- cumulusci/tasks/utility/tests/test_secretsToEnv.py +1091 -0
- cumulusci/tests/test_integration_infrastructure.py +3 -1
- cumulusci/tests/test_utils.py +70 -6
- cumulusci/utils/__init__.py +54 -9
- cumulusci/utils/classutils.py +5 -2
- cumulusci/utils/http/tests/cassettes/ManualEditTestCompositeParallelSalesforce.test_http_headers.yaml +31 -30
- cumulusci/utils/options.py +23 -1
- cumulusci/utils/parallel/task_worker_queues/parallel_worker.py +1 -1
- cumulusci/utils/yaml/cumulusci_yml.py +7 -3
- cumulusci/utils/yaml/model_parser.py +2 -2
- cumulusci/utils/yaml/tests/test_cumulusci_yml.py +1 -1
- cumulusci/utils/yaml/tests/test_model_parser.py +3 -3
- cumulusci/vcs/base.py +23 -15
- cumulusci/vcs/bootstrap.py +5 -4
- cumulusci/vcs/utils/list_modified_files.py +189 -0
- cumulusci/vcs/utils/tests/test_list_modified_files.py +588 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/METADATA +12 -10
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/RECORD +123 -98
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/WHEEL +0 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/entry_points.txt +0 -0
- {cumulusci_plus-5.0.19.dist-info → cumulusci_plus-5.0.35.dist-info}/licenses/AUTHORS.rst +0 -0
- {cumulusci_plus-5.0.19.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,
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
133
|
-
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
namespace: Optional[str] = Field(
|
|
187
|
+
None,
|
|
188
|
+
description=(
|
|
134
189
|
"Salesforce project namespace. Defaults to "
|
|
135
|
-
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
"
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
202
|
-
"
|
|
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
|
-
|
|
206
|
-
"
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
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.
|
|
319
|
+
if self.parsed_options.managed:
|
|
267
320
|
|
|
268
|
-
namespace = self.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
402
|
+
if self.parsed_options.test_name_exclude:
|
|
353
403
|
test_name_exclude = self._get_comma_separated_string_of_items(
|
|
354
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 = (
|
|
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.
|
|
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
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
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.
|
|
969
|
+
for class_name, coverage_info in class_level_coverage_failures.items():
|
|
709
970
|
errors.append(
|
|
710
|
-
f"{class_name}'s code coverage of {
|
|
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
|
-
|
|
714
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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)))
|