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