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.
- cumulusci/__about__.py +1 -1
- cumulusci/cli/logger.py +2 -2
- cumulusci/cli/service.py +20 -0
- cumulusci/cli/task.py +19 -3
- cumulusci/cli/tests/test_error.py +3 -1
- cumulusci/cli/tests/test_flow.py +279 -2
- cumulusci/cli/tests/test_org.py +5 -0
- cumulusci/cli/tests/test_service.py +15 -12
- cumulusci/cli/tests/test_task.py +122 -2
- cumulusci/cli/tests/utils.py +1 -4
- cumulusci/core/config/__init__.py +1 -0
- cumulusci/core/config/base_task_flow_config.py +26 -1
- cumulusci/core/config/org_config.py +2 -1
- cumulusci/core/config/project_config.py +14 -20
- cumulusci/core/config/scratch_org_config.py +12 -0
- cumulusci/core/config/tests/test_config.py +1 -0
- cumulusci/core/config/tests/test_config_expensive.py +9 -3
- cumulusci/core/config/universal_config.py +3 -4
- cumulusci/core/dependencies/base.py +5 -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 +73 -1
- 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 +69 -0
- cumulusci/tasks/apex/anon.py +1 -1
- cumulusci/tasks/apex/testrunner.py +421 -144
- 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 +46 -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/__init__.py +2 -0
- cumulusci/tasks/metadata_etl/applications.py +256 -0
- 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/metadata_etl/tests/test_applications.py +710 -0
- 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/insert_record.py +18 -19
- 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_enable_prediction.py +4 -2
- cumulusci/tasks/salesforce/tests/test_getPackageVersion.py +651 -0
- cumulusci/tasks/salesforce/tests/test_update_dependencies.py +1 -1
- cumulusci/tasks/salesforce/tests/test_update_external_auth_identity_provider.py +927 -0
- cumulusci/tasks/salesforce/tests/test_update_external_credential.py +1427 -0
- cumulusci/tasks/salesforce/tests/test_update_named_credential.py +1042 -0
- cumulusci/tasks/salesforce/tests/test_update_record.py +512 -0
- cumulusci/tasks/salesforce/update_dependencies.py +2 -2
- cumulusci/tasks/salesforce/update_external_auth_identity_provider.py +551 -0
- cumulusci/tasks/salesforce/update_external_credential.py +647 -0
- cumulusci/tasks/salesforce/update_named_credential.py +441 -0
- cumulusci/tasks/salesforce/update_profile.py +17 -13
- cumulusci/tasks/salesforce/update_record.py +217 -0
- 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 +376 -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 +302 -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 +1150 -0
- cumulusci/tasks/utility/tests/test_directoryRecreator.py +439 -0
- cumulusci/tasks/utility/tests/test_secretsToEnv.py +1118 -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 +8 -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.21.dist-info → cumulusci_plus-5.0.43.dist-info}/METADATA +11 -10
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/RECORD +135 -104
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/WHEEL +1 -1
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/entry_points.txt +0 -0
- {cumulusci_plus-5.0.21.dist-info → cumulusci_plus-5.0.43.dist-info}/licenses/AUTHORS.rst +0 -0
- {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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
244
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
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.
|
|
320
|
+
if self.parsed_options.managed:
|
|
267
321
|
|
|
268
|
-
namespace = self.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
403
|
+
if self.parsed_options.test_name_exclude:
|
|
353
404
|
test_name_exclude = self._get_comma_separated_string_of_items(
|
|
354
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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 = (
|
|
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.
|
|
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
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
|
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.
|
|
972
|
+
for class_name, coverage_info in class_level_coverage_failures.items():
|
|
709
973
|
errors.append(
|
|
710
|
-
f"{class_name}'s code coverage of {
|
|
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
|
-
|
|
714
|
-
|
|
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 =
|
|
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.
|
|
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.
|
|
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)))
|