diff --git a/packages/shorebird_cli/lib/src/gradlew.dart b/packages/shorebird_cli/lib/src/gradlew.dart index cd712758..a75e17ff 100644 --- a/packages/shorebird_cli/lib/src/gradlew.dart +++ b/packages/shorebird_cli/lib/src/gradlew.dart @@ -22,7 +22,7 @@ class MissingGradleWrapperException implements Exception { String toString() { return ''' Could not find $executablePath. -Make sure you have run "flutter build apk at least once.'''; +Make sure you have run "flutter build apk" at least once.'''; } } diff --git a/packages/shorebird_cli/lib/src/xcodebuild.dart b/packages/shorebird_cli/lib/src/xcodebuild.dart new file mode 100644 index 00000000..763dba70 --- /dev/null +++ b/packages/shorebird_cli/lib/src/xcodebuild.dart @@ -0,0 +1,107 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/process.dart'; + +/// {@template missing_ios_project_exception} +/// Thrown when the Flutter project does not have iOS configured as a platform. +/// {@endtemplate} +class MissingIOSProjectException implements Exception { + /// {@macro missing_ios_project_exception} + const MissingIOSProjectException(this.projectPath); + + final String projectPath; + + @override + String toString() { + return ''' +Could not find an iOS project in $projectPath. +To add iOS, run "flutter create . --platforms ios"'''; + } +} + +/// {@template xcode_project_build_info} +/// Xcode project build information returned by `xcodebuild -list` +/// {@endtemplate} +class XcodeProjectBuildInfo { + /// {@macro xcode_project_build_info} + XcodeProjectBuildInfo({ + this.targets = const {}, + this.buildConfigurations = const {}, + this.schemes = const {}, + }); + + /// Set of targets configured for the project. + final Set targets; + + /// Set of build configurations configured for the project. + final Set buildConfigurations; + + /// Set of schemes configured for the project. + final Set schemes; +} + +/// A wrapper around the `xcodebuild` command. +class XcodeBuild { + /// Name of the executable. + static const executable = 'xcodebuild'; + + /// Return Xcode project build info returned by `xcodebuild -list` + /// for the app at [projectPath]. + Future list(String projectPath) async { + // Flutter apps have ios files in root/ios + // Flutter modules have ios files in root/.ios + final iosRoot = [ + Directory(p.join(projectPath, 'ios')), + Directory(p.join(projectPath, '.ios')), + ].firstWhereOrNull((dir) => dir.existsSync()); + + if (iosRoot == null) throw MissingIOSProjectException(projectPath); + + const arguments = ['-list']; + final result = await process.run( + executable, + arguments, + workingDirectory: iosRoot.path, + ); + + if (result.exitCode != ExitCode.success.code) { + throw ProcessException(executable, arguments, '${result.stderr}'); + } + + final lines = '${result.stdout}'.split('\n'); + final targets = {}; + final buildConfigurations = {}; + final schemes = {}; + Set? bucket; + + for (final line in lines) { + if (line.isEmpty) { + bucket = null; + continue; + } + if (line.endsWith('Targets:')) { + bucket = targets; + continue; + } + if (line.endsWith('Build Configurations:')) { + bucket = buildConfigurations; + continue; + } + if (line.endsWith('Schemes:')) { + bucket = schemes; + continue; + } + bucket?.add(line.trim()); + } + if (schemes.isEmpty) schemes.add('Runner'); + + return XcodeProjectBuildInfo( + targets: targets, + buildConfigurations: buildConfigurations, + schemes: schemes, + ); + } +} diff --git a/packages/shorebird_cli/test/fixtures/xcodebuild_list.txt b/packages/shorebird_cli/test/fixtures/xcodebuild_list.txt new file mode 100644 index 00000000..2cd0090e --- /dev/null +++ b/packages/shorebird_cli/test/fixtures/xcodebuild_list.txt @@ -0,0 +1,29 @@ +Command line invocation: + /Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -list + +User defaults from command line: + IDEPackageSupportUseBuiltinSCM = YES + +Information about project "Runner": + Targets: + Runner + RunnerTests + + Build Configurations: + Debug + Debug-stable + Debug-internal + Release + Release-stable + Release-internal + Profile + Profile-stable + Profile-internal + + If no build configuration is specified and -scheme is not passed then "Release" is used. + + Schemes: + internal + Runner + stable + diff --git a/packages/shorebird_cli/test/src/gradlew_test.dart b/packages/shorebird_cli/test/src/gradlew_test.dart index 5bc33743..a4f9ded6 100644 --- a/packages/shorebird_cli/test/src/gradlew_test.dart +++ b/packages/shorebird_cli/test/src/gradlew_test.dart @@ -67,7 +67,7 @@ void main() { const MissingGradleWrapperException('test').toString(), ''' Could not find test. -Make sure you have run "flutter build apk at least once.''', +Make sure you have run "flutter build apk" at least once.''', ); }); }); diff --git a/packages/shorebird_cli/test/src/xcodebuild_test.dart b/packages/shorebird_cli/test/src/xcodebuild_test.dart new file mode 100644 index 00000000..90d51989 --- /dev/null +++ b/packages/shorebird_cli/test/src/xcodebuild_test.dart @@ -0,0 +1,136 @@ +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:scoped/scoped.dart'; +import 'package:shorebird_cli/src/process.dart'; +import 'package:shorebird_cli/src/xcodebuild.dart'; +import 'package:test/test.dart'; + +class _MockProcess extends Mock implements ShorebirdProcess {} + +void main() { + group(XcodeBuild, () { + late ShorebirdProcess process; + late XcodeBuild xcodeBuild; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + processRef.overrideWith(() => process), + }, + ); + } + + Directory setUpAppTempDir() { + final tempDir = Directory.systemTemp.createTempSync(); + Directory(p.join(tempDir.path, 'ios')).createSync(recursive: true); + return tempDir; + } + + setUp(() { + process = _MockProcess(); + xcodeBuild = runWithOverrides(XcodeBuild.new); + }); + + group(MissingIOSProjectException, () { + test('toString', () { + const exception = MissingIOSProjectException('test_project_path'); + expect( + exception.toString(), + ''' +Could not find an iOS project in test_project_path. +To add iOS, run "flutter create . --platforms ios"''', + ); + }); + }); + + group('list', () { + test('throws a MissingIOSProjectException if no iOS project is found', + () { + final tempDir = Directory.systemTemp.createTempSync(); + expect( + () => xcodeBuild.list(tempDir.path), + throwsA(isA()), + ); + verifyNever( + () => process.run( + any(), + any(), + workingDirectory: any(named: 'workingDirectory'), + ), + ); + }); + + test('throws ProcessException when xcodebuild fails', () async { + final tempDir = setUpAppTempDir(); + const message = 'oops'; + when( + () => process.run( + any(), + any(), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer( + (_) async => ShorebirdProcessResult( + exitCode: ExitCode.software.code, + stdout: '', + stderr: message, + ), + ); + expect( + () => runWithOverrides(() => xcodeBuild.list(tempDir.path)), + throwsA(isA()), + ); + }); + + test('returns correct XcodeProjectBuildInfo for an app', () async { + final tempDir = setUpAppTempDir(); + when( + () => process.run( + any(), + any(), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer( + (_) async => ShorebirdProcessResult( + exitCode: ExitCode.success.code, + stdout: + File('test/fixtures/xcodebuild_list.txt').readAsStringSync(), + stderr: '', + ), + ); + final info = await runWithOverrides( + () => xcodeBuild.list(tempDir.path), + ); + expect(info.targets, equals({'Runner', 'RunnerTests'})); + expect( + info.buildConfigurations, + equals( + { + 'Debug', + 'Debug-stable', + 'Debug-internal', + 'Release', + 'Release-stable', + 'Release-internal', + 'Profile', + 'Profile-stable', + 'Profile-internal' + }, + ), + ); + expect(info.schemes, equals({'Runner', 'stable', 'internal'})); + verify( + () => process.run( + 'xcodebuild', + ['-list'], + workingDirectory: p.join(tempDir.path, 'ios'), + ), + ).called(1); + }); + }); + }); +}