diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart index f2bd2455..a868b1f3 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_android_command.dart @@ -1,9 +1,11 @@ import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:mason_logger/mason_logger.dart'; import 'package:path/path.dart' as p; +import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart'; import 'package:shorebird_cli/src/cache.dart'; @@ -15,6 +17,7 @@ import 'package:shorebird_cli/src/formatters/formatters.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter_manager.dart'; import 'package:shorebird_cli/src/shorebird_release_version_mixin.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; @@ -99,45 +102,22 @@ class PatchAndroidCommand extends ShorebirdCommand const channelName = 'stable'; final flavor = results['flavor'] as String?; final target = results['target'] as String?; - final buildProgress = logger.progress('Building patch'); - 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 = shorebirdEnv.getShorebirdYaml()!; final appId = shorebirdYaml.getAppId(flavor: flavor); final app = await codePushClientWrapper.getApp(appId: appId); + final releases = await codePushClientWrapper.getReleases(appId: appId); + final releaseVersion = results['release-version'] as String? ?? + await promptForReleaseVersion(releases); - final bundlePath = flavor != null - ? './build/app/outputs/bundle/${flavor}Release/app-$flavor-release.aab' - : './build/app/outputs/bundle/release/app-release.aab'; - - final releaseVersionArg = results['release-version'] as String?; - final String releaseVersion; - - final detectReleaseVersionProgress = logger.progress( - 'Detecting release version', + final release = releases.firstWhereOrNull( + (r) => r.version == releaseVersion, ); - try { - releaseVersion = releaseVersionArg ?? - await extractReleaseVersionFromAppBundle(bundlePath); - detectReleaseVersionProgress.complete( - 'Detected release version $releaseVersion', - ); - } catch (error) { - detectReleaseVersionProgress.fail('$error'); - return ExitCode.software.code; + + if (releaseVersion == null || release == null) { + logger.info('No releases found'); + return ExitCode.success.code; } - final release = await codePushClientWrapper.getRelease( - appId: appId, - releaseVersion: releaseVersion, - ); - if (release.platformStatuses[ReleasePlatform.android] == ReleaseStatus.draft) { logger.err(''' @@ -147,33 +127,6 @@ Please re-run the release command for this version or create a new release.'''); return ExitCode.software.code; } - final shorebirdFlutterRevision = shorebirdEnv.flutterRevision; - if (release.flutterRevision != shorebirdFlutterRevision) { - logger - ..err(''' -Flutter revision mismatch. - -The release you are trying to patch was built with a different version of Flutter. - -Release Flutter Revision: ${release.flutterRevision} -Current Flutter Revision: $shorebirdFlutterRevision -''') - ..info( - ''' -Either create a new release using: - ${lightCyan.wrap('shorebird release')} - -Or downgrade your Flutter version and try again using: - ${lightCyan.wrap('cd ${shorebirdEnv.flutterDirectory.path}')} - ${lightCyan.wrap('git checkout ${release.flutterRevision}')} - -Shorebird plans to support this automatically, let us know if it's important to you: -https://github.com/shorebirdtech/shorebird/issues/472 -''', - ); - return ExitCode.software.code; - } - final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts( appId: app.appId, releaseId: release.id, @@ -211,12 +164,49 @@ https://github.com/shorebirdtech/shorebird/issues/472 Uri.parse(releaseAabArtifact.url), httpClient: _httpClient, ); + downloadReleaseArtifactProgress.complete(); } catch (error) { downloadReleaseArtifactProgress.fail('$error'); return ExitCode.software.code; } - downloadReleaseArtifactProgress.complete(); + final shorebirdFlutterRevision = shorebirdEnv.flutterRevision; + if (release.flutterRevision != shorebirdFlutterRevision) { + final installFlutterRevisionProgress = logger.progress( + 'Switching to Flutter revision ${release.flutterRevision}', + ); + try { + await shorebirdFlutterManager.installRevision( + revision: release.flutterRevision, + ); + installFlutterRevisionProgress.complete(); + } catch (error) { + installFlutterRevisionProgress.fail('$error'); + return ExitCode.software.code; + } + } + + final buildProgress = logger.progress('Building patch'); + try { + await runScoped( + () => buildAppBundle(flavor: flavor, target: target), + values: { + shorebirdEnvRef.overrideWith( + () => ShorebirdEnv( + flutterRevisionOverride: release.flutterRevision, + ), + ) + }, + ); + buildProgress.complete(); + } on ProcessException catch (error) { + buildProgress.fail('Failed to build: ${error.message}'); + return ExitCode.software.code; + } + + final bundlePath = flavor != null + ? './build/app/outputs/bundle/${flavor}Release/app-$flavor-release.aab' + : './build/app/outputs/bundle/release/app-release.aab'; FileSetDiff contentDiffs; try { @@ -358,6 +348,16 @@ ${summary.join('\n')} return ExitCode.success.code; } + Future promptForReleaseVersion(List releases) async { + if (releases.isEmpty) return null; + final release = logger.chooseOne( + 'Which release would you like to patch?', + choices: releases, + display: (release) => release.version, + ); + return release.version; + } + Future downloadReleaseArtifact( Uri uri, { required http.Client httpClient, diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart index 9987dc50..35ef4b96 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_android_command_test.dart @@ -22,6 +22,7 @@ import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_cli/src/process.dart'; import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter_manager.dart'; import 'package:shorebird_cli/src/shorebird_validator.dart'; import 'package:shorebird_cli/src/validators/validators.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; @@ -54,6 +55,8 @@ class _MockProgress extends Mock implements Progress {} class _MockProcessResult extends Mock implements ShorebirdProcessResult {} +class _MockProcessWrapper extends Mock implements ProcessWrapper {} + class _MockHttpClient extends Mock implements http.Client {} class _MockShorebirdEnv extends Mock implements ShorebirdEnv {} @@ -61,6 +64,9 @@ class _MockShorebirdEnv extends Mock implements ShorebirdEnv {} class _MockShorebirdFlutterValidator extends Mock implements ShorebirdFlutterValidator {} +class _MockShorebirdFlutterManager extends Mock + implements ShorebirdFlutterManager {} + class _MockShorebirdProcess extends Mock implements ShorebirdProcess {} class _MockShorebirdValidator extends Mock implements ShorebirdValidator {} @@ -133,6 +139,7 @@ flutter: late ShorebirdProcessResult patchProcessResult; late http.Client httpClient; late Cache cache; + late ShorebirdFlutterManager shorebirdFlutterManager; late ShorebirdFlutterValidator flutterValidator; late ShorebirdProcess shorebirdProcess; late ShorebirdValidator shorebirdValidator; @@ -153,6 +160,9 @@ flutter: shorebirdEnvRef.overrideWith(() => shorebirdEnv), platformRef.overrideWith(() => platform), processRef.overrideWith(() => shorebirdProcess), + shorebirdFlutterManagerRef.overrideWith( + () => shorebirdFlutterManager, + ), shorebirdValidatorRef.overrideWith(() => shorebirdValidator), }, ); @@ -217,6 +227,7 @@ flutter: cache = _MockCache(); shorebirdEnv = _MockShorebirdEnv(); shorebirdProcess = _MockShorebirdProcess(); + shorebirdFlutterManager = _MockShorebirdFlutterManager(); shorebirdValidator = _MockShorebirdValidator(); command = runWithOverrides( () => PatchAndroidCommand( @@ -272,6 +283,7 @@ flutter: when(() => argResults['channel']).thenReturn(channelName); when(() => argResults['dry-run']).thenReturn(false); when(() => argResults['force']).thenReturn(false); + when(() => argResults['release-version']).thenReturn(release.version); when(() => auth.isAuthenticated).thenReturn(true); when(() => auth.client).thenReturn(httpClient); when(() => logger.progress(any())).thenReturn(progress); @@ -292,11 +304,8 @@ flutter: ), ).thenAnswer((_) async => app); when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => release); + () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => [release]); when( () => codePushClientWrapper.getReleaseArtifacts( appId: any(named: 'appId'), @@ -341,6 +350,11 @@ flutter: when(() => bundletool.getVersionCode(any())).thenAnswer( (_) async => versionCode, ); + when( + () => shorebirdFlutterManager.installRevision( + revision: any(named: 'revision'), + ), + ).thenAnswer((_) async {}); when( () => shorebirdValidator.validatePreconditions( checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), @@ -376,19 +390,6 @@ flutter: ).called(1); }); - 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( - () => runWithOverrides(command.run), - getCurrentDirectory: () => tempDir, - ); - - expect(exitCode, equals(ExitCode.software.code)); - }); - test( 'exits with usage code when ' 'both --dry-run and --force are specified', () async { @@ -402,6 +403,232 @@ flutter: expect(exitCode, equals(ExitCode.usage.code)); }); + test('prompts for release when release-version is not specified', () async { + when(() => argResults['release-version']).thenReturn(null); + when( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).thenReturn(release); + try { + await runWithOverrides(command.run); + } catch (_) {} + await untilCalled( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ); + final display = verify( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: captureAny(named: 'display'), + ), + ).captured.single as String Function(Release); + expect(display(release), equals(release.version)); + }); + + test('exits early when no releases are found', () async { + when(() => argResults['release-version']).thenReturn(null); + when( + () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + try { + await runWithOverrides(command.run); + } catch (_) {} + verifyNever( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: captureAny(named: 'display'), + ), + ); + verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); + verify(() => logger.info('No releases found')).called(1); + }); + + test( + '''exits with code 70 if release is in draft state for the android platform''', + () async { + when( + () => codePushClientWrapper.getReleases( + appId: any(named: 'appId'), + ), + ).thenAnswer( + (_) async => const [ + Release( + id: 0, + appId: appId, + version: version, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + platformStatuses: {releasePlatform: ReleaseStatus.draft}, + ), + ], + ); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, ExitCode.software.code); + verify( + () => logger.err(''' +Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete. + +Please re-run the release command for this version or create a new release.'''), + ).called(1); + }); + + test('proceeds if release is in draft state for non-android platform', + () async { + when( + () => codePushClientWrapper.getReleases( + appId: any(named: 'appId'), + ), + ).thenAnswer( + (_) async => const [ + Release( + id: 0, + appId: appId, + version: version, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft}, + ) + ], + ); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, ExitCode.success.code); + }); + + test( + 'installs correct flutter revision ' + 'when release flutter revision differs', () async { + const otherRevision = 'other-revision'; + when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + + final exitCode = await IOOverrides.runZoned( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.success.code)); + verify( + () => logger.progress( + 'Switching to Flutter revision ${release.flutterRevision}', + ), + ).called(1); + verify( + () => shorebirdFlutterManager.installRevision( + revision: release.flutterRevision, + ), + ).called(1); + }); + + test( + 'builds using correct flutter revision ' + 'when release flutter revision differs', () async { + when( + () => platform.script, + ).thenReturn(Uri.parse('file:///bin/cache/shorebird.snapshot')); + const otherRevision = 'other-revision'; + when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); + final processWrapper = _MockProcessWrapper(); + when( + () => processWrapper.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + environment: any(named: 'environment'), + ), + ).thenAnswer((_) async => flutterBuildProcessResult); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + await IOOverrides.runZoned( + () => runWithOverrides( + () => runScoped( + () => command.run(), + values: { + processRef.overrideWith( + () => ShorebirdProcess( + logger: logger, + processWrapper: processWrapper, + ), + ), + }, + ), + ), + getCurrentDirectory: () => tempDir, + ); + verify( + () => processWrapper.run( + '/bin/cache/flutter/${release.flutterRevision}/bin/flutter', + any(), + runInShell: true, + workingDirectory: any(named: 'workingDirectory'), + environment: any(named: 'environment'), + ), + ).called(1); + }); + + test( + 'exits with code 70 when ' + 'unable to install correct flutter revision', () async { + final exception = Exception('oops'); + const otherRevision = 'other-revision'; + when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); + when( + () => shorebirdFlutterManager.installRevision( + revision: any(named: 'revision'), + ), + ).thenThrow(exception); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + + final exitCode = await IOOverrides.runZoned( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.progress( + 'Switching to Flutter revision ${release.flutterRevision}', + ), + ).called(1); + verify( + () => shorebirdFlutterManager.installRevision( + revision: release.flutterRevision, + ), + ).called(1); + verify(() => progress.fail('$exception')).called(1); + }); + + 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( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + + expect(exitCode, equals(ExitCode.software.code)); + }); + test('aborts when user opts out', () async { when(() => logger.confirm(any())).thenReturn(false); final tempDir = setUpTempDir(); @@ -414,140 +641,6 @@ flutter: verify(() => logger.info('Aborting.')).called(1); }); - test( - '''exits with code 70 if release is in draft state for the android platform''', - () async { - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer( - (_) async => const Release( - id: 0, - appId: appId, - version: version, - flutterRevision: flutterRevision, - displayName: '1.2.3+1', - platformStatuses: {releasePlatform: ReleaseStatus.draft}, - ), - ); - final tempDir = setUpTempDir(); - setUpTempArtifacts(tempDir); - final exitCode = await IOOverrides.runZoned( - () => runWithOverrides(command.run), - getCurrentDirectory: () => tempDir, - ); - expect(exitCode, ExitCode.software.code); - verify( - () => logger.err(''' -Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete. - -Please re-run the release command for this version or create a new release.'''), - ).called(1); - }, - ); - - test( - 'proceeds if release is in draft state for non-android platform', - () async { - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer( - (_) async => const Release( - id: 0, - appId: appId, - version: version, - flutterRevision: flutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft}, - ), - ); - final tempDir = setUpTempDir(); - setUpTempArtifacts(tempDir); - final exitCode = await IOOverrides.runZoned( - () => runWithOverrides(command.run), - getCurrentDirectory: () => tempDir, - ); - expect(exitCode, ExitCode.success.code); - }, - ); - - test( - 'errors when shorebird flutter revision ' - 'does not match release revision', () async { - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - final tempDir = setUpTempDir(); - setUpTempArtifacts(tempDir); - - final exitCode = await IOOverrides.runZoned( - () => runWithOverrides(command.run), - getCurrentDirectory: () => tempDir, - ); - - expect(exitCode, ExitCode.software.code); - verify( - () => logger.err(''' -Either create a new release using: - ${lightCyan.wrap('shorebird release aar')} - -Or downgrade your Flutter version and try again using: - ${lightCyan.wrap('cd ${shorebirdEnv.flutterDirectory.path}')} - ${lightCyan.wrap('git checkout ${release.flutterRevision}')} - -Shorebird plans to support this automatically, let us know if it's important to you: -https://github.com/shorebirdtech/shorebird/issues/472 -'''), - ).called(1); - }); - - test('errors when detecting release version name fails', () async { - final exception = Exception( - 'Failed to extract version name from app bundle: oops', - ); - when(() => bundletool.getVersionName(any())).thenThrow(exception); - final tempDir = setUpTempDir(); - setUpTempArtifacts(tempDir); - final exitCode = await IOOverrides.runZoned( - () => runWithOverrides(command.run), - getCurrentDirectory: () => tempDir, - ); - expect(exitCode, ExitCode.software.code); - verify(() => progress.fail('$exception')).called(1); - }); - - test('errors when detecting release version code fails', () async { - final exception = Exception( - 'Failed to extract version code from app bundle: oops', - ); - when(() => bundletool.getVersionCode(any())).thenThrow(exception); - final tempDir = setUpTempDir(); - setUpTempArtifacts(tempDir); - final exitCode = await IOOverrides.runZoned( - () => runWithOverrides(command.run), - getCurrentDirectory: () => tempDir, - ); - expect(exitCode, ExitCode.software.code); - verify(() => progress.fail('$exception')).called(1); - }); - - test('prints release version when detected', () async { - final tempDir = setUpTempDir(); - setUpTempArtifacts(tempDir); - final exitCode = await IOOverrides.runZoned( - () => runWithOverrides(command.run), - getCurrentDirectory: () => tempDir, - ); - - expect(exitCode, equals(ExitCode.success.code)); - verify(() => progress.complete('Detected release version 1.2.3+1')) - .called(1); - }); - test('throws error when release artifact does not exist.', () async { when(() => httpClient.send(any())).thenAnswer( (_) async => http.StreamedResponse(