From da3ce95d6f29d48a652a03c1a62965131499bcc9 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Tue, 23 May 2023 15:53:40 -0500 Subject: [PATCH 1/2] feat(shorebird_cli): add `shorebird build ipa` --- .../lib/src/commands/build/build.dart | 1 + .../lib/src/commands/build/build_command.dart | 17 +- .../src/commands/build/build_ipa_command.dart | 104 +++++++ .../lib/src/shorebird_build_mixin.dart | 32 ++ .../build/build_ipa_command_test.dart | 277 ++++++++++++++++++ 5 files changed, 417 insertions(+), 14 deletions(-) create mode 100644 packages/shorebird_cli/lib/src/commands/build/build_ipa_command.dart create mode 100644 packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart diff --git a/packages/shorebird_cli/lib/src/commands/build/build.dart b/packages/shorebird_cli/lib/src/commands/build/build.dart index 066d6d6b2..3db64a271 100644 --- a/packages/shorebird_cli/lib/src/commands/build/build.dart +++ b/packages/shorebird_cli/lib/src/commands/build/build.dart @@ -1,3 +1,4 @@ export 'build_apk_command.dart'; export 'build_app_bundle_command.dart'; export 'build_command.dart'; +export 'build_ipa_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/build/build_command.dart b/packages/shorebird_cli/lib/src/commands/build/build_command.dart index f135ceb62..0f0397b5d 100644 --- a/packages/shorebird_cli/lib/src/commands/build/build_command.dart +++ b/packages/shorebird_cli/lib/src/commands/build/build_command.dart @@ -13,20 +13,9 @@ class BuildCommand extends ShorebirdCommand with ShorebirdValidationMixin, ShorebirdConfigMixin, ShorebirdBuildMixin { /// {@macro build_command} BuildCommand({required super.logger}) { - addSubcommand( - BuildApkCommand( - auth: auth, - logger: logger, - validators: validators, - ), - ); - addSubcommand( - BuildAppBundleCommand( - auth: auth, - logger: logger, - validators: validators, - ), - ); + addSubcommand(BuildApkCommand(logger: logger)); + addSubcommand(BuildAppBundleCommand(logger: logger)); + addSubcommand(BuildIpaCommand(logger: logger)); } @override diff --git a/packages/shorebird_cli/lib/src/commands/build/build_ipa_command.dart b/packages/shorebird_cli/lib/src/commands/build/build_ipa_command.dart new file mode 100644 index 000000000..fd29921d8 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/build/build_ipa_command.dart @@ -0,0 +1,104 @@ +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/auth_logger_mixin.dart'; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_validation_mixin.dart'; + +/// {@template build_ipa_command} +/// `shorebird build ipa` +/// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for +/// App Store submission. +/// {@endtemplate} +class BuildIpaCommand extends ShorebirdCommand + with + AuthLoggerMixin, + ShorebirdValidationMixin, + ShorebirdConfigMixin, + ShorebirdBuildMixin { + /// {@macro build_ipa_command} + BuildIpaCommand({required super.logger, super.auth, super.validators}) { + argParser + ..addOption( + 'target', + abbr: 't', + help: 'The main entrypoint file of the application.', + ) + ..addOption( + 'flavor', + help: 'The product flavor to use when building the app.', + ) + ..addFlag( + 'codesign', + help: + '''Codesign the application bundle (only available on device builds).''', + ); + } + + @override + String get description => + '''Builds an .xcarchive and optionally .ipa for an iOS app to be generated for App Store submission.'''; + + @override + String get name => 'ipa'; + + @override + Future run() async { + if (!auth.isAuthenticated) { + printNeedsAuthInstructions(); + return ExitCode.noUser.code; + } + + final validationIssues = await runValidators(); + if (validationIssuesContainsError(validationIssues)) { + logValidationFailure(issues: validationIssues); + return ExitCode.config.code; + } + + final flavor = results['flavor'] as String?; + final target = results['target'] as String?; + final codesign = results['codesign'] as bool; + + if (!codesign) { + logger.warn(''' +Codesigning is disabled. You must manually codesign before deploying to devices.'''); + } + + final buildProgress = logger.progress('Building ipa'); + try { + await buildIpa( + flavor: flavor, + target: target, + codesign: codesign, + ); + } on ProcessException catch (error) { + buildProgress.fail('Failed to build: ${error.message}'); + return ExitCode.software.code; + } + + buildProgress.complete(); + + const xcarchivePath = './build/ios/archive/Runner.xcarchive'; + + logger.info(''' +📦 Generated an xcode archive at: +${lightCyan.wrap(xcarchivePath)}'''); + + if (!codesign) { + logger.info( + 'Codesigning disabled via "--no-codesign". Skipping ipa generation.', + ); + return ExitCode.success.code; + } + + const ipaPath = './build/ios/ipa/Runner.ipa'; + + logger.info(''' +📦 Generated an ipa at: +${lightCyan.wrap(ipaPath)}'''); + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart index 4ff86b9d0..f2bf9ec61 100644 --- a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart +++ b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart @@ -119,4 +119,36 @@ mixin ShorebirdBuildMixin on ShorebirdCommand { ); } } + + Future buildIpa({ + String? flavor, + String? target, + bool codesign = true, + }) async { + const executable = 'flutter'; + final arguments = [ + 'build', + 'ipa', + '--release', + if (flavor != null) '--flavor=$flavor', + if (target != null) '--target=$target', + if (!codesign) '--no-codesign', + ...results.rest, + ]; + + final result = await process.run( + executable, + arguments, + runInShell: true, + ); + + if (result.exitCode != ExitCode.success.code) { + throw ProcessException( + 'flutter', + arguments, + result.stderr.toString(), + result.exitCode, + ); + } + } } diff --git a/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart b/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart new file mode 100644 index 000000000..0f9b7b3eb --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/build/build_ipa_command_test.dart @@ -0,0 +1,277 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:http/http.dart' as http; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/commands/build/build.dart'; +import 'package:shorebird_cli/src/shorebird_process.dart'; +import 'package:shorebird_cli/src/validators/validators.dart'; +import 'package:test/test.dart'; + +class _MockArgResults extends Mock implements ArgResults {} + +class _MockHttpClient extends Mock implements http.Client {} + +class _MockAuth extends Mock implements Auth {} + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +class _MockProcessResult extends Mock implements ShorebirdProcessResult {} + +class _MockShorebirdFlutterValidator extends Mock + implements ShorebirdFlutterValidator {} + +class _MockShorebirdProcess extends Mock implements ShorebirdProcess {} + +void main() { + group(BuildIpaCommand, () { + late ArgResults argResults; + late http.Client httpClient; + late Auth auth; + late Logger logger; + late ShorebirdProcessResult processResult; + late BuildIpaCommand command; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + + setUp(() { + argResults = _MockArgResults(); + httpClient = _MockHttpClient(); + auth = _MockAuth(); + logger = _MockLogger(); + shorebirdProcess = _MockShorebirdProcess(); + processResult = _MockProcessResult(); + flutterValidator = _MockShorebirdFlutterValidator(); + command = BuildIpaCommand( + auth: auth, + logger: logger, + validators: [flutterValidator], + ) + ..testArgResults = argResults + ..testProcess = shorebirdProcess + ..testEngineConfig = const EngineConfig.empty(); + registerFallbackValue(shorebirdProcess); + + when( + () => shorebirdProcess.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => processResult); + when(() => argResults['codesign']).thenReturn(true); + when(() => argResults.rest).thenReturn([]); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => auth.client).thenReturn(httpClient); + when(() => logger.progress(any())).thenReturn(_MockProgress()); + when(() => logger.info(any())).thenReturn(null); + when(() => flutterValidator.validate(any())).thenAnswer((_) async => []); + }); + + test('has correct description', () { + expect(command.description, isNotEmpty); + }); + + test('exits with no user when not logged in', () async { + when(() => auth.isAuthenticated).thenReturn(false); + + final result = await command.run(); + expect(result, equals(ExitCode.noUser.code)); + + verify( + () => logger.err(any(that: contains('You must be logged in to run'))), + ).called(1); + }); + + test('exits with code 70 when building ipa fails', () async { + when(() => processResult.exitCode).thenReturn(1); + when(() => processResult.stderr).thenReturn('oops'); + final tempDir = Directory.systemTemp.createTempSync(); + + final result = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(result, equals(ExitCode.software.code)); + verify( + () => shorebirdProcess.run( + 'flutter', + ['build', 'ipa', '--release'], + runInShell: any(named: 'runInShell'), + ), + ).called(1); + }); + + test('exits with code 0 when building ipa succeeds', () async { + when(() => processResult.exitCode).thenReturn(ExitCode.success.code); + final tempDir = Directory.systemTemp.createTempSync(); + final result = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(result, equals(ExitCode.success.code)); + + verify( + () => shorebirdProcess.run( + 'flutter', + ['build', 'ipa', '--release'], + runInShell: true, + ), + ).called(1); + + verifyInOrder([ + () => logger.info( + ''' +📦 Generated an xcode archive at: +${lightCyan.wrap("./build/ios/archive/Runner.xcarchive")}''', + ), + () => logger.info( + ''' +📦 Generated an ipa at: +${lightCyan.wrap("./build/ios/ipa/Runner.ipa")}''', + ), + ]); + }); + + test( + 'exits with code 0 when building ipa succeeds ' + 'with flavor and target', () async { + const flavor = 'development'; + const target = './lib/main_development.dart'; + when(() => argResults['flavor']).thenReturn(flavor); + when(() => argResults['target']).thenReturn(target); + when(() => processResult.exitCode).thenReturn(ExitCode.success.code); + final tempDir = Directory.systemTemp.createTempSync(); + final result = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(result, equals(ExitCode.success.code)); + + verify( + () => shorebirdProcess.run( + 'flutter', + [ + 'build', + 'ipa', + '--release', + '--flavor=$flavor', + '--target=$target', + ], + runInShell: true, + ), + ).called(1); + + verifyInOrder([ + () => logger.info( + ''' +📦 Generated an xcode archive at: +${lightCyan.wrap("./build/ios/archive/Runner.xcarchive")}''', + ), + () => logger.info( + ''' +📦 Generated an ipa at: +${lightCyan.wrap("./build/ios/ipa/Runner.ipa")}''', + ), + ]); + }); + + test( + 'exits with code 0 when building ipa succeeds ' + 'with --no-codesign', () async { + when(() => argResults['codesign']).thenReturn(false); + when(() => processResult.exitCode).thenReturn(ExitCode.success.code); + final tempDir = Directory.systemTemp.createTempSync(); + final result = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(result, equals(ExitCode.success.code)); + + verify( + () => shorebirdProcess.run( + 'flutter', + ['build', 'ipa', '--release', '--no-codesign'], + runInShell: true, + ), + ).called(1); + + verify( + () => logger.info( + ''' +📦 Generated an xcode archive at: +${lightCyan.wrap("./build/ios/archive/Runner.xcarchive")}''', + ), + ).called(1); + + verifyNever( + () => logger.info( + ''' +📦 Generated an ipa at: +${lightCyan.wrap("./build/ios/ipa/Runner.ipa")}''', + ), + ); + }); + + test('prints flutter validation warnings', () async { + when(() => flutterValidator.validate(any())).thenAnswer( + (_) async => [ + const ValidationIssue( + severity: ValidationIssueSeverity.warning, + message: 'Flutter issue 1', + ), + const ValidationIssue( + severity: ValidationIssueSeverity.warning, + message: 'Flutter issue 2', + ), + ], + ); + when(() => processResult.exitCode).thenReturn(ExitCode.success.code); + + final result = await command.run(); + + expect(result, equals(ExitCode.success.code)); + verify( + () => logger.info(any(that: contains('Flutter issue 1'))), + ).called(1); + verify( + () => logger.info(any(that: contains('Flutter issue 2'))), + ).called(1); + }); + + test('aborts if validation errors are present', () async { + when(() => flutterValidator.validate(any())).thenAnswer( + (_) async => [ + ValidationIssue( + severity: ValidationIssueSeverity.error, + message: 'There was an issue', + fix: () async {}, + ), + ], + ); + + final result = await command.run(); + + expect(result, equals(ExitCode.config.code)); + verify(() => logger.err('Aborting due to validation errors.')).called(1); + verify( + () => logger.info( + any( + that: stringContainsInOrder([ + 'issue can be fixed automatically', + 'shorebird doctor --fix', + ]), + ), + ), + ).called(1); + }); + }); +} From 720b2b280d96bd897209b9d8198b74a32316bf5a Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Tue, 23 May 2023 15:56:01 -0500 Subject: [PATCH 2/2] cleanup --- .../lib/src/commands/build/build_command.dart | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/shorebird_cli/lib/src/commands/build/build_command.dart b/packages/shorebird_cli/lib/src/commands/build/build_command.dart index 0f0397b5d..0029b04c1 100644 --- a/packages/shorebird_cli/lib/src/commands/build/build_command.dart +++ b/packages/shorebird_cli/lib/src/commands/build/build_command.dart @@ -1,16 +1,11 @@ import 'package:shorebird_cli/src/command.dart'; import 'package:shorebird_cli/src/commands/build/build.dart'; -import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; -import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; -import 'package:shorebird_cli/src/shorebird_validation_mixin.dart'; /// {@template build_command} -/// /// `shorebird build` /// Build a new release of your application. /// {@endtemplate} -class BuildCommand extends ShorebirdCommand - with ShorebirdValidationMixin, ShorebirdConfigMixin, ShorebirdBuildMixin { +class BuildCommand extends ShorebirdCommand { /// {@macro build_command} BuildCommand({required super.logger}) { addSubcommand(BuildApkCommand(logger: logger));