1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
2 | // Use of this source code is governed by a BSD-style license that can be |
3 | // found in the LICENSE file. |
4 | |
5 | import 'package:meta/meta.dart' ; |
6 | |
7 | import '../../src/base/process.dart'; |
8 | import '../../src/convert.dart' show json; |
9 | import '../../src/macos/xcode.dart'; |
10 | import '../base/version.dart'; |
11 | import '../convert.dart'; |
12 | |
13 | /// The generator of xcresults. |
14 | /// |
15 | /// Call [generate] after an iOS/MacOS build will generate a [XCResult]. |
16 | /// This only works when the `-resultBundleVersion` is set to 3. |
17 | /// * See also: [XCResult]. |
18 | class XCResultGenerator { |
19 | /// Construct the [XCResultGenerator]. |
20 | XCResultGenerator({ |
21 | required this.resultPath, |
22 | required this.xcode, |
23 | required this.processUtils, |
24 | }); |
25 | |
26 | /// The file path that used to store the xcrun result. |
27 | /// |
28 | /// There's usually a `resultPath.xcresult` file in the same folder. |
29 | final String resultPath; |
30 | |
31 | /// The [ProcessUtils] to run commands. |
32 | final ProcessUtils processUtils; |
33 | |
34 | /// [Xcode] object used to run xcode command. |
35 | final Xcode xcode; |
36 | |
37 | /// Generates the XCResult. |
38 | /// |
39 | /// Calls `xcrun xcresulttool get --legacy --path <resultPath> --format json`, |
40 | /// then stores the useful information the json into an [XCResult] object. |
41 | /// |
42 | /// A`issueDiscarders` can be passed to discard any issues that matches the description of any [XCResultIssueDiscarder] in the list. |
43 | Future<XCResult> generate( |
44 | {List<XCResultIssueDiscarder> issueDiscarders = |
45 | const <XCResultIssueDiscarder>[]}) async { |
46 | final Version? xcodeVersion = xcode.currentVersion; |
47 | final RunResult result = await processUtils.run( |
48 | <String>[ |
49 | ...xcode.xcrunCommand(), |
50 | 'xcresulttool' , |
51 | 'get' , |
52 | // See https://github.com/flutter/flutter/issues/151502 |
53 | if (xcodeVersion != null && xcodeVersion >= Version(16, 0, 0)) |
54 | '--legacy' , |
55 | '--path' , |
56 | resultPath, |
57 | '--format' , |
58 | 'json' , |
59 | ], |
60 | ); |
61 | if (result.exitCode != 0) { |
62 | return XCResult.failed(errorMessage: result.stderr); |
63 | } |
64 | if (result.stdout.isEmpty) { |
65 | return XCResult.failed( |
66 | errorMessage: 'xcresult parser: Unrecognized top level json format.' ); |
67 | } |
68 | final Object? resultJson = json.decode(result.stdout); |
69 | if (resultJson == null || resultJson is! Map<String, Object?>) { |
70 | // If json parsing failed, indicate such error. |
71 | // This also includes the top level json object is an array, which indicates |
72 | // the structure of the json is changed and this parser class possibly needs to update for this change. |
73 | return XCResult.failed( |
74 | errorMessage: 'xcresult parser: Unrecognized top level json format.' ); |
75 | } |
76 | return XCResult(resultJson: resultJson, issueDiscarders: issueDiscarders); |
77 | } |
78 | } |
79 | |
80 | /// The xcresult of an `xcodebuild` command. |
81 | /// |
82 | /// This is the result from an `xcrun xcresulttool get --legacy --path <resultPath> --format json` run. |
83 | /// The result contains useful information such as build errors and warnings. |
84 | class XCResult { |
85 | /// Parse the `resultJson` and stores useful information in the returned `XCResult`. |
86 | factory XCResult({required Map<String, Object?> resultJson, List<XCResultIssueDiscarder> issueDiscarders = const <XCResultIssueDiscarder>[]}) { |
87 | final List<XCResultIssue> issues = <XCResultIssue>[]; |
88 | |
89 | final Object? issuesMap = resultJson['issues' ]; |
90 | if (issuesMap == null || issuesMap is! Map<String, Object?>) { |
91 | return XCResult.failed( |
92 | errorMessage: 'xcresult parser: Failed to parse the issues map.' ); |
93 | } |
94 | |
95 | final Object? errorSummaries = issuesMap['errorSummaries' ]; |
96 | if (errorSummaries is Map<String, Object?>) { |
97 | issues.addAll(_parseIssuesFromIssueSummariesJson( |
98 | type: XCResultIssueType.error, |
99 | issueSummariesJson: errorSummaries, |
100 | issueDiscarder: issueDiscarders, |
101 | )); |
102 | } |
103 | |
104 | final Object? warningSummaries = issuesMap['warningSummaries' ]; |
105 | if (warningSummaries is Map<String, Object?>) { |
106 | issues.addAll(_parseIssuesFromIssueSummariesJson( |
107 | type: XCResultIssueType.warning, |
108 | issueSummariesJson: warningSummaries, |
109 | issueDiscarder: issueDiscarders, |
110 | )); |
111 | } |
112 | |
113 | final Object? actionsMap = resultJson['actions' ]; |
114 | if (actionsMap is Map<String, Object?>) { |
115 | final List<XCResultIssue> actionIssues = _parseActionIssues(actionsMap, issueDiscarders: issueDiscarders); |
116 | issues.addAll(actionIssues); |
117 | } |
118 | |
119 | return XCResult._(issues: issues); |
120 | } |
121 | |
122 | factory XCResult.failed({required String errorMessage}) { |
123 | return XCResult._( |
124 | parseSuccess: false, |
125 | parsingErrorMessage: errorMessage, |
126 | ); |
127 | } |
128 | |
129 | /// Create a [XCResult] with constructed [XCResultIssue]s for testing. |
130 | @visibleForTesting |
131 | factory XCResult.test({ |
132 | List<XCResultIssue>? issues, |
133 | bool? parseSuccess, |
134 | String? parsingErrorMessage, |
135 | }) { |
136 | return XCResult._( |
137 | issues: issues ?? const <XCResultIssue>[], |
138 | parseSuccess: parseSuccess ?? true, |
139 | parsingErrorMessage: parsingErrorMessage, |
140 | ); |
141 | } |
142 | |
143 | XCResult._({ |
144 | this.issues = const <XCResultIssue>[], |
145 | this.parseSuccess = true, |
146 | this.parsingErrorMessage, |
147 | }); |
148 | |
149 | /// The issues in the xcresult file. |
150 | final List<XCResultIssue> issues; |
151 | |
152 | /// Indicate if the xcresult was successfully parsed. |
153 | /// |
154 | /// See also: [parsingErrorMessage] for the error message if the parsing was unsuccessful. |
155 | final bool parseSuccess; |
156 | |
157 | /// The error message describes why the parse if unsuccessful. |
158 | /// |
159 | /// This is `null` if [parseSuccess] is `true`. |
160 | final String? parsingErrorMessage; |
161 | } |
162 | |
163 | /// An issue object in the XCResult |
164 | class XCResultIssue { |
165 | /// Construct an `XCResultIssue` object from `issueJson`. |
166 | /// |
167 | /// `issueJson` is the object at xcresultJson[['actions']['_values'][0]['buildResult']['issues']['errorSummaries'/'warningSummaries']['_values']. |
168 | factory XCResultIssue({ |
169 | required XCResultIssueType type, |
170 | required Map<String, Object?> issueJson, |
171 | }) { |
172 | // Parse type. |
173 | final Object? issueSubTypeMap = issueJson['issueType' ]; |
174 | String? subType; |
175 | if (issueSubTypeMap is Map<String, Object?>) { |
176 | final Object? subTypeValue = issueSubTypeMap['_value' ]; |
177 | if (subTypeValue is String) { |
178 | subType = subTypeValue; |
179 | } |
180 | } |
181 | |
182 | // Parse message. |
183 | String? message; |
184 | final Object? messageMap = issueJson['message' ]; |
185 | if (messageMap is Map<String, Object?>) { |
186 | final Object? messageValue = messageMap['_value' ]; |
187 | if (messageValue is String) { |
188 | message = messageValue; |
189 | } |
190 | } |
191 | |
192 | final List<String> warnings = <String>[]; |
193 | // Parse url and convert it to a location String. |
194 | String? location; |
195 | final Object? documentLocationInCreatingWorkspaceMap = |
196 | issueJson['documentLocationInCreatingWorkspace' ]; |
197 | if (documentLocationInCreatingWorkspaceMap is Map<String, Object?>) { |
198 | final Object? urlMap = documentLocationInCreatingWorkspaceMap['url' ]; |
199 | if (urlMap is Map<String, Object?>) { |
200 | final Object? urlValue = urlMap['_value' ]; |
201 | if (urlValue is String) { |
202 | location = _convertUrlToLocationString(urlValue); |
203 | if (location == null) { |
204 | warnings.add( |
205 | '(XCResult) The `url` exists but it was failed to be parsed. url: $urlValue' ); |
206 | } |
207 | } |
208 | } |
209 | } |
210 | |
211 | return XCResultIssue._( |
212 | type: type, |
213 | subType: subType, |
214 | message: message, |
215 | location: location, |
216 | warnings: warnings, |
217 | ); |
218 | } |
219 | |
220 | /// Create a [XCResultIssue] without JSON parsing for testing. |
221 | @visibleForTesting |
222 | factory XCResultIssue.test({ |
223 | XCResultIssueType type = XCResultIssueType.error, |
224 | String? subType, |
225 | String? message, |
226 | String? location, |
227 | List<String> warnings = const <String>[], |
228 | }) { |
229 | return XCResultIssue._( |
230 | type: type, |
231 | subType: subType, |
232 | message: message, |
233 | location: location, |
234 | warnings: warnings, |
235 | ); |
236 | } |
237 | |
238 | XCResultIssue._({ |
239 | required this.type, |
240 | required this.subType, |
241 | required this.message, |
242 | required this.location, |
243 | required this.warnings, |
244 | }); |
245 | |
246 | /// The type of the issue. |
247 | final XCResultIssueType type; |
248 | |
249 | /// The sub type of the issue. |
250 | /// |
251 | /// This is a more detailed category about the issue. |
252 | /// The possible values are `Warning`, `Semantic Issue'` etc. |
253 | final String? subType; |
254 | |
255 | /// Human readable message for the issue. |
256 | /// |
257 | /// This can be displayed to user for their information. |
258 | final String? message; |
259 | |
260 | /// The location where the issue occurs. |
261 | /// |
262 | /// This is a re-formatted version of the "url" value in the json. |
263 | /// The format looks like <FileLocation>:<StartingLineNumber>:<StartingColumnNumber>. |
264 | final String? location; |
265 | |
266 | /// Warnings when constructing the issue object. |
267 | final List<String> warnings; |
268 | } |
269 | |
270 | /// The type of an `XCResultIssue`. |
271 | enum XCResultIssueType { |
272 | /// The issue is an warning. |
273 | /// |
274 | /// This is for all the issues under the `warningSummaries` key in the xcresult. |
275 | warning, |
276 | |
277 | /// The issue is an warning. |
278 | /// |
279 | /// This is for all the issues under the `errorSummaries` key in the xcresult. |
280 | error, |
281 | } |
282 | |
283 | /// Discards the [XCResultIssue] that matches any of the matchers. |
284 | class XCResultIssueDiscarder { |
285 | XCResultIssueDiscarder( |
286 | {this.typeMatcher, |
287 | this.subTypeMatcher, |
288 | this.messageMatcher, |
289 | this.locationMatcher}) |
290 | : assert(typeMatcher != null || |
291 | subTypeMatcher != null || |
292 | messageMatcher != null || |
293 | locationMatcher != null); |
294 | |
295 | /// The type of the discarder. |
296 | /// |
297 | /// A [XCResultIssue] should be discarded if its `type` equals to this. |
298 | final XCResultIssueType? typeMatcher; |
299 | |
300 | /// The subType of the discarder. |
301 | /// |
302 | /// A [XCResultIssue] should be discarded if its `subType` matches the RegExp. |
303 | final RegExp? subTypeMatcher; |
304 | |
305 | /// The message of the discarder. |
306 | /// |
307 | /// A [XCResultIssue] should be discarded if its `message` matches the RegExp. |
308 | final RegExp? messageMatcher; |
309 | |
310 | /// The location of the discarder. |
311 | /// |
312 | /// A [XCResultIssue] should be discarded if its `location` matches the RegExp. |
313 | final RegExp? locationMatcher; |
314 | } |
315 | |
316 | // A typical location url string looks like file:///foo.swift#CharacterRangeLen=0&EndingColumnNumber=82&EndingLineNumber=7&StartingColumnNumber=82&StartingLineNumber=7. |
317 | // |
318 | // This function converts it to something like: /foo.swift::. |
319 | String? _convertUrlToLocationString(String url) { |
320 | final Uri? fragmentLocation = Uri.tryParse(url); |
321 | if (fragmentLocation == null) { |
322 | return null; |
323 | } |
324 | // Parse the fragment as a query of key-values: |
325 | final Uri fileLocation = Uri( |
326 | path: fragmentLocation.path, |
327 | query: fragmentLocation.fragment, |
328 | ); |
329 | String startingLineNumber = |
330 | fileLocation.queryParameters['StartingLineNumber' ] ?? '' ; |
331 | if (startingLineNumber.isNotEmpty) { |
332 | startingLineNumber = ': $startingLineNumber' ; |
333 | } |
334 | String startingColumnNumber = |
335 | fileLocation.queryParameters['StartingColumnNumber' ] ?? '' ; |
336 | if (startingColumnNumber.isNotEmpty) { |
337 | startingColumnNumber = ': $startingColumnNumber' ; |
338 | } |
339 | return ' ${fileLocation.path}$startingLineNumber$startingColumnNumber' ; |
340 | }
|
341 |
|
342 | // Determine if an `issue` should be discarded based on the `discarder`.
|
343 | bool _shouldDiscardIssue(
|
344 | {required XCResultIssue issue, required XCResultIssueDiscarder discarder}) {
|
345 | if (issue.type == discarder.typeMatcher) {
|
346 | return true;
|
347 | }
|
348 | if (issue.subType != null &&
|
349 | discarder.subTypeMatcher != null &&
|
350 | discarder.subTypeMatcher!.hasMatch(issue.subType!)) {
|
351 | return true;
|
352 | }
|
353 | if (issue.message != null &&
|
354 | discarder.messageMatcher != null &&
|
355 | discarder.messageMatcher!.hasMatch(issue.message!)) {
|
356 | return true;
|
357 | }
|
358 | if (issue.location != null &&
|
359 | discarder.locationMatcher != null &&
|
360 | discarder.locationMatcher!.hasMatch(issue.location!)) {
|
361 | return true;
|
362 | }
|
363 |
|
364 | return false;
|
365 | }
|
366 |
|
367 | List<XCResultIssue> _parseIssuesFromIssueSummariesJson({
|
368 | required XCResultIssueType type,
|
369 | required Map<String, Object?> issueSummariesJson,
|
370 | required List<XCResultIssueDiscarder> issueDiscarder,
|
371 | }) {
|
372 | final List<XCResultIssue> issues = <XCResultIssue>[];
|
373 | final Object? errorsList = issueSummariesJson['_values' ];
|
374 | if (errorsList is List<Object?>) {
|
375 | for (final Object? issueJson in errorsList) {
|
376 | if (issueJson == null || issueJson is! Map<String, Object?>) {
|
377 | continue;
|
378 | }
|
379 | final XCResultIssue resultIssue = XCResultIssue(
|
380 | type: type,
|
381 | issueJson: issueJson,
|
382 | );
|
383 | bool discard = false;
|
384 | for (final XCResultIssueDiscarder discarder in issueDiscarder) {
|
385 | if (_shouldDiscardIssue(issue: resultIssue, discarder: discarder)) {
|
386 | discard = true;
|
387 | break;
|
388 | }
|
389 | }
|
390 | if (discard) {
|
391 | continue;
|
392 | }
|
393 | issues.add(resultIssue);
|
394 | }
|
395 | }
|
396 | return issues;
|
397 | }
|
398 |
|
399 | List<XCResultIssue> _parseActionIssues(
|
400 | Map<String, Object?> actionsMap, {
|
401 | required List<XCResultIssueDiscarder> issueDiscarders,
|
402 | }) {
|
403 | // Example of json:
|
404 | // {
|
405 | // "actions" : {
|
406 | // "_values" : [
|
407 | // {
|
408 | // "actionResult" : {
|
409 | // "_type" : {
|
410 | // "_name" : "ActionResult"
|
411 | // },
|
412 | // "issues" : {
|
413 | // "_type" : {
|
414 | // "_name" : "ResultIssueSummaries"
|
415 | // },
|
416 | // "testFailureSummaries" : {
|
417 | // "_type" : {
|
418 | // "_name" : "Array"
|
419 | // },
|
420 | // "_values" : [
|
421 | // {
|
422 | // "_type" : {
|
423 | // "_name" : "TestFailureIssueSummary",
|
424 | // "_supertype" : {
|
425 | // "_name" : "IssueSummary"
|
426 | // }
|
427 | // },
|
428 | // "issueType" : {
|
429 | // "_type" : {
|
430 | // "_name" : "String"
|
431 | // },
|
432 | // "_value" : "Uncategorized"
|
433 | // },
|
434 | // "message" : {
|
435 | // "_type" : {
|
436 | // "_name" : "String"
|
437 | // },
|
438 | // "_value" : "Unable to find a destination matching the provided destination specifier:\n\t\t{ id:1234D567-890C-1DA2-34E5-F6789A0123C4 }\n\n\tIneligible destinations for the \"Runner\" scheme:\n\t\t{ platform:iOS, id:dvtdevice-DVTiPhonePlaceholder-iphoneos:placeholder, name:Any iOS Device, error:iOS 17.0 is not installed. To use with Xcode, first download and install the platform }"
|
439 | // }
|
440 | // }
|
441 | // ]
|
442 | // }
|
443 | // }
|
444 | // }
|
445 | // }
|
446 | // ]
|
447 | // }
|
448 | // }
|
449 | final List<XCResultIssue> issues = <XCResultIssue>[];
|
450 | final Object? actionsValues = actionsMap['_values' ];
|
451 | if (actionsValues is! List<Object?>) {
|
452 | return issues;
|
453 | }
|
454 |
|
455 | for (final Object? actionValue in actionsValues) {
|
456 | if (actionValue is!Map<String, Object?>) {
|
457 | continue;
|
458 | }
|
459 | final Object? actionResult = actionValue['actionResult' ];
|
460 | if (actionResult is! Map<String, Object?>) {
|
461 | continue;
|
462 | }
|
463 | final Object? actionResultIssues = actionResult['issues' ];
|
464 | if (actionResultIssues is! Map<String, Object?>) {
|
465 | continue;
|
466 | }
|
467 | final Object? testFailureSummaries = actionResultIssues['testFailureSummaries' ];
|
468 | if (testFailureSummaries is Map<String, Object?>) {
|
469 | issues.addAll(_parseIssuesFromIssueSummariesJson(
|
470 | type: XCResultIssueType.error,
|
471 | issueSummariesJson: testFailureSummaries,
|
472 | issueDiscarder: issueDiscarders,
|
473 | ));
|
474 | }
|
475 | }
|
476 |
|
477 | return issues;
|
478 | }
|
479 |
|