From c137744d287d7ca90eed0360b5f90c4b444a0018 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Tue, 28 Mar 2023 15:58:29 -0500 Subject: [PATCH] feat(shorebird_cli): add `shorebird release` (#188) --- packages/shorebird_cli/lib/src/command.dart | 3 + .../shorebird_cli/lib/src/command_runner.dart | 1 + .../lib/src/commands/commands.dart | 1 + .../lib/src/commands/publish_command.dart | 49 +- .../lib/src/commands/release_command.dart | 229 +++++++++ .../src/commands/publish_command_test.dart | 52 +- .../src/commands/release_command_test.dart | 446 ++++++++++++++++++ 7 files changed, 715 insertions(+), 66 deletions(-) create mode 100644 packages/shorebird_cli/lib/src/commands/release_command.dart create mode 100644 packages/shorebird_cli/test/src/commands/release_command_test.dart diff --git a/packages/shorebird_cli/lib/src/command.dart b/packages/shorebird_cli/lib/src/command.dart index 271ff8fb..1c338f6d 100644 --- a/packages/shorebird_cli/lib/src/command.dart +++ b/packages/shorebird_cli/lib/src/command.dart @@ -8,6 +8,9 @@ import 'package:shorebird_cli/src/auth/auth.dart'; import 'package:shorebird_cli/src/command_runner.dart'; 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); + typedef CodePushClientBuilder = CodePushClient Function({ required String apiKey, Uri? hostedUri, diff --git a/packages/shorebird_cli/lib/src/command_runner.dart b/packages/shorebird_cli/lib/src/command_runner.dart index 3ebabac5..b4533950 100644 --- a/packages/shorebird_cli/lib/src/command_runner.dart +++ b/packages/shorebird_cli/lib/src/command_runner.dart @@ -52,6 +52,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { addCommand(LoginCommand(logger: _logger)); addCommand(LogoutCommand(logger: _logger)); addCommand(PublishCommand(logger: _logger)); + addCommand(ReleaseCommand(logger: _logger)); addCommand(RunCommand(logger: _logger)); addCommand(UpgradeCommand(logger: _logger)); } diff --git a/packages/shorebird_cli/lib/src/commands/commands.dart b/packages/shorebird_cli/lib/src/commands/commands.dart index 4652211b..695afdaa 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -4,5 +4,6 @@ export 'init_command.dart'; export 'login_command.dart'; export 'logout_command.dart'; export 'publish_command.dart'; +export 'release_command.dart'; export 'run_command.dart'; export 'upgrade_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/publish_command.dart b/packages/shorebird_cli/lib/src/commands/publish_command.dart index ab3ebbfc..87a654b7 100644 --- a/packages/shorebird_cli/lib/src/commands/publish_command.dart +++ b/packages/shorebird_cli/lib/src/commands/publish_command.dart @@ -11,9 +11,6 @@ import 'package:shorebird_cli/src/shorebird_create_app_mixin.dart'; import 'package:shorebird_cli/src/shorebird_engine_mixin.dart'; 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); - /// {@template publish_command} /// /// `shorebird publish ` @@ -120,26 +117,14 @@ class PublishCommand extends ShorebirdCommand return ExitCode.software.code; } - var app = apps.firstWhereOrNull((a) => a.id == shorebirdYaml.appId); + final app = apps.firstWhereOrNull((a) => a.id == shorebirdYaml.appId); if (app == null) { - logger.info( - lightCyan.wrap("\nIt looks like this is a new app. Let's get started!"), + logger.err( + ''' +Could not find app with id: "${shorebirdYaml.appId}". +Did you forget to run "shorebird init"?''', ); - try { - app = await createApp(); - addShorebirdYamlToProject(app.id); - addShorebirdYamlToPubspecAssets(); - logger.info(''' - -${lightGreen.wrap('🐦 Shorebird initialized successfully!')} - -āœ… A shorebird app has been created. -āœ… A "shorebird.yaml" has been created. -āœ… The "pubspec.yaml" has been updated to include "shorebird.yaml" as an asset.'''); - } catch (error) { - logger.err('$error'); - return ExitCode.software.code; - } + return ExitCode.software.code; } logger.info( @@ -172,22 +157,20 @@ ${styleBold.wrap(lightGreen.wrap('šŸš€ Ready to publish a new patch!'))} return ExitCode.software.code; } - var release = releases.firstWhereOrNull( + final release = releases.firstWhereOrNull( (r) => r.version == versionString, ); if (release == null) { - final createReleaseProgress = logger.progress('Creating release'); - try { - release = await codePushClient.createRelease( - appId: app.id, - version: versionString, - ); - createReleaseProgress.complete(); - } catch (error) { - createReleaseProgress.fail('$error'); - return ExitCode.software.code; - } + logger.err( + ''' +Release not found: "$versionString" + +Patches can only be published for existing releases. +Please create a release using "shorebird release" and try again. +''', + ); + return ExitCode.software.code; } late final Patch patch; diff --git a/packages/shorebird_cli/lib/src/commands/release_command.dart b/packages/shorebird_cli/lib/src/commands/release_command.dart new file mode 100644 index 00000000..8b35233e --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/release_command.dart @@ -0,0 +1,229 @@ +import 'dart:io'; + +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/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_create_app_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_engine_mixin.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template release_command} +/// `shorebird release` +/// Create new app releases. +/// {@endtemplate} +class ReleaseCommand extends ShorebirdCommand + with + ShorebirdConfigMixin, + ShorebirdEngineMixin, + ShorebirdBuildMixin, + ShorebirdCreateAppMixin { + /// {@macro release_command} + ReleaseCommand({ + required super.logger, + super.auth, + super.buildCodePushClient, + super.runProcess, + HashFunction? hashFn, + }) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()) { + argParser + ..addOption( + 'release-version', + help: 'The version of the release (e.g. "1.0.0").', + ) + ..addOption( + 'platform', + help: 'The platform of the release (e.g. "android").', + allowed: ['android'], + allowedHelp: {'android': 'The Android platform.'}, + defaultsTo: 'android', + ) + ..addOption( + 'arch', + help: 'The architecture of the release (e.g. "aarch64").', + allowed: ['aarch64'], + allowedHelp: {'aarch64': 'The 64-bit ARM architecture.'}, + defaultsTo: 'aarch64', + ); + } + + @override + String get description => ''' +Builds and submits your app to Shorebird. +Shorebird saves the compiled Dart code from your application in order to +make smaller updates to your app. +'''; + + @override + String get name => 'release'; + + final HashFunction _hashFn; + + @override + Future run() async { + if (!isShorebirdInitialized) { + logger.err( + 'Shorebird is not initialized. Did you run "shorebird init"?', + ); + return ExitCode.config.code; + } + + final session = auth.currentSession; + if (session == null) { + logger.err('You must be logged in to release.'); + return ExitCode.noUser.code; + } + + try { + await ensureEngineExists(); + } catch (error) { + logger.err(error.toString()); + return ExitCode.software.code; + } + + final buildProgress = logger.progress('Building release'); + try { + await buildRelease(); + buildProgress.complete(); + } on ProcessException catch (error) { + buildProgress.fail('Failed to build: ${error.message}'); + return ExitCode.software.code; + } + + final artifactPath = p.join( + Directory.current.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + 'arm64-v8a', + 'libapp.so', + ); + + final artifact = File(artifactPath); + + if (!artifact.existsSync()) { + logger.err('Artifact not found: "${artifact.path}"'); + return ExitCode.software.code; + } + + final hash = _hashFn(await artifact.readAsBytes()); + final pubspecYaml = getPubspecYaml()!; + final shorebirdYaml = getShorebirdYaml()!; + final codePushClient = buildCodePushClient( + apiKey: session.apiKey, + hostedUri: hostedUri, + ); + final version = pubspecYaml.version!; + final versionString = '${version.major}.${version.minor}.${version.patch}'; + + 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 app = apps.firstWhereOrNull((a) => a.id == shorebirdYaml.appId); + if (app == null) { + logger.err( + ''' +Could not find app with id: "${shorebirdYaml.appId}". +Did you forget to run "shorebird init"?''', + ); + return ExitCode.software.code; + } + + final releaseVersionArg = results['release-version'] as String?; + final pubspecVersion = pubspecYaml.version!; + final pubspecVersionString = + '''${pubspecVersion.major}.${pubspecVersion.minor}.${pubspecVersion.patch}'''; + final releaseVersion = releaseVersionArg ?? + logger.prompt( + '\nWhat is the version of this release?', + defaultValue: pubspecVersionString, + ); + final arch = results['arch'] as String; + final platform = results['platform'] as String; + + logger.info( + ''' + +${styleBold.wrap(lightGreen.wrap('šŸš€ Ready to create a new release!'))} + +šŸ“± App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.id})')} +šŸ“¦ Release Version: ${lightCyan.wrap(releaseVersion)} +āš™ļø Architecture: ${lightCyan.wrap(arch)} +šŸ•¹ļø Platform: ${lightCyan.wrap(platform)} +#ļøāƒ£ Hash: ${lightCyan.wrap(hash)} + +Your next step is to upload the release artifact to the Play Store. +${lightCyan.wrap(artifactPath)} + +See the following link for more information: +${link(uri: Uri.parse('https://support.google.com/googleplay/android-developer/answer/9859152?hl=en'))} +''', + ); + + 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 == versionString); + if (release == null) { + final createReleaseProgress = logger.progress('Creating release'); + try { + release = await codePushClient.createRelease( + appId: app.id, + version: versionString, + ); + createReleaseProgress.complete(); + } catch (error) { + createReleaseProgress.fail('$error'); + return ExitCode.software.code; + } + } + + final createArtifactProgress = logger.progress('Creating artifact'); + try { + await codePushClient.createReleaseArtifact( + releaseId: release.id, + artifactPath: artifact.path, + arch: arch, + platform: platform, + hash: hash, + ); + createArtifactProgress.complete(); + } catch (error) { + createArtifactProgress.fail('$error'); + return ExitCode.software.code; + } + + logger.success('\nāœ… Released Successfully!'); + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/test/src/commands/publish_command_test.dart b/packages/shorebird_cli/test/src/commands/publish_command_test.dart index 1071cb36..be5d76f5 100644 --- a/packages/shorebird_cli/test/src/commands/publish_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/publish_command_test.dart @@ -31,7 +31,6 @@ void main() { const appId = 'test-app-id'; const version = '1.2.3'; const appDisplayName = 'Test App'; - const app = App(id: appId, displayName: appDisplayName); const appMetadata = AppMetadata(appId: appId, displayName: appDisplayName); const patchArtifact = PatchArtifact( id: 0, @@ -124,21 +123,12 @@ flutter: when( () => codePushClient.getReleases(appId: any(named: 'appId')), ).thenAnswer((_) async => [release]); - when( - () => codePushClient.createApp(displayName: any(named: 'displayName')), - ).thenAnswer((_) async => app); when( () => codePushClient.createChannel( appId: any(named: 'appId'), channel: any(named: 'channel'), ), ).thenAnswer((_) async => channel); - when( - () => codePushClient.createRelease( - appId: any(named: 'appId'), - version: any(named: 'version'), - ), - ).thenAnswer((_) async => release); when( () => codePushClient.createPatch(releaseId: any(named: 'releaseId')), ).thenAnswer((_) async => patch); @@ -260,15 +250,11 @@ flutter: expect(exitCode, ExitCode.software.code); }); - test('throws error when creating apps fails.', () async { - const error = 'something went wrong'; + test('throws error when app does not exist fails.', () async { when( () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), ).thenReturn(appDisplayName); when(() => codePushClient.getApps()).thenAnswer((_) async => []); - when( - () => codePushClient.createApp(displayName: any(named: 'displayName')), - ).thenThrow(error); final tempDir = setUpTempDir(); Directory( p.join(command.shorebirdEnginePath, 'engine'), @@ -290,7 +276,13 @@ flutter: command.run, getCurrentDirectory: () => tempDir, ); - verify(() => logger.err(error)).called(1); + verify( + () => logger.err( + ''' +Could not find app with id: "$appId". +Did you forget to run "shorebird init"?''', + ), + ).called(1); expect(exitCode, ExitCode.software.code); }); @@ -299,7 +291,6 @@ flutter: when( () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), ).thenReturn(appDisplayName); - when(() => codePushClient.getApps()).thenAnswer((_) async => []); final tempDir = setUpTempDir(); Directory( p.join(command.shorebirdEnginePath, 'engine'), @@ -355,18 +346,10 @@ flutter: expect(exitCode, ExitCode.software.code); }); - test('throws error when creating release fails.', () async { - const error = 'something went wrong'; + test('throws error when release does not exist.', () async { when( () => codePushClient.getReleases(appId: any(named: 'appId')), ).thenAnswer((_) async => []); - when( - () => codePushClient.createRelease( - appId: any(named: 'appId'), - version: any(named: 'version'), - displayName: any(named: 'displayName'), - ), - ).thenThrow(error); final tempDir = setUpTempDir(); Directory( p.join(command.shorebirdEnginePath, 'engine'), @@ -388,15 +371,21 @@ flutter: command.run, getCurrentDirectory: () => tempDir, ); - verify(() => progress.fail(error)).called(1); + verify( + () => logger.err( + ''' +Release not found: "$version" + +Patches can only be published for existing releases. +Please create a release using "shorebird release" and try again. +''', + ), + ).called(1); expect(exitCode, ExitCode.software.code); }); test('throws error when creating patch fails.', () async { const error = 'something went wrong'; - when( - () => codePushClient.getReleases(appId: any(named: 'appId')), - ).thenAnswer((_) async => []); when( () => codePushClient.createPatch(releaseId: any(named: 'releaseId')), ).thenThrow(error); @@ -427,9 +416,6 @@ flutter: test('throws error when uploading patch artifact fails.', () async { const error = 'something went wrong'; - when( - () => codePushClient.getReleases(appId: any(named: 'appId')), - ).thenAnswer((_) async => []); when( () => codePushClient.createPatchArtifact( artifactPath: any(named: 'artifactPath'), diff --git a/packages/shorebird_cli/test/src/commands/release_command_test.dart b/packages/shorebird_cli/test/src/commands/release_command_test.dart new file mode 100644 index 00000000..7b6be83f --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/release_command_test.dart @@ -0,0 +1,446 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +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/auth/session.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.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 _MockProgress extends Mock implements Progress {} + +class _MockProcessResult extends Mock implements ProcessResult {} + +class _MockCodePushClient extends Mock implements CodePushClient {} + +void main() { + group('release', () { + const session = Session(apiKey: 'test-api-key'); + const appId = 'test-app-id'; + const version = '1.2.3'; + const appDisplayName = 'Test App'; + const arch = 'aarch64'; + const platform = 'android'; + const appMetadata = AppMetadata(appId: appId, displayName: appDisplayName); + const release = Release( + id: 0, + appId: appId, + version: version, + displayName: '1.2.3', + ); + const releaseArtifact = ReleaseArtifact( + id: 0, + releaseId: 0, + arch: arch, + platform: platform, + hash: '#', + size: 42, + url: 'https://example.com', + ); + + const pubspecYamlContent = ''' +name: example +version: $version +environment: + sdk: ">=2.19.0 <3.0.0" + +flutter: + assets: + - shorebird.yaml'''; + + late ArgResults argResults; + late Directory applicationConfigHome; + late Auth auth; + late Progress progress; + late Logger logger; + late ProcessResult processResult; + late CodePushClient codePushClient; + late ReleaseCommand command; + late Uri? capturedHostedUri; + + Directory setUpTempDir() { + final tempDir = Directory.systemTemp.createTempSync(); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync('app_id: $appId'); + return tempDir; + } + + setUp(() { + argResults = _MockArgResults(); + applicationConfigHome = Directory.systemTemp.createTempSync(); + auth = _MockAuth(); + progress = _MockProgress(); + logger = _MockLogger(); + processResult = _MockProcessResult(); + codePushClient = _MockCodePushClient(); + command = ReleaseCommand( + auth: auth, + buildCodePushClient: ({required String apiKey, Uri? hostedUri}) { + capturedHostedUri = hostedUri; + return codePushClient; + }, + runProcess: ( + executable, + arguments, { + bool runInShell = false, + String? workingDirectory, + }) async { + return processResult; + }, + logger: logger, + )..testArgResults = argResults; + testApplicationConfigHome = (_) => applicationConfigHome.path; + + when(() => argResults.rest).thenReturn([]); + when(() => argResults['arch']).thenReturn(arch); + when(() => argResults['platform']).thenReturn(platform); + when(() => auth.currentSession).thenReturn(session); + when(() => logger.progress(any())).thenReturn(progress); + when(() => logger.confirm(any())).thenReturn(true); + when( + () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), + ).thenReturn(version); + when(() => processResult.exitCode).thenReturn(ExitCode.success.code); + when( + () => codePushClient.downloadEngine(revision: any(named: 'revision')), + ).thenAnswer((_) async => Uint8List.fromList([])); + 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'), + ), + ).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); + }); + + 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('throws no user error when session does not exist', () async { + when(() => auth.currentSession).thenReturn(null); + final tempDir = setUpTempDir(); + final exitCode = await IOOverrides.runZoned( + () => command.run(), + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.noUser.code)); + }); + + test('exits with code 70 when pulling engine fails', () async { + when( + () => codePushClient.downloadEngine(revision: any(named: 'revision')), + ).thenThrow(Exception('oops')); + when(() => auth.currentSession).thenReturn(session); + final tempDir = setUpTempDir(); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.software.code)); + }); + + test('exits with code 70 when building fails', () async { + when(() => processResult.exitCode).thenReturn(1); + when(() => processResult.stderr).thenReturn('oops'); + when(() => auth.currentSession).thenReturn(session); + + when( + () => codePushClient.downloadEngine(revision: any(named: 'revision')), + ).thenAnswer( + (_) async => Uint8List.fromList(ZipEncoder().encode(Archive())!), + ); + + final tempDir = setUpTempDir(); + final exitCode = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(exitCode, equals(ExitCode.software.code)); + }); + + test('throws software error when artifact is not found (default).', + () async { + final tempDir = setUpTempDir(); + Directory( + p.join(command.shorebirdEnginePath, 'engine'), + ).createSync(recursive: true); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => logger.err(any(that: contains('Artifact not found:'))), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('throws error when fetching apps fails.', () async { + const error = 'something went wrong'; + when(() => codePushClient.getApps()).thenThrow(error); + final tempDir = setUpTempDir(); + Directory( + p.join(command.shorebirdEnginePath, 'engine'), + ).createSync(recursive: true); + final artifactPath = p.join( + tempDir.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + 'arm64-v8a', + 'libapp.so', + ); + File(artifactPath).createSync(recursive: true); + 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(); + Directory( + p.join(command.shorebirdEnginePath, 'engine'), + ).createSync(recursive: true); + final artifactPath = p.join( + tempDir.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + 'arm64-v8a', + 'libapp.so', + ); + File(artifactPath).createSync(recursive: true); + 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(any(), defaultValue: any(named: 'defaultValue')), + ).thenReturn(appDisplayName); + final tempDir = setUpTempDir(); + Directory( + p.join(command.shorebirdEnginePath, 'engine'), + ).createSync(recursive: true); + final artifactPath = p.join( + tempDir.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + 'arm64-v8a', + 'libapp.so', + ); + File(artifactPath).createSync(recursive: true); + 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(); + Directory( + p.join(command.shorebirdEnginePath, 'engine'), + ).createSync(recursive: true); + final artifactPath = p.join( + tempDir.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + 'arm64-v8a', + 'libapp.so', + ); + File(artifactPath).createSync(recursive: true); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => progress.fail(error)).called(1); + expect(exitCode, ExitCode.software.code); + }); + + 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'), + displayName: any(named: 'displayName'), + ), + ).thenThrow(error); + final tempDir = setUpTempDir(); + Directory( + p.join(command.shorebirdEnginePath, 'engine'), + ).createSync(recursive: true); + final artifactPath = p.join( + tempDir.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + 'arm64-v8a', + 'libapp.so', + ); + File(artifactPath).createSync(recursive: true); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => progress.fail(error)).called(1); + expect(exitCode, ExitCode.software.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(); + Directory( + p.join(command.shorebirdEnginePath, 'engine'), + ).createSync(recursive: true); + final artifactPath = p.join( + tempDir.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + 'arm64-v8a', + 'libapp.so', + ); + File(artifactPath).createSync(recursive: true); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => progress.fail(error)).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('succeeds when release is successful', () async { + final tempDir = setUpTempDir(); + Directory( + p.join(command.shorebirdEnginePath, 'engine'), + ).createSync(recursive: true); + final artifactPath = p.join( + tempDir.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + 'release', + 'out', + 'lib', + 'arm64-v8a', + 'libapp.so', + ); + File(artifactPath).createSync(recursive: true); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => logger.success('\nāœ… Released Successfully!')).called(1); + expect(exitCode, ExitCode.success.code); + expect(capturedHostedUri, isNull); + }); + }); +}