[deeplink] add safe guards around api request (#10022) * [deeplink] add safe guards around api request * update * update * update * update * update * update
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart index a6130c8..2344bac 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart
@@ -93,7 +93,11 @@ message: 'Your Flutter project has no Links to verify.', ); case PagePhase.analyzeErrorPage: - assert(controller.currentAppLinkSettings?.error != null); + final error = [ + controller.currentAppLinkSettings?.error, + controller.currentUniversalLinkSettings?.error, + ].nonNulls.join('\n\n'); + assert(error.isNotEmpty); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -104,12 +108,8 @@ const SizedBox(height: densePadding), Expanded( child: Scrollbar( - thumbVisibility: true, child: SingleChildScrollView( - child: Text( - controller.currentAppLinkSettings!.error!, - style: theme.errorTextStyle, - ), + child: Text(error, style: theme.errorTextStyle), ), ), ),
diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart index 992798d..0f6000f 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart
@@ -663,8 +663,8 @@ } Future<void> validateLinks() async { - final appLinkSettings = currentAppLinkSettings; - if (appLinkSettings?.error != null) { + if (currentAppLinkSettings?.error != null || + currentUniversalLinkSettings?.error != null) { pagePhase.value = PagePhase.analyzeErrorPage; ga.select( gac.deeplink,
diff --git a/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart b/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart index 4151375..6c036cf 100644 --- a/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart +++ b/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart
@@ -76,11 +76,14 @@ }, ); final resp = await request(uri.toString()); - if (resp?.statusOk ?? false) { - return UniversalLinkSettings.fromJson(resp!.body); - } else { - logWarning(resp, DeeplinkApi.iosUniversalLinkSettings); + if (resp != null) { + if (resp.statusOk) { + return UniversalLinkSettings.fromJson(resp.body); + } else { + logWarning(resp, DeeplinkApi.iosUniversalLinkSettings); + return UniversalLinkSettings.fromErrorJson(resp.body.toString()); + } } } - return UniversalLinkSettings.empty; + return UniversalLinkSettings.error('DevTools server is not available'); }
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 22f639d..1c05daf 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -91,6 +91,9 @@ * Added a "Watch tutorial" link to the status line that points to the [deep links video tutorial](https://youtu.be/d7sZL6hIElw). [#9925](https://github.com/flutter/devtools/pull/9925) +* Validated build option parameters and surfaced iOS universal link settings + errors in the Deep Links tool. + [#10022](https://github.com/flutter/devtools/pull/10022) ## VS Code sidebar updates
diff --git a/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart b/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart index aa503ec..35e69c9 100644 --- a/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart +++ b/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart
@@ -655,5 +655,29 @@ expect(find.text('/ios-path1'), findsOneWidget); expect(find.text('NOT /ios-path2'), findsOneWidget); }); + + testWidgetsWithWindowSize( + 'surfaces iOS universal link settings error to UI', + windowSize, + (WidgetTester tester) async { + final deepLinksController = TestDeepLinksController(); + const iosErrorMessage = + 'Unknown iOS build configuration (Debug) or target (Runner).'; + + deepLinksController + ..selectedProject.value = FlutterProject( + path: '/abc', + androidVariants: ['debug', 'release'], + iosBuildOptions: xcodeBuildOptions, + ) + ..fakeAndroidDeepLinks = [defaultAndroidDeeplink] + ..fakeIosError = iosErrorMessage; + + await pumpDeepLinkScreen(tester, controller: deepLinksController); + + expect(deepLinksController.pagePhase.value, PagePhase.analyzeErrorPage); + expect(find.text(iosErrorMessage), findsOneWidget); + }, + ); }); }
diff --git a/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart b/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart index 509a0b3..2a17ff4 100644 --- a/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart +++ b/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart
@@ -5,6 +5,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/screens/deep_link_validation/deep_links_model.dart'; import 'package:devtools_app/src/screens/deep_link_validation/deep_links_services.dart'; +import 'package:devtools_shared/devtools_deeplink.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart'; import 'package:http/testing.dart'; @@ -58,6 +59,7 @@ bool hasAndroidDomainErrors = false; String iosValidationResponse = ''; List<String> fakeIosDomains = []; + String? fakeIosError; late DeepLinksService _deepLinksService; @@ -74,9 +76,9 @@ androidAppLinks[selectedAndroidVariantIndex.value] = fakeAppLinkSettings( fakeAndroidDeepLinks, ); - iosLinks[selectedIosConfigurationIndex.value] = fakeUniversalLinkSettings( - fakeIosDomains, - ); + iosLinks[selectedIosConfigurationIndex.value] = fakeIosError != null + ? UniversalLinkSettings.error(fakeIosError!) + : fakeUniversalLinkSettings(fakeIosDomains); await super.validateLinks(); }
diff --git a/packages/devtools_shared/CHANGELOG.md b/packages/devtools_shared/CHANGELOG.md index f5c4069..40ab214 100644 --- a/packages/devtools_shared/CHANGELOG.md +++ b/packages/devtools_shared/CHANGELOG.md
@@ -7,6 +7,10 @@ * Track providing package name for DevTools extensions to isolate extension enablement, deduplication, and asset loading. +* Validate `rootPath`, `buildVariant`, `configuration`, and `target` parameters in + `DeeplinkManager`. +* Add `UniversalLinkSettings.fromErrorJson`, `UniversalLinkSettings.error`, and + `UniversalLinkSettings.error` getter to surface iOS universal link settings errors. # 14.0.0
diff --git a/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart b/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart index 40aece3..3026bcc 100644 --- a/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart +++ b/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart
@@ -8,6 +8,8 @@ import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; +import 'xcode_build_options.dart'; + class DeeplinkManager { /// A regex to retrieve the json part from the stdout of Android analyzer. /// @@ -32,6 +34,25 @@ /// APIs. static const kOutputJsonField = 'json'; + /// Cached Android build variants keyed by normalized project root path. + /// + /// Populated by [getAndroidBuildVariants] and used to validate `buildVariant` + /// in [getAndroidAppLinkSettings]. + static final _androidBuildVariantsCache = <String, Set<String>>{}; + + /// Cached iOS Xcode build options keyed by normalized project root path. + /// + /// Populated by [getIosBuildOptions] and used to validate `configuration` and + /// `target` in [getIosUniversalLinkSettings]. + static final _iosBuildOptionsCache = <String, XcodeBuildOptions>{}; + + /// Clears the cached Android build variants and iOS build options. + @visibleForTesting + static void clearBuildOptionsCache() { + _androidBuildVariantsCache.clear(); + _iosBuildOptionsCache.clear(); + } + // TODO(https://github.com/flutter/devtools/issues/9702): Use the `DashTool` // and `DashEnvVar` enums and `getEnvironment()` helper directly from // `package:unified_analytics` once the pinned Flutter candidate SDK in this @@ -66,8 +87,9 @@ Future<ProcessResult> runProcess( String executable, { required List<String> arguments, - String? ide, - bool suppressAnalytics = false, + required String workingDirectory, + required String? ide, + required bool suppressAnalytics, }) { final environment = <String, String>{ ...Platform.environment, @@ -75,7 +97,12 @@ 'DASH__TOOL': ide != null ? _mapIdeToDashToolLabel(ide) : 'devtools', }; - return Process.run(executable, arguments, environment: environment); + return Process.run( + executable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + ); } String _mapIdeToDashToolLabel(String ide) { @@ -116,6 +143,7 @@ Future<String> _runFlutterCommand( List<String> arguments, { + required String workingDirectory, required RegExp outputMatcher, String? ide, bool suppressAnalytics = false, @@ -124,6 +152,7 @@ final result = await runProcess( flutterPath, arguments: arguments, + workingDirectory: workingDirectory, ide: ide, suppressAnalytics: suppressAnalytics, ); @@ -140,10 +169,11 @@ } } - Map<String, Object?> _handleRunFlutterError( - covariant _FlutterProcessError error, - ) { - return <String, String?>{kErrorField: error.message}; + Map<String, Object?> _handleRunFlutterError(Object error) { + final message = error is _FlutterProcessError + ? error.message + : error.toString(); + return <String, String?>{kErrorField: message}; } Future<Map<String, Object?>> _handleReadJsonFile(String filePath) { @@ -166,31 +196,62 @@ String? ide, bool suppressAnalytics = false, }) { + final canonicalPath = path.canonicalize(rootPath); return _runFlutterCommand( - <String>['analyze', '--android', '--list-build-variants', rootPath], + <String>['analyze', '--android', '--list-build-variants'], + workingDirectory: rootPath, outputMatcher: _androidBuildVariantJsonRegex, ide: ide, suppressAnalytics: suppressAnalytics, - ).then<Map<String, Object?>>( - _handleJsonOutput, - onError: _handleRunFlutterError, - ); + ).then<Map<String, Object?>>((jsonOutput) { + try { + final variants = (jsonDecode(jsonOutput) as List) + .cast<String>() + .toSet(); + _androidBuildVariantsCache[canonicalPath] = variants; + } on Object catch (e) { + return <String, String?>{ + kErrorField: 'Failed to parse Android build variants: $e', + }; + } + return _handleJsonOutput(jsonOutput); + }, onError: _handleRunFlutterError); } + static const _fileIssueMessage = + 'This should not happen; please file an issue at ' + 'https://github.com/flutter/devtools/issues.'; + Future<Map<String, Object?>> getAndroidAppLinkSettings({ required String rootPath, required String buildVariant, String? ide, bool suppressAnalytics = false, - }) { + }) async { + final canonicalPath = path.canonicalize(rootPath); + final validVariants = _androidBuildVariantsCache[canonicalPath]; + if (validVariants == null) { + return <String, String?>{ + kErrorField: + 'Android build variants for "$rootPath" have not been parsed yet. ' + '$_fileIssueMessage', + }; + } + if (!validVariants.contains(buildVariant)) { + return <String, String?>{ + kErrorField: + 'Unknown Android build variant "$buildVariant" for "$rootPath". ' + '$_fileIssueMessage', + }; + } return _runFlutterCommand( <String>[ 'analyze', '--android', '--output-app-link-settings', '--build-variant=$buildVariant', - rootPath, ], + workingDirectory: rootPath, outputMatcher: _outputFilePathRegex, ide: ide, suppressAnalytics: suppressAnalytics, @@ -205,15 +266,25 @@ String? ide, bool suppressAnalytics = false, }) { + final canonicalPath = path.canonicalize(rootPath); return _runFlutterCommand( - <String>['analyze', '--ios', '--list-build-options', rootPath], + <String>['analyze', '--ios', '--list-build-options'], + workingDirectory: rootPath, outputMatcher: _iosBuildOptionsJsonRegex, ide: ide, suppressAnalytics: suppressAnalytics, - ).then<Map<String, Object?>>( - _handleJsonOutput, - onError: _handleRunFlutterError, - ); + ).then<Map<String, Object?>>((jsonOutput) { + try { + _iosBuildOptionsCache[canonicalPath] = XcodeBuildOptions.fromJson( + jsonOutput, + ); + } on Object catch (e) { + return <String, String?>{ + kErrorField: 'Failed to parse iOS build options: $e', + }; + } + return _handleJsonOutput(jsonOutput); + }, onError: _handleRunFlutterError); } Future<Map<String, Object?>> getIosUniversalLinkSettings({ @@ -222,7 +293,24 @@ required String target, String? ide, bool suppressAnalytics = false, - }) { + }) async { + final canonicalPath = path.canonicalize(rootPath); + final validOptions = _iosBuildOptionsCache[canonicalPath]; + if (validOptions == null) { + return <String, String?>{ + kErrorField: + 'iOS build options for "$rootPath" have not been parsed yet. ' + '$_fileIssueMessage', + }; + } + if (!validOptions.configurations.contains(configuration) || + !validOptions.targets.contains(target)) { + return <String, String?>{ + kErrorField: + 'Unknown iOS build configuration ($configuration) or target ($target) for "$rootPath". ' + '$_fileIssueMessage', + }; + } return _runFlutterCommand( <String>[ 'analyze', @@ -230,8 +318,8 @@ '--output-universal-link-settings', '--configuration=$configuration', '--target=$target', - rootPath, ], + workingDirectory: rootPath, outputMatcher: _outputFilePathRegex, ide: ide, suppressAnalytics: suppressAnalytics,
diff --git a/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart b/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart index 4e3a7b0..6d62b38 100644 --- a/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart +++ b/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart
@@ -9,9 +9,28 @@ factory UniversalLinkSettings.fromJson(String json) => UniversalLinkSettings._(jsonDecode(json)); + /// Used when the server can't retrieve universal link settings. + /// + /// The input needs to be in json format from DevTools server response. + factory UniversalLinkSettings.fromErrorJson(String json) { + final jsonObject = jsonDecode(json) as Map<String, Object?>; + final message = jsonObject[_kErrorKey]! as String; + return UniversalLinkSettings.error(message); + } + + /// Used when the server can't retrieve universal link settings. + factory UniversalLinkSettings.error(String message) => + UniversalLinkSettings._({ + _kBundleIdentifierKey: '', + _kTeamIdentifierKey: '', + _kAssociatedDomainsKey: const <String>[], + _kErrorKey: message, + }); + static const _kBundleIdentifierKey = 'bundleIdentifier'; static const _kTeamIdentifierKey = 'teamIdentifier'; static const _kAssociatedDomainsKey = 'associatedDomains'; + static const _kErrorKey = 'error'; /// Used when the the server can't retrieve universal link settings. static const empty = UniversalLinkSettings._({ @@ -30,4 +49,7 @@ /// The associated domains of the iOS build of this Flutter project. List<String> get associatedDomains => (_json[_kAssociatedDomainsKey] as List).cast<String>().toList(); + + /// The error message when requesting universal link settings. + String? get error => _json[_kErrorKey] as String?; }
diff --git a/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart b/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart index fb36b12..5483a19 100644 --- a/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart +++ b/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart
@@ -14,6 +14,7 @@ late Directory tmpDir; setUp(() { + DeeplinkManager.clearBuildOptionsCache(); manager = StubbedDeeplinkManager(); tmpDir = Directory.current.createTempSync(); }); @@ -33,12 +34,8 @@ manager.expectedCommands.add( TestCommand( executable: manager.mockedFlutterBinary, - arguments: <String>[ - 'analyze', - '--android', - '--list-build-variants', - projectRoot, - ], + arguments: <String>['analyze', '--android', '--list-build-variants'], + workingDirectory: projectRoot, result: ProcessResult(0, 0, r''' Running Gradle task 'printBuildVariants'... 10.4s ["debug","release","profile"] @@ -66,8 +63,8 @@ 'analyze', '--android', '--list-build-variants', - projectRoot, ], + workingDirectory: projectRoot, ide: 'VS-Code', suppressAnalytics: true, result: ProcessResult(0, 0, r''' @@ -97,8 +94,8 @@ 'analyze', '--android', '--list-build-variants', - projectRoot, ], + workingDirectory: projectRoot, result: ProcessResult(0, 1, '', 'unknown error'), ), ); @@ -118,7 +115,16 @@ const buildVariant = 'someVariant'; final jsonFile = File('${tmpDir.path}/some-output.json'); jsonFile.writeAsStringSync(json); - manager.expectedCommands.add( + manager.expectedCommands.addAll([ + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: <String>['analyze', '--android', '--list-build-variants'], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, ''' +Running Gradle task 'printBuildVariants'... 10.4s +["$buildVariant"] + ''', ''), + ), TestCommand( executable: manager.mockedFlutterBinary, arguments: <String>[ @@ -126,14 +132,15 @@ '--android', '--output-app-link-settings', '--build-variant=$buildVariant', - projectRoot, ], + workingDirectory: projectRoot, result: ProcessResult(0, 0, ''' Running Gradle task 'printBuildVariants'... 10.4s result saved in ${jsonFile.absolute.path} ''', ''), ), - ); + ]); + await manager.getAndroidBuildVariants(rootPath: projectRoot); final response = await manager.getAndroidAppLinkSettings( buildVariant: buildVariant, rootPath: projectRoot, @@ -143,6 +150,59 @@ }); test( + 'getAndroidAppLinkSettings reuses cached variants across instances', + () async { + const projectRoot = '/abc'; + const json = '"some json"'; + const buildVariant = 'release'; + final jsonFile = File('${tmpDir.path}/some-output.json'); + jsonFile.writeAsStringSync(json); + + manager.expectedCommands.add( + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: <String>[ + 'analyze', + '--android', + '--list-build-variants', + ], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, ''' +Running Gradle task 'printBuildVariants'... 10.4s +["debug","release","profile"] + ''', ''), + ), + ); + await manager.getAndroidBuildVariants(rootPath: projectRoot); + + final secondManager = StubbedDeeplinkManager() + ..expectedCommands.add( + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: <String>[ + 'analyze', + '--android', + '--output-app-link-settings', + '--build-variant=$buildVariant', + ], + workingDirectory: '$projectRoot/', + result: ProcessResult(0, 0, ''' +Running Gradle task 'printBuildVariants'... 10.4s +result saved in ${jsonFile.absolute.path} + ''', ''), + ), + ); + final response = await secondManager.getAndroidAppLinkSettings( + buildVariant: buildVariant, + rootPath: '$projectRoot/', + ); + expect(secondManager.expectedCommands, isEmpty); + expect(response[DeeplinkManager.kErrorField], isNull); + expect(response[DeeplinkManager.kOutputJsonField], json); + }, + ); + + test( 'getIosUniversalLinkSettings calls flutter command correctly', () async { const projectRoot = '/abc'; @@ -151,7 +211,15 @@ const target = 'someTarget'; final jsonFile = File('${tmpDir.path}/some-output.json'); jsonFile.writeAsStringSync(json); - manager.expectedCommands.add( + manager.expectedCommands.addAll([ + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: <String>['analyze', '--ios', '--list-build-options'], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, ''' +{"configurations":["$configuration"],"targets":["$target"]} + ''', ''), + ), TestCommand( executable: manager.mockedFlutterBinary, arguments: <String>[ @@ -160,14 +228,15 @@ '--output-universal-link-settings', '--configuration=$configuration', '--target=$target', - projectRoot, ], + workingDirectory: projectRoot, result: ProcessResult(0, 0, ''' Running Gradle task 'printBuildVariants'... 10.4s result saved in ${jsonFile.absolute.path} ''', ''), ), - ); + ]); + await manager.getIosBuildOptions(rootPath: projectRoot); final response = await manager.getIosUniversalLinkSettings( configuration: configuration, target: target, @@ -183,12 +252,8 @@ manager.expectedCommands.add( TestCommand( executable: manager.mockedFlutterBinary, - arguments: <String>[ - 'analyze', - '--ios', - '--list-build-options', - projectRoot, - ], + arguments: <String>['analyze', '--ios', '--list-build-options'], + workingDirectory: projectRoot, result: ProcessResult(0, 0, r''' {"configurations":["Debug","Release","Profile"],"targets":["Runner","RunnerTests"]} ''', ''), @@ -201,6 +266,171 @@ '{"configurations":["Debug","Release","Profile"],"targets":["Runner","RunnerTests"]}', ); }); + + test( + 'returns error when project build options have not been parsed yet', + () async { + const projectRoot = '/unparsed_project'; + final androidResponse = await manager.getAndroidAppLinkSettings( + rootPath: projectRoot, + buildVariant: 'debug', + ); + expect( + androidResponse[DeeplinkManager.kErrorField], + allOf( + contains('have not been parsed yet'), + contains('https://github.com/flutter/devtools/issues'), + ), + ); + + final iosResponse = await manager.getIosUniversalLinkSettings( + rootPath: projectRoot, + configuration: 'Debug', + target: 'Runner', + ); + expect( + iosResponse[DeeplinkManager.kErrorField], + allOf( + contains('have not been parsed yet'), + contains('https://github.com/flutter/devtools/issues'), + ), + ); + }, + ); + + test( + 'rejects buildVariant not in available Android build variants', + () async { + const projectRoot = '/abc'; + manager.expectedCommands.add( + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: <String>[ + 'analyze', + '--android', + '--list-build-variants', + ], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, r''' +Running Gradle task 'printBuildVariants'... 10.4s +["debug","release","profile"] + ''', ''), + ), + ); + await manager.getAndroidBuildVariants(rootPath: projectRoot); + + for (final invalidVariant in <String>[ + 'nonExistentVariant', + '"& calc & echo pwned > MARKER.txt &"', + 'debug --help', + ]) { + final response = await manager.getAndroidAppLinkSettings( + rootPath: projectRoot, + buildVariant: invalidVariant, + ); + expect( + response[DeeplinkManager.kErrorField], + allOf( + contains('Unknown Android build variant'), + contains('https://github.com/flutter/devtools/issues'), + ), + ); + } + }, + ); + + test( + 'rejects configuration or target not in available iOS build options', + () async { + const projectRoot = '/abc'; + manager.expectedCommands.add( + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: <String>['analyze', '--ios', '--list-build-options'], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, r''' +{"configurations":["Debug","Release","Profile"],"targets":["Runner","RunnerTests"]} + ''', ''), + ), + ); + await manager.getIosBuildOptions(rootPath: projectRoot); + + final badConfigResponse = await manager.getIosUniversalLinkSettings( + rootPath: projectRoot, + configuration: '"& calc & echo pwned > MARKER.txt &"', + target: 'Runner', + ); + expect( + badConfigResponse[DeeplinkManager.kErrorField], + allOf( + contains('Unknown iOS build configuration'), + contains('https://github.com/flutter/devtools/issues'), + ), + ); + + final badTargetResponse = await manager.getIosUniversalLinkSettings( + rootPath: projectRoot, + configuration: 'Debug', + target: 'Runner --help', + ); + expect( + badTargetResponse[DeeplinkManager.kErrorField], + allOf( + contains('Unknown iOS build configuration'), + contains('https://github.com/flutter/devtools/issues'), + ), + ); + }, + ); + + test('returns error when workingDirectory does not exist', () async { + final realManager = DeeplinkManager(); + final response = await realManager.getAndroidBuildVariants( + rootPath: '${tmpDir.path}/non_existent_dir_"&calc', + ); + expect(response[DeeplinkManager.kErrorField], isNotNull); + }); + + test( + 'returns error when parsing Android build variants or iOS build options fails', + () async { + const projectRoot = '/abc'; + manager.expectedCommands.addAll([ + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: <String>[ + 'analyze', + '--android', + '--list-build-variants', + ], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, '[invalid json]', ''), + ), + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: <String>['analyze', '--ios', '--list-build-options'], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, '{invalid json}', ''), + ), + ]); + + final androidResponse = await manager.getAndroidBuildVariants( + rootPath: projectRoot, + ); + expect( + androidResponse[DeeplinkManager.kErrorField], + contains('Failed to parse Android build variants'), + ); + + final iosResponse = await manager.getIosBuildOptions( + rootPath: projectRoot, + ); + expect( + iosResponse[DeeplinkManager.kErrorField], + contains('Failed to parse iOS build options'), + ); + }, + ); }); } @@ -215,8 +445,9 @@ Future<ProcessResult> runProcess( String executable, { required List<String> arguments, - String? ide, - bool suppressAnalytics = false, + required String workingDirectory, + required String? ide, + required bool suppressAnalytics, }) async { if (expectedCommands.isNotEmpty) { final expectedCommand = expectedCommands.removeAt(0); @@ -228,6 +459,7 @@ ), isTrue, ); + expect(workingDirectory, expectedCommand.workingDirectory); expect(ide, expectedCommand.ide); expect(suppressAnalytics, expectedCommand.suppressAnalytics); return expectedCommand.result; @@ -240,18 +472,20 @@ const TestCommand({ required this.executable, required this.arguments, + required this.workingDirectory, this.ide, this.suppressAnalytics = false, required this.result, }); final String executable; final List<String> arguments; + final String workingDirectory; final String? ide; final bool suppressAnalytics; final ProcessResult result; @override String toString() { - return '"$executable ${arguments.join(' ')}"'; + return '"$executable ${arguments.join(' ')}" (workingDirectory: $workingDirectory)'; } }
diff --git a/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart b/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart index ec15bcd..d53955c 100644 --- a/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart +++ b/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart
@@ -45,6 +45,24 @@ expect(settings.bundleIdentifier, 'com.example.app'); expect(settings.teamIdentifier, isEmpty); expect(settings.associatedDomains, ['applinks:example.com']); + expect(settings.error, isNull); + }); + + test('parses error json correctly', () { + const json = '{"error": "Failed to load iOS settings"}'; + final settings = UniversalLinkSettings.fromErrorJson(json); + expect(settings.bundleIdentifier, isEmpty); + expect(settings.teamIdentifier, isEmpty); + expect(settings.associatedDomains, isEmpty); + expect(settings.error, 'Failed to load iOS settings'); + }); + + test('creates error settings correctly', () { + final settings = UniversalLinkSettings.error('Some error'); + expect(settings.bundleIdentifier, isEmpty); + expect(settings.teamIdentifier, isEmpty); + expect(settings.associatedDomains, isEmpty); + expect(settings.error, 'Some error'); }); }); }