From 6c7473d5ea79dfa70c3b7dff34d244507d9e64ec Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Wed, 31 May 2023 17:21:48 -0400 Subject: [PATCH] feat(shorebird_cli): add shorebird build aar command (#570) --- .../lib/src/commands/build/build.dart | 1 + .../src/commands/build/build_aar_command.dart | 99 +++++++ .../lib/src/commands/build/build_command.dart | 1 + .../lib/src/shorebird_build_mixin.dart | 31 +++ .../build/build_aar_command_test.dart | 261 ++++++++++++++++++ 5 files changed, 393 insertions(+) create mode 100644 packages/shorebird_cli/lib/src/commands/build/build_aar_command.dart create mode 100644 packages/shorebird_cli/test/src/commands/build/build_aar_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 3db64a27..db5a810f 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_aar_command.dart'; export 'build_apk_command.dart'; export 'build_app_bundle_command.dart'; export 'build_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/build/build_aar_command.dart b/packages/shorebird_cli/lib/src/commands/build/build_aar_command.dart new file mode 100644 index 00000000..051b558f --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/build/build_aar_command.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; +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_aar_command} +/// +/// `shorebird build aar` +/// Build an Android aar file from your app. +/// {@endtemplate} +class BuildAarCommand extends ShorebirdCommand + with + AuthLoggerMixin, + ShorebirdValidationMixin, + ShorebirdConfigMixin, + ShorebirdBuildMixin { + BuildAarCommand({ + required super.logger, + super.auth, + }) { + // We would have a "target" option here, similar to what [BuildApkCommand] + // and [BuildAabCommand] have, but target cannot currently be configured in + // `flutter build aar` and is always assumed to be lib/main.dart. + argParser + ..addOption( + 'flavor', + help: 'The product flavor to use when building the app.', + ) + // `flutter build aar` defaults to a build number of 1.0, so we do the + // same. + ..addOption( + 'build-number', + help: 'The build number of the aar', + defaultsTo: '1.0', + ); + } + + @override + String get name => 'aar'; + + @override + String get description => 'Build an Android AAR file from your module.'; + + @override + Future run() async { + if (!auth.isAuthenticated) { + printNeedsAuthInstructions(); + return ExitCode.noUser.code; + } + + final pubspec = getPubspecYaml(); + if (pubspec == null) { + logger.err('No pubspec.yaml file found.'); + return ExitCode.config.code; + } + + final module = pubspec.flutter?['module'] as Map?; + final androidPackageName = module?['androidPackage'] as String?; + if (androidPackageName == null) { + logger.err('Could not find androidPackage in pubspec.yaml.'); + return ExitCode.config.code; + } + + final flavor = results['flavor'] as String?; + final buildNumber = results['build-number'] as String; + final buildProgress = logger.progress('Building aar'); + try { + await buildAar(buildNumber: buildNumber, flavor: flavor); + } on ProcessException catch (error) { + buildProgress.fail('Failed to build: ${error.message}'); + return ExitCode.software.code; + } + + buildProgress.complete(); + + final aarPath = p.joinAll([ + 'build', + 'host', + 'outputs', + 'repo', + ...androidPackageName.split('.'), + 'flutter_release', + buildNumber, + 'flutter_release-$buildNumber.aar', + ]); + + logger.info(''' +📦 Generated an aar at: +${lightCyan.wrap(aarPath)}'''); + + 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 index 0029b04c..b362d716 100644 --- a/packages/shorebird_cli/lib/src/commands/build/build_command.dart +++ b/packages/shorebird_cli/lib/src/commands/build/build_command.dart @@ -8,6 +8,7 @@ import 'package:shorebird_cli/src/commands/build/build.dart'; class BuildCommand extends ShorebirdCommand { /// {@macro build_command} BuildCommand({required super.logger}) { + addSubcommand(BuildAarCommand(logger: logger)); addSubcommand(BuildApkCommand(logger: logger)); addSubcommand(BuildAppBundleCommand(logger: logger)); addSubcommand(BuildIpaCommand(logger: logger)); diff --git a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart index f2bf9ec6..ab83b803 100644 --- a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart +++ b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart @@ -93,6 +93,37 @@ mixin ShorebirdBuildMixin on ShorebirdCommand { } } + Future buildAar({ + required String buildNumber, + String? flavor, + }) async { + const executable = 'flutter'; + final arguments = [ + 'build', + 'aar', + '--no-debug', + '--no-profile', + '--build-number=$buildNumber', + if (flavor != null) '--flavor=$flavor', + ...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, + ); + } + } + Future buildApk({String? flavor, String? target}) async { const executable = 'flutter'; final arguments = [ diff --git a/packages/shorebird_cli/test/src/commands/build/build_aar_command_test.dart b/packages/shorebird_cli/test/src/commands/build/build_aar_command_test.dart new file mode 100644 index 00000000..5c4da9e2 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/build/build_aar_command_test.dart @@ -0,0 +1,261 @@ +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +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:test/test.dart'; + +class _MockArgResults extends Mock implements ArgResults {} + +class _MockAuth extends Mock implements Auth {} + +class _MockLogger extends Mock implements Logger {} + +class _MockProcessResult extends Mock implements ShorebirdProcessResult {} + +class _MockProgress extends Mock implements Progress {} + +class _MockShorebirdProcess extends Mock implements ShorebirdProcess {} + +void main() { + group(BuildAarCommand, () { + const buildNumber = '1.0'; + const noModulePubspecYamlContent = ''' +name: example +version: 1.0.0 +environment: + sdk: ">=2.19.0 <3.0.0" + +flutter: + assets: + - shorebird.yaml'''; + + const pubspecYamlContent = ''' +name: example +version: 1.0.0 +environment: + sdk: ">=2.19.0 <3.0.0" + +flutter: + module: + androidX: true + androidPackage: com.example.my_flutter_module + iosBundleIdentifier: com.example.myFlutterModule + assets: + - shorebird.yaml'''; + + late ArgResults argResults; + late Auth auth; + late Logger logger; + late Progress progress; + late ShorebirdProcess shorebirdProcess; + late ShorebirdProcessResult processResult; + + late BuildAarCommand command; + + Directory setUpTempDir({bool includeModule = true}) { + final tempDir = Directory.systemTemp.createTempSync(); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync( + includeModule ? pubspecYamlContent : noModulePubspecYamlContent, + ); + return tempDir; + } + + setUp(() { + argResults = _MockArgResults(); + auth = _MockAuth(); + logger = _MockLogger(); + processResult = _MockProcessResult(); + progress = _MockProgress(); + shorebirdProcess = _MockShorebirdProcess(); + + command = BuildAarCommand( + auth: auth, + logger: logger, + ) + ..testArgResults = argResults + ..testProcess = shorebirdProcess + ..testEngineConfig = const EngineConfig.empty(); + + when(() => argResults['build-number']).thenReturn(buildNumber); + when(() => argResults.rest).thenReturn([]); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => logger.progress(any())).thenReturn(progress); + + when( + () => shorebirdProcess.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((invocation) async { + return processResult; + }); + }); + + 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 78 if no pubspec.yaml exists', () async { + final tempDir = Directory.systemTemp.createTempSync(); + final result = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(result, ExitCode.config.code); + }); + + test('exits with 78 if no module entry exists in pubspec.yaml', () async { + final tempDir = setUpTempDir(includeModule: false); + final result = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(result, ExitCode.config.code); + }); + + test('exits with code 70 when building aar fails', () async { + when(() => processResult.exitCode).thenReturn(1); + when(() => processResult.stderr).thenReturn('oops'); + final tempDir = setUpTempDir(); + + final result = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(result, equals(ExitCode.software.code)); + verify( + () => shorebirdProcess.run( + 'flutter', + [ + 'build', + 'aar', + '--no-debug', + '--no-profile', + '--build-number=$buildNumber', + ], + runInShell: any(named: 'runInShell'), + ), + ).called(1); + verify(() => progress.fail(any(that: contains('Failed to build')))) + .called(1); + }); + + test('exits with code 0 when building aar succeeds', () async { + when(() => processResult.exitCode).thenReturn(ExitCode.success.code); + final tempDir = setUpTempDir(); + final result = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(result, equals(ExitCode.success.code)); + + verify( + () => shorebirdProcess.run( + 'flutter', + [ + 'build', + 'aar', + '--no-debug', + '--no-profile', + '--build-number=$buildNumber', + ], + runInShell: any(named: 'runInShell'), + ), + ).called(1); + verify( + () => logger.info( + ''' +📦 Generated an aar at: +${lightCyan.wrap( + p.join( + 'build', + 'host', + 'outputs', + 'repo', + 'com', + 'example', + 'my_flutter_module', + 'flutter_release', + buildNumber, + 'flutter_release-$buildNumber.aar', + ), + )}''', + ), + ).called(1); + }); + + test( + '''exits with code 0 when building aar succeeds with flavor and custom build number''', + () async { + const flavor = 'development'; + when(() => argResults['flavor']).thenReturn(flavor); + when(() => argResults['build-number']).thenReturn('2.0'); + when(() => processResult.exitCode).thenReturn(ExitCode.success.code); + final tempDir = setUpTempDir(); + final result = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(result, equals(ExitCode.success.code)); + + verify( + () => shorebirdProcess.run( + 'flutter', + [ + 'build', + 'aar', + '--no-debug', + '--no-profile', + '--build-number=2.0', + '--flavor=$flavor', + ], + runInShell: any(named: 'runInShell'), + ), + ).called(1); + verify( + () => logger.info( + ''' +📦 Generated an aar at: +${lightCyan.wrap( + p.join( + 'build', + 'host', + 'outputs', + 'repo', + 'com', + 'example', + 'my_flutter_module', + 'flutter_release', + '2.0', + 'flutter_release-2.0.aar', + ), + )}''', + ), + ).called(1); + }); + }); +}