diff --git a/TRUSTED_TESTERS.md b/TRUSTED_TESTERS.md index fa46a403..ca96d4dc 100644 --- a/TRUSTED_TESTERS.md +++ b/TRUSTED_TESTERS.md @@ -342,7 +342,7 @@ variable inside Dart as you might have done with `flutter build` directly. Success should look like this: ``` -% shorebird build +% shorebird build appbundle ✓ Building shorebird engine (8.0s) ✓ Building release (6.4s) ``` diff --git a/packages/shorebird_cli/.gitignore b/packages/shorebird_cli/.gitignore index 9f6ee8a7..6d766968 100644 --- a/packages/shorebird_cli/.gitignore +++ b/packages/shorebird_cli/.gitignore @@ -3,7 +3,7 @@ # Files and directories created by pub .dart_tool/ .packages -build/ +/build/ pubspec.lock # Files generated during tests diff --git a/packages/shorebird_cli/README.md b/packages/shorebird_cli/README.md index 95162d77..ac1d1d5e 100644 --- a/packages/shorebird_cli/README.md +++ b/packages/shorebird_cli/README.md @@ -274,7 +274,11 @@ Would you like to continue? (y/N) Yes Build a new release of your application using the `shorebird build` command: ```bash -shorebird build +# Build an AppBundle +shorebird build appbundle + +# Build an APK +shorebird build apk ``` ### List Channels diff --git a/packages/shorebird_cli/lib/src/commands/build/build.dart b/packages/shorebird_cli/lib/src/commands/build/build.dart new file mode 100644 index 00000000..4414cbd8 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/build/build.dart @@ -0,0 +1,3 @@ +export 'build_apk_command.dart'; +export 'build_app_bundle_command.dart.dart'; +export 'build_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/build_command.dart b/packages/shorebird_cli/lib/src/commands/build/build_apk_command.dart similarity index 71% rename from packages/shorebird_cli/lib/src/commands/build_command.dart rename to packages/shorebird_cli/lib/src/commands/build/build_apk_command.dart index 877dee8a..ddbaef08 100644 --- a/packages/shorebird_cli/lib/src/commands/build_command.dart +++ b/packages/shorebird_cli/lib/src/commands/build/build_apk_command.dart @@ -6,27 +6,26 @@ import 'package:shorebird_cli/src/flutter_validation_mixin.dart'; import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; -/// {@template build_command} +/// {@template build_apk_command} /// -/// `shorebird build` -/// Build a new release of your application. +/// `shorebird build apk` +/// Build an Android APK file from your app. /// {@endtemplate} -class BuildCommand extends ShorebirdCommand +class BuildApkCommand extends ShorebirdCommand with ShorebirdValidationMixin, ShorebirdConfigMixin, ShorebirdBuildMixin { - /// {@macro build_command} - BuildCommand({ + /// {@macro build_apk_command} + BuildApkCommand({ required super.logger, super.auth, - super.buildCodePushClient, super.runProcess, super.validators, }); @override - String get description => 'Build a new release of your application.'; + String get description => 'Build an Android APK file from your app.'; @override - String get name => 'build'; + String get name => 'apk'; @override Future run() async { @@ -39,9 +38,9 @@ class BuildCommand extends ShorebirdCommand await logValidationIssues(); - final buildProgress = logger.progress('Building release '); + final buildProgress = logger.progress('Building apk'); try { - await buildRelease(); + await buildApk(); buildProgress.complete(); } on ProcessException catch (error) { buildProgress.fail('Failed to build: ${error.message}'); diff --git a/packages/shorebird_cli/lib/src/commands/build/build_app_bundle_command.dart.dart b/packages/shorebird_cli/lib/src/commands/build/build_app_bundle_command.dart.dart new file mode 100644 index 00000000..40126c4d --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/build/build_app_bundle_command.dart.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/flutter_validation_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; + +/// {@template build_app_bundle_command} +/// +/// `shorebird build appbundle` +/// Build an Android App Bundle file from your app. +/// {@endtemplate} +class BuildAppBundleCommand extends ShorebirdCommand + with ShorebirdValidationMixin, ShorebirdConfigMixin, ShorebirdBuildMixin { + /// {@macro build_app_bundle_command} + BuildAppBundleCommand({ + required super.logger, + super.auth, + super.runProcess, + super.validators, + }); + + @override + String get description => 'Build an Android App Bundle file from your app.'; + + @override + String get name => 'appbundle'; + + @override + Future run() async { + if (!auth.isAuthenticated) { + logger + ..err('You must be logged in to build.') + ..err("Run 'shorebird login' to log in and try again."); + return ExitCode.noUser.code; + } + + await logValidationIssues(); + + final buildProgress = logger.progress('Building appbundle'); + try { + await buildAppBundle(); + buildProgress.complete(); + } on ProcessException catch (error) { + buildProgress.fail('Failed to build: ${error.message}'); + return ExitCode.software.code; + } + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/lib/src/commands/build/build_command.dart b/packages/shorebird_cli/lib/src/commands/build/build_command.dart new file mode 100644 index 00000000..fd0a8b69 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/build/build_command.dart @@ -0,0 +1,39 @@ +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/commands/build/build.dart'; +import 'package:shorebird_cli/src/flutter_validation_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; + +/// {@template build_command} +/// +/// `shorebird build` +/// Build a new release of your application. +/// {@endtemplate} +class BuildCommand extends ShorebirdCommand + with ShorebirdValidationMixin, ShorebirdConfigMixin, ShorebirdBuildMixin { + /// {@macro build_command} + BuildCommand({required super.logger}) { + addSubcommand( + BuildApkCommand( + auth: auth, + logger: logger, + runProcess: runProcess, + validators: validators, + ), + ); + addSubcommand( + BuildAppBundleCommand( + auth: auth, + logger: logger, + runProcess: runProcess, + validators: validators, + ), + ); + } + + @override + String get description => 'Build a new release of your application.'; + + @override + String get name => 'build'; +} diff --git a/packages/shorebird_cli/lib/src/commands/commands.dart b/packages/shorebird_cli/lib/src/commands/commands.dart index ce7f0325..02eec28a 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -1,6 +1,6 @@ export 'account_command.dart'; export 'apps/apps.dart'; -export 'build_command.dart'; +export 'build/build_command.dart'; export 'cache/cache.dart'; export 'channels/channels.dart'; export 'doctor_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patch_command.dart b/packages/shorebird_cli/lib/src/commands/patch_command.dart index 639d394e..01614536 100644 --- a/packages/shorebird_cli/lib/src/commands/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch_command.dart @@ -125,7 +125,7 @@ class PatchCommand extends ShorebirdCommand final buildProgress = logger.progress('Building patch'); try { - await buildRelease(); + await buildAppBundle(); buildProgress.complete(); } on ProcessException catch (error) { buildProgress.fail('Failed to build: ${error.message}'); diff --git a/packages/shorebird_cli/lib/src/commands/release_command.dart b/packages/shorebird_cli/lib/src/commands/release_command.dart index b06b4f94..e350e9b7 100644 --- a/packages/shorebird_cli/lib/src/commands/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release_command.dart @@ -74,7 +74,7 @@ make smaller updates to your app. final buildProgress = logger.progress('Building release'); try { - await buildRelease(); + await buildAppBundle(); buildProgress.complete(); } on ProcessException catch (error) { buildProgress.fail('Failed to build: ${error.message}'); diff --git a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart index 8af7d7b6..5f2ea16b 100644 --- a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart +++ b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart @@ -33,12 +33,10 @@ mixin ShorebirdBuildMixin on ShorebirdCommand { ), }; - Future buildRelease() async { + Future buildAppBundle() async { const executable = 'flutter'; final arguments = [ 'build', - // This is temporary because the Shorebird engine currently - // only supports Android. 'appbundle', '--release', ...results.rest, @@ -59,4 +57,29 @@ mixin ShorebirdBuildMixin on ShorebirdCommand { ); } } + + Future buildApk() async { + const executable = 'flutter'; + final arguments = [ + 'build', + 'apk', + '--release', + ...results.rest, + ]; + + final result = await runProcess( + 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_command_test.dart b/packages/shorebird_cli/test/src/commands/build/build_apk_command_test.dart similarity index 79% rename from packages/shorebird_cli/test/src/commands/build_command_test.dart rename to packages/shorebird_cli/test/src/commands/build/build_apk_command_test.dart index 8673b82f..54da7752 100644 --- a/packages/shorebird_cli/test/src/commands/build_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/build/build_apk_command_test.dart @@ -5,9 +5,8 @@ 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_command.dart'; +import 'package:shorebird_cli/src/commands/build/build.dart'; import 'package:shorebird_cli/src/validators/validators.dart'; -import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; import 'package:test/test.dart'; class _MockArgResults extends Mock implements ArgResults {} @@ -22,38 +21,33 @@ class _MockProgress extends Mock implements Progress {} class _MockProcessResult extends Mock implements ProcessResult {} -class _MockCodePushClient extends Mock implements CodePushClient {} - class _MockShorebirdFlutterValidator extends Mock implements ShorebirdFlutterValidator {} void main() { - group('build', () { + group('build apk', () { late ArgResults argResults; late http.Client httpClient; late Auth auth; - late CodePushClient codePushClient; late Logger logger; late ProcessResult processResult; - late BuildCommand buildCommand; + late BuildApkCommand command; late ShorebirdFlutterValidator flutterValidator; + String? processExecutable; + List? processArguments; + setUp(() { argResults = _MockArgResults(); httpClient = _MockHttpClient(); auth = _MockAuth(); - codePushClient = _MockCodePushClient(); logger = _MockLogger(); processResult = _MockProcessResult(); flutterValidator = _MockShorebirdFlutterValidator(); - buildCommand = BuildCommand( + processExecutable = null; + processArguments = null; + command = BuildApkCommand( auth: auth, - buildCodePushClient: ({ - required http.Client httpClient, - Uri? hostedUri, - }) { - return codePushClient; - }, logger: logger, runProcess: ( executable, @@ -63,6 +57,8 @@ void main() { String? workingDirectory, bool useVendedFlutter = true, }) async { + processExecutable = executable; + processArguments = arguments; return processResult; }, validators: [flutterValidator], @@ -76,10 +72,14 @@ void main() { when(() => flutterValidator.validate()).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 buildCommand.run(); + final result = await command.run(); expect(result, equals(ExitCode.noUser.code)); verify(() => logger.err('You must be logged in to build.')).called(1); @@ -88,28 +88,32 @@ void main() { ).called(1); }); - test('exits with code 70 when building fails', () async { + test('exits with code 70 when building apk fails', () async { when(() => processResult.exitCode).thenReturn(1); when(() => processResult.stderr).thenReturn('oops'); final tempDir = Directory.systemTemp.createTempSync(); final result = await IOOverrides.runZoned( - () async => buildCommand.run(), + () async => command.run(), getCurrentDirectory: () => tempDir, ); expect(result, equals(ExitCode.software.code)); + expect(processExecutable, equals('flutter')); + expect(processArguments, equals(['build', 'apk', '--release'])); }); - test('exits with code 0 when building succeeds', () async { + test('exits with code 0 when building apk succeeds', () async { when(() => processResult.exitCode).thenReturn(ExitCode.success.code); final tempDir = Directory.systemTemp.createTempSync(); final result = await IOOverrides.runZoned( - () async => buildCommand.run(), + () async => command.run(), getCurrentDirectory: () => tempDir, ); expect(result, equals(ExitCode.success.code)); + expect(processExecutable, equals('flutter')); + expect(processArguments, equals(['build', 'apk', '--release'])); }); test('prints flutter validation warnings', () async { @@ -127,7 +131,7 @@ void main() { ); when(() => processResult.exitCode).thenReturn(ExitCode.success.code); - final result = await buildCommand.run(); + final result = await command.run(); expect(result, equals(ExitCode.success.code)); verify( diff --git a/packages/shorebird_cli/test/src/commands/build/build_app_bundle_command_test.dart b/packages/shorebird_cli/test/src/commands/build/build_app_bundle_command_test.dart new file mode 100644 index 00000000..0fb529fd --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/build/build_app_bundle_command_test.dart @@ -0,0 +1,145 @@ +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/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 ProcessResult {} + +class _MockShorebirdFlutterValidator extends Mock + implements ShorebirdFlutterValidator {} + +void main() { + group('build appbundle', () { + late ArgResults argResults; + late http.Client httpClient; + late Auth auth; + late Logger logger; + late ProcessResult processResult; + late BuildAppBundleCommand command; + late ShorebirdFlutterValidator flutterValidator; + + String? processExecutable; + List? processArguments; + + setUp(() { + argResults = _MockArgResults(); + httpClient = _MockHttpClient(); + auth = _MockAuth(); + logger = _MockLogger(); + processResult = _MockProcessResult(); + flutterValidator = _MockShorebirdFlutterValidator(); + processExecutable = null; + processArguments = null; + command = BuildAppBundleCommand( + auth: auth, + logger: logger, + runProcess: ( + executable, + arguments, { + bool runInShell = false, + Map? environment, + String? workingDirectory, + bool useVendedFlutter = true, + }) async { + processExecutable = executable; + processArguments = arguments; + return processResult; + }, + validators: [flutterValidator], + )..testArgResults = argResults; + + 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()).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('You must be logged in to build.')).called(1); + verify( + () => logger.err("Run 'shorebird login' to log in and try again."), + ).called(1); + }); + + test('exits with code 70 when building appbundle 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)); + expect(processExecutable, equals('flutter')); + expect(processArguments, equals(['build', 'appbundle', '--release'])); + }); + + test('exits with code 0 when building appbundle 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)); + expect(processExecutable, equals('flutter')); + expect(processArguments, equals(['build', 'appbundle', '--release'])); + }); + + test('prints flutter validation warnings', () async { + when(() => flutterValidator.validate()).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); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/build/build_command_test.dart b/packages/shorebird_cli/test/src/commands/build/build_command_test.dart new file mode 100644 index 00000000..ebf54968 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/build/build_command_test.dart @@ -0,0 +1,22 @@ +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shorebird_cli/src/commands/build/build.dart'; +import 'package:test/test.dart'; + +class _MockLogger extends Mock implements Logger {} + +void main() { + group('build', () { + late Logger logger; + late BuildCommand command; + + setUp(() { + logger = _MockLogger(); + command = BuildCommand(logger: logger); + }); + + test('has a description', () async { + expect(command.description, isNotEmpty); + }); + }); +}