diff --git a/packages/shorebird_cli/lib/src/command.dart b/packages/shorebird_cli/lib/src/command.dart index 66eeef774..454347c51 100644 --- a/packages/shorebird_cli/lib/src/command.dart +++ b/packages/shorebird_cli/lib/src/command.dart @@ -15,6 +15,9 @@ import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; /// Signature for a function which takes a list of bytes and returns a hash. typedef HashFunction = String Function(List bytes); +/// Signature for a function which takes a path to a zip file. +typedef UnzipFn = Future Function(String zipFilePath, String outputDir); + typedef CodePushClientBuilder = CodePushClient Function({ required http.Client httpClient, Uri? hostedUri, diff --git a/packages/shorebird_cli/lib/src/commands/release/release.dart b/packages/shorebird_cli/lib/src/commands/release/release.dart index a1f36e4a0..9735e6d5b 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release.dart @@ -1,3 +1,4 @@ +export 'release_android_archive_command.dart'; export 'release_android_command.dart'; export 'release_command.dart'; export 'release_ios_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/release/release_android_archive_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_android_archive_command.dart new file mode 100644 index 000000000..6de1e4686 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/release/release_android_archive_command.dart @@ -0,0 +1,310 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:collection/collection.dart'; +import 'package:crypto/crypto.dart'; +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/config/config.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_create_app_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_java_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_release_version_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_validation_mixin.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template release_android_archive_command} +/// `shorebird release android_archive` +/// Create new Android archive releases. +/// {@endtemplate} +class ReleaseAndroidArchiveCommand extends ShorebirdCommand + with + AuthLoggerMixin, + ShorebirdValidationMixin, + ShorebirdConfigMixin, + ShorebirdBuildMixin, + ShorebirdCreateAppMixin, + ShorebirdJavaMixin, + ShorebirdReleaseVersionMixin { + /// {@macro release_android_archive_command} + ReleaseAndroidArchiveCommand({ + required super.logger, + super.auth, + super.buildCodePushClient, + super.validators, + HashFunction? hashFn, + UnzipFn? unzipFn, + }) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()), + _unzipFn = unzipFn ?? extractFileToDisk { + 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', + ) + ..addFlag( + 'force', + abbr: 'f', + help: 'Release without confirmation if there are no errors.', + negatable: false, + ); + } + + @override + String get name => 'android_archive'; + + @override + String get description => ''' +Builds and submits your Android archive to Shorebird. +Shorebird saves the compiled Dart code from your application in order to +make smaller updates to your app. +'''; + + final HashFunction _hashFn; + final UnzipFn _unzipFn; + + @override + Future run() async { + if (!isShorebirdInitialized) { + logger.err( + 'Shorebird is not initialized. Did you run "shorebird init"?', + ); + return ExitCode.config.code; + } + + if (!auth.isAuthenticated) { + printNeedsAuthInstructions(); + return ExitCode.noUser.code; + } + + final validationIssues = await runValidators(); + if (validationIssuesContainsError(validationIssues)) { + logValidationFailure(issues: validationIssues); + return ExitCode.config.code; + } + + // We know the pubspec exists due to the call to isShorebirdInitialized + // above. + final pubspec = getPubspecYaml()!; + 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 shorebirdYaml = getShorebirdYaml()!; + final codePushClient = buildCodePushClient( + httpClient: auth.client, + hostedUri: hostedUri, + ); + + late final List apps; + final fetchAppsProgress = logger.progress('Fetching apps'); + try { + apps = (await codePushClient.getApps()) + .map((a) => App(id: a.appId, displayName: a.displayName)) + .toList(); + fetchAppsProgress.complete(); + } catch (error) { + fetchAppsProgress.fail('$error'); + return ExitCode.software.code; + } + + final appId = shorebirdYaml.getAppId(flavor: flavor); + final app = apps.firstWhereOrNull((a) => a.id == appId); + if (app == null) { + logger.err( + ''' +Could not find app with id: "$appId". +Did you forget to run "shorebird init"?''', + ); + return ExitCode.software.code; + } + + final releaseVersion = buildNumber; + const platform = 'android'; + final archNames = architectures.keys.map( + (arch) => arch.name, + ); + final summary = [ + '''šŸ“± App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.id})')}''', + if (flavor != null) 'šŸ§ Flavor: ${lightCyan.wrap(flavor)}', + 'šŸ“¦ Release Version: ${lightCyan.wrap(releaseVersion)}', + '''šŸ•¹ļø Platform: ${lightCyan.wrap(platform)} ${lightCyan.wrap('(${archNames.join(', ')})')}''', + ]; + + logger.info(''' + +${styleBold.wrap(lightGreen.wrap('šŸš€ Ready to create a new release!'))} + +${summary.join('\n')} +'''); + + final force = results['force'] == true; + final needConfirmation = !force; + if (needConfirmation) { + final confirm = logger.confirm('Would you like to continue?'); + + if (!confirm) { + logger.info('Aborting.'); + return ExitCode.success.code; + } + } + + late final List releases; + final fetchReleasesProgress = logger.progress('Fetching releases'); + try { + releases = await codePushClient.getReleases(appId: app.id); + fetchReleasesProgress.complete(); + } catch (error) { + fetchReleasesProgress.fail('$error'); + return ExitCode.software.code; + } + + var release = releases.firstWhereOrNull((r) => r.version == releaseVersion); + if (release == null) { + final flutterRevisionProgress = logger.progress( + 'Fetching Flutter revision', + ); + final String shorebirdFlutterRevision; + try { + shorebirdFlutterRevision = await getShorebirdFlutterRevision(); + flutterRevisionProgress.complete(); + } catch (error) { + flutterRevisionProgress.fail('$error'); + return ExitCode.software.code; + } + + final createReleaseProgress = logger.progress('Creating release'); + try { + release = await codePushClient.createRelease( + appId: app.id, + version: releaseVersion, + flutterRevision: shorebirdFlutterRevision, + ); + createReleaseProgress.complete(); + } catch (error) { + createReleaseProgress.fail('$error'); + return ExitCode.software.code; + } + } + + final createArtifactProgress = logger.progress('Creating artifacts'); + final aarDir = p.joinAll([ + 'build', + 'host', + 'outputs', + 'repo', + ...androidPackageName.split('.'), + 'flutter_release', + buildNumber, + ]); + final aarPath = p.join( + aarDir, + 'flutter_release-$buildNumber.aar', + ); + final zipPath = p.join( + aarDir, + 'flutter_release-$buildNumber.zip', + ); + + // Copy the .aar file to a .zip file so package:archive knows how to read it + File(aarPath).copySync(zipPath); + final extractedAarDir = p.join(aarDir, 'flutter_release-$buildNumber'); + // Unzip the .zip file to a directory so we can read the .so files + await _unzipFn(zipPath, extractedAarDir); + + for (final archMetadata in architectures.values) { + final artifactPath = p.join( + extractedAarDir, + 'jni', + archMetadata.path, + 'libapp.so', + ); + final artifact = File(artifactPath); + final hash = _hashFn(await artifact.readAsBytes()); + logger.detail('Creating artifact for $artifactPath'); + + try { + await codePushClient.createReleaseArtifact( + releaseId: release.id, + artifactPath: artifact.path, + arch: archMetadata.arch, + platform: platform, + hash: hash, + ); + } on CodePushConflictException catch (_) { + // Newlines are due to how logger.info interacts with logger.progress. + logger.info( + ''' + +${archMetadata.arch} artifact already exists, continuing...''', + ); + } catch (error) { + createArtifactProgress.fail('Error uploading ${artifact.path}: $error'); + return ExitCode.software.code; + } + } + + try { + logger.detail('Creating artifact for $aarPath'); + await codePushClient.createReleaseArtifact( + releaseId: release.id, + artifactPath: aarPath, + arch: 'aar', + platform: platform, + hash: _hashFn(await File(aarPath).readAsBytes()), + ); + } on CodePushConflictException catch (_) { + // Newlines are due to how logger.info interacts with logger.progress. + logger.info( + ''' + +aar artifact already exists, continuing...''', + ); + } catch (error) { + createArtifactProgress.fail('Error uploading $aarPath: $error'); + return ExitCode.software.code; + } + + createArtifactProgress.complete(); + + logger + ..success('\nāœ… Published Release!') + ..info(''' + +Your next step is to add this module as a dependency in your app's build.gradle: +${lightCyan.wrap(''' +dependencies { + // ... + releaseImplementation '$androidPackageName:flutter_release:$buildNumber' + // ... +}''')} +'''); + + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/lib/src/commands/release/release_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_command.dart index 4e12b373c..dd37380c3 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_command.dart @@ -8,6 +8,7 @@ import 'package:shorebird_cli/src/commands/commands.dart'; class ReleaseCommand extends ShorebirdCommand { /// {@macro release_command} ReleaseCommand({required super.logger}) { + addSubcommand(ReleaseAndroidArchiveCommand(logger: logger)); addSubcommand(ReleaseAndroidCommand(logger: logger)); addSubcommand(ReleaseIosCommand(logger: logger)); } diff --git a/packages/shorebird_cli/lib/src/validators/android_internet_permission_validator.dart b/packages/shorebird_cli/lib/src/validators/android_internet_permission_validator.dart index e0c9e1e92..5d111ff30 100644 --- a/packages/shorebird_cli/lib/src/validators/android_internet_permission_validator.dart +++ b/packages/shorebird_cli/lib/src/validators/android_internet_permission_validator.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:shorebird_cli/src/validators/validators.dart'; @@ -27,16 +28,22 @@ class AndroidInternetPermissionValidator extends Validator { @override Future> validate(ShorebirdProcess process) async { const manifestFileName = 'AndroidManifest.xml'; - final androidSrcDir = Directory( + final androidSrcDir = [ p.join( Directory.current.path, 'android', 'app', 'src', ), - ); + p.join( + Directory.current.path, + '.android', + 'Flutter', + 'src', + ), + ].map(Directory.new).firstWhereOrNull((dir) => dir.existsSync()); - if (!androidSrcDir.existsSync()) { + if (androidSrcDir == null) { return [ const ValidationIssue( severity: ValidationIssueSeverity.error, diff --git a/packages/shorebird_cli/test/src/commands/release/release_android_archive_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_android_archive_command_test.dart new file mode 100644 index 000000000..b726550fa --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/release/release_android_archive_command_test.dart @@ -0,0 +1,677 @@ +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:path/path.dart' as p; +import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_process.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 {} + +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 _MockCodePushClient extends Mock implements CodePushClient {} + +class _MockShorebirdFlutterValidator extends Mock + implements ShorebirdFlutterValidator {} + +class _MockShorebirdProcess extends Mock implements ShorebirdProcess {} + +void main() { + group(ReleaseAndroidArchiveCommand, () { + const appDisplayName = 'Test App'; + const appId = 'test-app-id'; + const appMetadata = AppMetadata(appId: appId, displayName: appDisplayName); + + const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; + + const versionName = '1.2.3'; + const versionCode = '1'; + const version = '$versionName+$versionCode'; + const release = Release( + id: 0, + appId: appId, + version: version, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + ); + + const arch = 'aarch64'; + const platform = 'android'; + const releaseArtifact = ReleaseArtifact( + id: 0, + releaseId: 0, + arch: arch, + platform: platform, + hash: '#', + size: 42, + url: 'https://example.com', + ); + + 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 http.Client httpClient; + late Auth auth; + late Progress progress; + late Logger logger; + late ShorebirdProcessResult flutterBuildProcessResult; + late ShorebirdProcessResult flutterRevisionProcessResult; + late CodePushClient codePushClient; + late ReleaseAndroidArchiveCommand command; + late Uri? capturedHostedUri; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + + Directory setUpTempDir({bool includeModule = true}) { + final tempDir = Directory.systemTemp.createTempSync(); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync( + includeModule ? pubspecYamlContent : noModulePubspecYamlContent, + ); + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync('app_id: $appId'); + return tempDir; + } + + void setUpTempArtifacts(Directory dir) { + final aarDir = p.join( + 'build', + 'host', + 'outputs', + 'repo', + 'com', + 'example', + 'my_flutter_module', + 'flutter_release', + buildNumber, + ); + final aarPath = p.join(aarDir, 'flutter_release-$buildNumber.aar'); + for (final archMetadata + in ShorebirdBuildMixin.allAndroidArchitectures.values) { + final artifactPath = p.join( + aarDir, + 'flutter_release-$buildNumber', + 'jni', + archMetadata.path, + 'libapp.so', + ); + File(artifactPath).createSync(recursive: true); + } + File(aarPath).createSync(recursive: true); + } + + setUp(() { + argResults = _MockArgResults(); + httpClient = _MockHttpClient(); + auth = _MockAuth(); + progress = _MockProgress(); + logger = _MockLogger(); + flutterBuildProcessResult = _MockProcessResult(); + flutterRevisionProcessResult = _MockProcessResult(); + codePushClient = _MockCodePushClient(); + flutterValidator = _MockShorebirdFlutterValidator(); + shorebirdProcess = _MockShorebirdProcess(); + command = ReleaseAndroidArchiveCommand( + auth: auth, + buildCodePushClient: ({ + required http.Client httpClient, + Uri? hostedUri, + }) { + capturedHostedUri = hostedUri; + return codePushClient; + }, + unzipFn: (_, __) async {}, + logger: logger, + validators: [flutterValidator], + ) + ..testArgResults = argResults + ..testProcess = shorebirdProcess + ..testEngineConfig = const EngineConfig.empty(); + + registerFallbackValue(shorebirdProcess); + + when(() => auth.client).thenReturn(httpClient); + when(() => argResults['build-number']).thenReturn(buildNumber); + when(() => argResults.rest).thenReturn([]); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => logger.confirm(any())).thenReturn(true); + when(() => logger.progress(any())).thenReturn(progress); + + when(() => flutterBuildProcessResult.exitCode).thenReturn(0); + + when( + () => flutterRevisionProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => flutterRevisionProcessResult.stdout, + ).thenReturn(flutterRevision); + + when( + () => shorebirdProcess.run( + any(), + any(that: containsAll(['build', 'aar'])), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((invocation) async { + return flutterBuildProcessResult; + }); + when( + () => shorebirdProcess.run( + 'git', + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => flutterRevisionProcessResult); + + when( + () => codePushClient.getApps(), + ).thenAnswer((_) async => [appMetadata]); + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => [release]); + when( + () => codePushClient.createRelease( + appId: any(named: 'appId'), + version: any(named: 'version'), + flutterRevision: any(named: 'flutterRevision'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + ), + ).thenAnswer((_) async => releaseArtifact); + + when(() => flutterValidator.validate(any())).thenAnswer((_) async => []); + }); + + test('has correct description', () { + expect(command.description, isNotEmpty); + }); + + test('throws config error when shorebird is not initialized', () async { + final tempDir = Directory.systemTemp.createTempSync(); + + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + + verify( + () => logger.err( + 'Shorebird is not initialized. Did you run "shorebird init"?', + ), + ).called(1); + expect(exitCode, ExitCode.config.code); + }); + + test('exits with no user when not logged in', () async { + when(() => auth.isAuthenticated).thenReturn(false); + final tempDir = setUpTempDir(includeModule: false); + + final result = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + 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(() => flutterBuildProcessResult.exitCode).thenReturn(1); + when(() => flutterBuildProcessResult.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('throws error when fetching apps fails.', () async { + const error = 'something went wrong'; + when(() => codePushClient.getApps()).thenThrow(error); + final tempDir = setUpTempDir(); + + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + + verify(() => progress.fail(error)).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('throws error when app does not exist.', () async { + when( + () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), + ).thenReturn(appDisplayName); + when(() => codePushClient.getApps()).thenAnswer((_) async => []); + final tempDir = setUpTempDir(); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => logger.err( + ''' +Could not find app with id: "$appId". +Did you forget to run "shorebird init"?''', + ), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('aborts when user opts out', () async { + when(() => logger.confirm(any())).thenReturn(false); + when( + () => logger.prompt( + 'What is the version of this release?', + defaultValue: any(named: 'defaultValue'), + ), + ).thenAnswer((_) => '1.0.0'); + final tempDir = setUpTempDir(); + // setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, ExitCode.success.code); + verify(() => logger.info('Aborting.')).called(1); + }); + + test('throws error when fetching releases fails.', () async { + const error = 'something went wrong'; + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenThrow(error); + final tempDir = setUpTempDir(); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => progress.fail(error)).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('throws error when unable to detect flutter revision', () async { + const error = 'oops'; + when(() => flutterRevisionProcessResult.exitCode).thenReturn(1); + when(() => flutterRevisionProcessResult.stderr).thenReturn(error); + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + final tempDir = setUpTempDir(); + // setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, ExitCode.software.code); + verify( + () => progress.fail( + 'Exception: Unable to determine flutter revision: $error', + ), + ).called(1); + }); + + test('throws error when creating release fails.', () async { + const error = 'something went wrong'; + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + when( + () => codePushClient.createRelease( + appId: any(named: 'appId'), + version: any(named: 'version'), + flutterRevision: any(named: 'flutterRevision'), + displayName: any(named: 'displayName'), + ), + ).thenThrow(error); + final tempDir = setUpTempDir(); + // setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => progress.fail(error)).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('logs message when uploading release artifact that already exists.', + () async { + const error = 'something went wrong'; + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + when( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + ), + ).thenThrow(const CodePushConflictException(message: error)); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + + // 1 for each arch, 1 for the aar + final numArtifactsUploaded = Arch.values.length + 1; + verify( + () => logger.info(any(that: contains('already exists'))), + ).called(numArtifactsUploaded); + verifyNever(() => progress.fail(error)); + expect(exitCode, ExitCode.success.code); + }); + + test('logs message when uploading aar that already exists.', () async { + const error = 'something went wrong'; + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + when( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath', that: endsWith('.aar')), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + ), + ).thenThrow(const CodePushConflictException(message: error)); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + + verify( + () => logger.info( + any(that: contains('aar artifact already exists, continuing...')), + ), + ).called(1); + verifyNever(() => progress.fail(error)); + expect(exitCode, ExitCode.success.code); + }); + + test('throws error when uploading release artifact fails.', () async { + const error = 'something went wrong'; + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + when( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + ), + ).thenThrow(error); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + + verify( + () => progress + .fail(any(that: stringContainsInOrder(['libapp.so', error]))), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('throws error when uploading aar fails', () async { + const error = 'something went wrong'; + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + when( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath', that: endsWith('.aar')), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + ), + ).thenThrow(error); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + + verify( + () => progress.fail( + any( + that: stringContainsInOrder( + ['flutter_release-$buildNumber.aar', error], + ), + ), + ), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('does not prompt for confirmation when --force is used', () async { + when(() => argResults['force']).thenReturn(true); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + + verify(() => logger.success('\nāœ… Published Release!')).called(1); + expect(exitCode, ExitCode.success.code); + expect(capturedHostedUri, isNull); + verifyNever( + () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), + ); + }); + + test('succeeds when release is successful', () async { + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => logger.success('\nāœ… Published Release!')).called(1); + verify( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath', that: endsWith('.aar')), + releaseId: any(named: 'releaseId'), + arch: 'aar', + platform: 'android', + hash: any(named: 'hash'), + ), + ).called(1); + expect(exitCode, ExitCode.success.code); + expect(capturedHostedUri, isNull); + }); + + test( + 'succeeds when release is successful ' + 'with flavors and target', () async { + const flavor = 'development'; + when(() => argResults['flavor']).thenReturn(flavor); + final tempDir = setUpTempDir(); + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync(''' +app_id: productionAppId +flavors: + development: $appId'''); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => logger.success('\nāœ… Published Release!')).called(1); + verify( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath', that: endsWith('.aar')), + releaseId: any(named: 'releaseId'), + arch: 'aar', + platform: 'android', + hash: any(named: 'hash'), + ), + ).called(1); + expect(exitCode, ExitCode.success.code); + expect(capturedHostedUri, isNull); + }); + + 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', + ), + ], + ); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + + verify(() => logger.success('\nāœ… Published Release!')).called(1); + expect(exitCode, ExitCode.success.code); + expect(capturedHostedUri, isNull); + 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 => [ + const ValidationIssue( + severity: ValidationIssueSeverity.error, + message: 'There was an issue', + ), + ], + ); + + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.config.code)); + verify(() => logger.err('Aborting due to validation errors.')).called(1); + }); + }); +}