From 11be5c8c276d5edd897e7c3e824dca0b8c7cddf3 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Wed, 24 May 2023 15:59:19 -0500 Subject: [PATCH 1/3] refactor(shorebird_cli): refactor `shorebird release` introduce `shorebird release android` --- .../lib/src/commands/release/release.dart | 1 + .../release/release_android_command.dart | 273 ++++++++ .../src/commands/release/release_command.dart | 14 +- .../release/release_android_command_test.dart | 643 ++++++++++++++++++ .../release/release_command_test.dart | 16 + 5 files changed, 942 insertions(+), 5 deletions(-) create mode 100644 packages/shorebird_cli/lib/src/commands/release/release_android_command.dart create mode 100644 packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart diff --git a/packages/shorebird_cli/lib/src/commands/release/release.dart b/packages/shorebird_cli/lib/src/commands/release/release.dart index ae12c3128..4c1329e54 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release.dart @@ -1 +1,2 @@ +export 'release_android_command.dart'; export 'release_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/release/release_android_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_android_command.dart new file mode 100644 index 000000000..445e198f3 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/release/release_android_command.dart @@ -0,0 +1,273 @@ +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/auth_logger_mixin.dart'; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/config/shorebird_yaml.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_command} +/// `shorebird release android` +/// Create new app releases for Android. +/// {@endtemplate} +class ReleaseAndroidCommand extends ShorebirdCommand + with + AuthLoggerMixin, + ShorebirdValidationMixin, + ShorebirdConfigMixin, + ShorebirdBuildMixin, + ShorebirdCreateAppMixin, + ShorebirdJavaMixin, + ShorebirdReleaseVersionMixin { + /// {@macro release_android_command} + ReleaseAndroidCommand({ + required super.logger, + super.auth, + super.cache, + super.buildCodePushClient, + super.validators, + HashFunction? hashFn, + }) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()) { + 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( + 'force', + abbr: 'f', + help: 'Release without confirmation if there are no errors.', + negatable: false, + ); + } + + @override + String get description => ''' +Builds and submits your Android 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 => 'android'; + + final HashFunction _hashFn; + + @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; + } + + final flavor = results['flavor'] as String?; + final target = results['target'] as String?; + final buildProgress = logger.progress('Building release'); + try { + await buildAppBundle(flavor: flavor, target: target); + buildProgress.complete(); + } on ProcessException catch (error) { + buildProgress.fail('Failed to build: ${error.message}'); + return ExitCode.software.code; + } + + 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 bundleDirPath = p.join('build', 'app', 'outputs', 'bundle'); + final bundlePath = flavor != null + ? p.join(bundleDirPath, '${flavor}Release', 'app-$flavor-release.aab') + : p.join(bundleDirPath, 'release', 'app-release.aab'); + + final String releaseVersion; + final detectReleaseVersionProgress = logger.progress( + 'Detecting release version', + ); + try { + releaseVersion = await extractReleaseVersionFromAppBundle(bundlePath); + detectReleaseVersionProgress.complete(); + } catch (error) { + detectReleaseVersionProgress.fail('$error'); + return ExitCode.software.code; + } + + final platform = results['platform'] as String; + 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'); + for (final archMetadata in architectures.values) { + final artifactPath = p.join( + Directory.current.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + flavor != null ? '${flavor}Release' : 'release', + 'out', + 'lib', + archMetadata.path, + 'libapp.so', + ); + final artifact = File(artifactPath); + final hash = _hashFn(await artifact.readAsBytes()); + + try { + await codePushClient.createReleaseArtifact( + releaseId: release.id, + artifactPath: artifact.path, + arch: archMetadata.arch, + platform: platform, + hash: hash, + ); + } catch (error) { + createArtifactProgress.fail('$error'); + return ExitCode.software.code; + } + } + + try { + await codePushClient.createReleaseArtifact( + releaseId: release.id, + artifactPath: bundlePath, + arch: 'aab', + platform: platform, + hash: _hashFn(await File(bundlePath).readAsBytes()), + ); + } catch (error) { + createArtifactProgress.fail('$error'); + return ExitCode.software.code; + } + + createArtifactProgress.complete(); + + logger + ..success('\nāœ… Published Release!') + ..info(''' + +Your next step is to upload the app bundle to the Play Store. +${lightCyan.wrap(bundlePath)} + +See the following link for more information: +${link(uri: Uri.parse('https://support.google.com/googleplay/android-developer/answer/9859152?hl=en'))} +'''); + + 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 1ee2c1e5f..3f627b342 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_command.dart @@ -6,6 +6,7 @@ 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/commands/commands.dart'; import 'package:shorebird_cli/src/config/shorebird_yaml.dart'; import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; @@ -37,6 +38,7 @@ class ReleaseCommand extends ShorebirdCommand super.validators, HashFunction? hashFn, }) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()) { + addSubcommand(ReleaseAndroidCommand(logger: logger)); argParser ..addOption( 'platform', @@ -63,11 +65,7 @@ class ReleaseCommand extends ShorebirdCommand } @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. -'''; + String get description => 'Manage your Shorebird app releases.'; @override String get name => 'release'; @@ -76,6 +74,12 @@ make smaller updates to your app. @override Future run() async { + logger.warn( + ''' +"shorebird release" is deprecated. +Please use "shorebird release android" instead.''', + ); + if (!isShorebirdInitialized) { logger.err( 'Shorebird is not initialized. Did you run "shorebird init"?', diff --git a/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart new file mode 100644 index 000000000..9c0ef4859 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart @@ -0,0 +1,643 @@ +import 'dart:io' hide Platform; + +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:platform/platform.dart'; +import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/cache.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_environment.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 _MockCache extends Mock implements Cache {} + +class _MockLogger extends Mock implements Logger {} + +class _MockPlatform extends Mock implements Platform {} + +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(ReleaseCommand, () { + const appId = 'test-app-id'; + const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; + const versionName = '1.2.3'; + const versionCode = '1'; + const version = '$versionName+$versionCode'; + 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, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + ); + 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 http.Client httpClient; + late Directory shorebirdRoot; + late Platform environmentPlatform; + late Auth auth; + late Cache cache; + late Progress progress; + late Logger logger; + late ShorebirdProcessResult flutterBuildProcessResult; + late ShorebirdProcessResult flutterRevisionProcessResult; + late ShorebirdProcessResult releaseVersionNameProcessResult; + late ShorebirdProcessResult releaseVersionCodeProcessResult; + late CodePushClient codePushClient; + late ReleaseCommand command; + late Uri? capturedHostedUri; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + + 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; + } + + void setUpTempArtifacts(Directory dir, {String? flavor}) { + for (final archMetadata + in ShorebirdBuildMixin.allAndroidArchitectures.values) { + final artifactPath = p.join( + dir.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + flavor != null ? '${flavor}Release' : 'release', + 'out', + 'lib', + archMetadata.path, + 'libapp.so', + ); + File(artifactPath).createSync(recursive: true); + } + + final bundleDirPath = p.join('build', 'app', 'outputs', 'bundle'); + final bundlePath = flavor != null + ? p.join(bundleDirPath, '${flavor}Release', 'app-$flavor-release.aab') + : p.join(bundleDirPath, 'release', 'app-release.aab'); + File(bundlePath).createSync(recursive: true); + } + + setUp(() { + argResults = _MockArgResults(); + httpClient = _MockHttpClient(); + environmentPlatform = _MockPlatform(); + shorebirdRoot = Directory.systemTemp.createTempSync(); + auth = _MockAuth(); + cache = _MockCache(); + progress = _MockProgress(); + logger = _MockLogger(); + flutterBuildProcessResult = _MockProcessResult(); + flutterRevisionProcessResult = _MockProcessResult(); + releaseVersionNameProcessResult = _MockProcessResult(); + releaseVersionCodeProcessResult = _MockProcessResult(); + codePushClient = _MockCodePushClient(); + flutterValidator = _MockShorebirdFlutterValidator(); + shorebirdProcess = _MockShorebirdProcess(); + command = ReleaseCommand( + auth: auth, + buildCodePushClient: ({ + required http.Client httpClient, + Uri? hostedUri, + }) { + capturedHostedUri = hostedUri; + return codePushClient; + }, + cache: cache, + logger: logger, + validators: [flutterValidator], + ) + ..testArgResults = argResults + ..testProcess = shorebirdProcess + ..testEngineConfig = const EngineConfig.empty(); + + registerFallbackValue(shorebirdProcess); + + ShorebirdEnvironment.platform = environmentPlatform; + when(() => environmentPlatform.script).thenReturn( + Uri.file( + p.join( + shorebirdRoot.path, + 'bin', + 'cache', + 'shorebird.snapshot', + ), + ), + ); + when( + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => flutterBuildProcessResult); + when( + () => shorebirdProcess.run( + 'git', + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => flutterRevisionProcessResult); + when( + () => shorebirdProcess.run( + 'java', + any(), + runInShell: any(named: 'runInShell'), + environment: any(named: 'environment'), + ), + ).thenAnswer((invocation) async { + final args = invocation.positionalArguments[1] as List; + return args.last == '/manifest/@android:versionCode' + ? releaseVersionCodeProcessResult + : releaseVersionNameProcessResult; + }); + when(() => argResults.rest).thenReturn([]); + when(() => argResults['arch']).thenReturn(arch); + when(() => argResults['platform']).thenReturn(platform); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => auth.client).thenReturn(httpClient); + when(() => cache.updateAll()).thenAnswer((_) async => {}); + when( + () => cache.getArtifactDirectory(any()), + ).thenReturn(Directory.systemTemp.createTempSync()); + when(() => logger.progress(any())).thenReturn(progress); + when(() => logger.confirm(any())).thenReturn(true); + when( + () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), + ).thenReturn(version); + when( + () => flutterBuildProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => flutterRevisionProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => releaseVersionNameProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => releaseVersionCodeProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => flutterRevisionProcessResult.stdout, + ).thenReturn(flutterRevision); + when( + () => releaseVersionNameProcessResult.stdout, + ).thenReturn(versionName); + when( + () => releaseVersionCodeProcessResult.stdout, + ).thenReturn(versionCode); + 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('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 user is not logged in', () async { + when(() => auth.isAuthenticated).thenReturn(false); + final tempDir = setUpTempDir(); + final exitCode = await IOOverrides.runZoned( + () => command.run(), + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.noUser.code)); + }); + + test('exits with code 70 when building fails', () async { + when(() => flutterBuildProcessResult.exitCode).thenReturn(1); + when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); + + final tempDir = setUpTempDir(); + final exitCode = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(exitCode, equals(ExitCode.software.code)); + }); + + test('throws error when fetching apps fails.', () async { + const error = 'something went wrong'; + when(() => codePushClient.getApps()).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('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(); + setUpTempArtifacts(tempDir); + 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); + }); + + group('getJavaExecutable', () { + test('uses correct executable on windows', () async { + const javaHome = r'C:\Program Files\Java\jdk-11.0.1'; + final platform = _MockPlatform(); + when(() => platform.isWindows).thenReturn(true); + when(() => platform.environment).thenReturn({'JAVA_HOME': javaHome}); + expect( + command.getJavaExecutable(platform), + equals(p.join(javaHome, 'bin', 'java.exe')), + ); + }); + + test('uses correct executable on non-windows', () async { + final platform = _MockPlatform(); + when(() => platform.isWindows).thenReturn(false); + expect(command.getJavaExecutable(platform), equals('java')); + }); + }); + + test('errors when detecting release version name fails', () async { + const error = 'oops'; + when(() => releaseVersionNameProcessResult.exitCode).thenReturn(1); + when(() => releaseVersionNameProcessResult.stderr).thenReturn(error); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, ExitCode.software.code); + verify( + () => progress.fail( + 'Exception: Failed to extract version name from app bundle: $error', + ), + ).called(1); + }); + + test('errors when detecting release version code fails', () async { + const error = 'oops'; + when(() => releaseVersionCodeProcessResult.exitCode).thenReturn(1); + when(() => releaseVersionCodeProcessResult.stderr).thenReturn(error); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, ExitCode.software.code); + verify( + () => progress.fail( + 'Exception: Failed to extract version code from app bundle: $error', + ), + ).called(1); + }); + + 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(); + setUpTempArtifacts(tempDir); + 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('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(error)).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('throws error when uploading aab 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('.aab')), + 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(error)).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test( + 'does not prompt for confirmation ' + 'when --release-version and --force are used', () async { + when(() => argResults['force']).thenReturn(true); + when(() => argResults['release-version']).thenReturn(version); + 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('.aab')), + releaseId: any(named: 'releaseId'), + arch: 'aab', + 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'; + final target = p.join('lib', 'main_development.dart'); + when(() => argResults['flavor']).thenReturn(flavor); + when(() => argResults['target']).thenReturn(target); + final tempDir = setUpTempDir(); + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync(''' +app_id: productionAppId +flavors: + development: $appId'''); + setUpTempArtifacts(tempDir, flavor: flavor); + 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('.aab')), + releaseId: any(named: 'releaseId'), + arch: 'aab', + 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); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/commands/release/release_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_command_test.dart index 9c0ef4859..78857a2b1 100644 --- a/packages/shorebird_cli/test/src/commands/release/release_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/release_command_test.dart @@ -265,6 +265,22 @@ flutter: when(() => flutterValidator.validate(any())).thenAnswer((_) async => []); }); + test('logs deprecation warning', () async { + final tempDir = Directory.systemTemp.createTempSync(); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => logger.warn( + ''' +"shorebird release" is deprecated. +Please use "shorebird release android" instead.''', + ), + ).called(1); + expect(exitCode, ExitCode.config.code); + }); + test('throws config error when shorebird is not initialized', () async { final tempDir = Directory.systemTemp.createTempSync(); final exitCode = await IOOverrides.runZoned( From 5cf2eaf3daf7042c0a6ecd01084b40c4e0d8918d Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Wed, 24 May 2023 16:04:51 -0500 Subject: [PATCH 2/3] fix --- .../src/commands/release/release_android_command_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart index 9c0ef4859..58249cf68 100644 --- a/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart @@ -40,7 +40,7 @@ class _MockShorebirdFlutterValidator extends Mock class _MockShorebirdProcess extends Mock implements ShorebirdProcess {} void main() { - group(ReleaseCommand, () { + group(ReleaseAndroidCommand, () { const appId = 'test-app-id'; const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; const versionName = '1.2.3'; @@ -90,7 +90,7 @@ flutter: late ShorebirdProcessResult releaseVersionNameProcessResult; late ShorebirdProcessResult releaseVersionCodeProcessResult; late CodePushClient codePushClient; - late ReleaseCommand command; + late ReleaseAndroidCommand command; late Uri? capturedHostedUri; late ShorebirdFlutterValidator flutterValidator; late ShorebirdProcess shorebirdProcess; @@ -147,7 +147,7 @@ flutter: codePushClient = _MockCodePushClient(); flutterValidator = _MockShorebirdFlutterValidator(); shorebirdProcess = _MockShorebirdProcess(); - command = ReleaseCommand( + command = ReleaseAndroidCommand( auth: auth, buildCodePushClient: ({ required http.Client httpClient, From 76aacfc4e864de6df2785b76ef7616d063d14bbe Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Wed, 24 May 2023 16:12:31 -0500 Subject: [PATCH 3/3] coverage --- .../src/commands/release/release_android_command_test.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart index 58249cf68..48577c998 100644 --- a/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/release_android_command_test.dart @@ -265,6 +265,10 @@ flutter: when(() => flutterValidator.validate(any())).thenAnswer((_) async => []); }); + test('has a 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(