diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart index b507f088..5d6b3018 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:archive/archive_io.dart'; +import 'package:collection/collection.dart'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:mason_logger/mason_logger.dart'; @@ -51,15 +52,6 @@ The version of the associated release (e.g. "1.0.0"). This should be the version of the Android app that is using this module.''', mandatory: true, ) - ..addOption( - 'channel', - help: 'The channel the patch should be promoted to (e.g. "stable").', - allowed: ['stable'], - allowedHelp: { - 'stable': 'The stable channel which is consumed by production apps.' - }, - defaultsTo: 'stable', - ) ..addFlag( 'force', abbr: 'f', @@ -112,16 +104,30 @@ of the Android app that is using this module.''', return ExitCode.config.code; } - final buildNumber = results['build-number'] as String; - final releaseVersion = results['release-version'] as String; - final shorebirdYaml = shorebirdEnv.getShorebirdYaml()!; final appId = shorebirdYaml.getAppId(); final app = await codePushClientWrapper.getApp(appId: appId); - final release = await codePushClientWrapper.getRelease( - appId: appId, - releaseVersion: releaseVersion, + final releases = await codePushClientWrapper.getReleases(appId: appId); + final releaseVersion = results['release-version'] as String? ?? + await _promptForReleaseVersion(releases); + + final release = releases.firstWhereOrNull( + (r) => r.version == releaseVersion, ); + + if (releaseVersion == null || release == null) { + logger.info('No releases found'); + return ExitCode.success.code; + } + + if (release.platformStatuses[ReleasePlatform.android] == + ReleaseStatus.draft) { + logger.err(''' +Release $releaseVersion 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.'''); + return ExitCode.software.code; + } + final shorebirdFlutterRevision = shorebirdEnv.flutterRevision; if (release.flutterRevision != shorebirdFlutterRevision) { final installFlutterRevisionProgress = logger.progress( @@ -138,6 +144,32 @@ of the Android app that is using this module.''', } } + const platform = ReleasePlatform.android; + final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts( + appId: appId, + releaseId: release.id, + architectures: architectures, + platform: platform, + ); + + final releaseAarArtifact = await codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: release.id, + arch: 'aar', + platform: platform, + ); + + final Map releaseArtifactPaths; + try { + releaseArtifactPaths = await _downloadReleaseArtifacts( + releaseArtifacts: releaseArtifacts, + httpClient: _httpClient, + ); + } catch (_) { + return ExitCode.software.code; + } + + final buildNumber = results['build-number'] as String; final buildProgress = logger.progress('Building patch'); try { await runScoped( @@ -156,30 +188,10 @@ of the Android app that is using this module.''', return ExitCode.software.code; } - const platform = ReleasePlatform.android; - final channelName = results['channel'] as String; - - if (release.platformStatuses[ReleasePlatform.android] == - ReleaseStatus.draft) { - logger.err(''' -Release $releaseVersion 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.'''); - return ExitCode.software.code; - } - - final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts( - appId: appId, - releaseId: release.id, - architectures: architectures, - platform: platform, - ); - - final releaseAarArtifact = await codePushClientWrapper.getReleaseArtifact( - appId: appId, - releaseId: release.id, - arch: 'aar', - platform: platform, + final extractedAarDir = await extractAar( + packageName: shorebirdEnv.androidPackageName!, + buildNumber: buildNumber, + unzipFn: _unzipFn, ); final shouldContinue = @@ -194,25 +206,8 @@ Please re-run the release command for this version or create a new release.'''); archiveDiffer: _archiveDiffer, force: force, ); - if (!shouldContinue) { - return ExitCode.success.code; - } - final Map releaseArtifactPaths; - try { - releaseArtifactPaths = await _downloadReleaseArtifacts( - releaseArtifacts: releaseArtifacts, - httpClient: _httpClient, - ); - } catch (_) { - return ExitCode.software.code; - } - - final extractedAarDir = await extractAar( - packageName: shorebirdEnv.androidPackageName!, - buildNumber: buildNumber, - unzipFn: _unzipFn, - ); + if (!shouldContinue) return ExitCode.success.code; final patchArtifactBundles = await _createPatchArtifacts( releaseArtifactPaths: releaseArtifactPaths, @@ -235,6 +230,7 @@ Please re-run the release command for this version or create a new release.'''); return ExitCode.success.code; } + const channelName = 'stable'; final summary = [ '''šŸ“± App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''', 'šŸ“¦ Release Version: ${lightCyan.wrap(releaseVersion)}', @@ -273,6 +269,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?> _createPatchArtifacts({ required Map releaseArtifactPaths, required String extractedAarDirectory, diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_aar_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_aar_command_test.dart index 175ea102..b1ae422e 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_aar_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_aar_command_test.dart @@ -267,11 +267,8 @@ void main() { () => codePushClientWrapper.getApp(appId: any(named: 'appId')), ).thenAnswer((_) async => appMetadata); 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'), @@ -360,12 +357,6 @@ void main() { ).called(1); }); - test('exits with 78 if no module entry exists in pubspec.yaml', () async { - when(() => shorebirdEnv.androidPackageName).thenReturn(null); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.config.code); - }); - test( 'exits with usage code when ' 'both --dry-run and --force are specified', () async { @@ -375,6 +366,146 @@ void main() { expect(exitCode, equals(ExitCode.usage.code)); }); + test('exits with 78 if no module entry exists in pubspec.yaml', () async { + when(() => shorebirdEnv.androidPackageName).thenReturn(null); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.config.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.android: 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('throws error when release artifact does not exist.', () async { + when( + () => httpClient.send( + any( + that: isA().having( + (req) => req.url.toString(), + 'url', + endsWith('so'), + ), + ), + ), + ).thenAnswer( + (_) async => http.StreamedResponse( + const Stream.empty(), + HttpStatus.notFound, + reasonPhrase: 'Not Found', + ), + ); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + () => runWithOverrides(command.run), + getCurrentDirectory: () => tempDir, + ); + verify( + () => progress.fail(any(that: contains('404 Not Found'))), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + test( 'installs correct flutter revision ' 'when release flutter revision differs', () async { @@ -486,98 +617,6 @@ void main() { expect(exitCode, equals(ExitCode.software.code)); }); - 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.android: 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('throws error when release artifact does not exist.', () async { - when( - () => httpClient.send( - any( - that: isA().having( - (req) => req.url.toString(), - 'url', - endsWith('so'), - ), - ), - ), - ).thenAnswer( - (_) async => http.StreamedResponse( - const Stream.empty(), - HttpStatus.notFound, - reasonPhrase: 'Not Found', - ), - ); - final tempDir = setUpTempDir(); - setUpTempArtifacts(tempDir); - final exitCode = await IOOverrides.runZoned( - () => runWithOverrides(command.run), - getCurrentDirectory: () => tempDir, - ); - verify( - () => progress.fail(any(that: contains('404 Not Found'))), - ).called(1); - expect(exitCode, ExitCode.software.code); - }); - test('exits if confirmUnpatchableDiffsIfNecessary returns false', () async { when(() => argResults['force']).thenReturn(false); when( @@ -710,12 +749,7 @@ Please re-run the release command for this version or create a new release.'''), verify(() => logger.success('\nāœ… Published Patch!')).called(1); verify(() => codePushClientWrapper.getApp(appId: appId)).called(1); - verify( - () => codePushClientWrapper.getRelease( - appId: appId, - releaseVersion: version, - ), - ).called(1); + verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); verify( () => codePushClientWrapper.getReleaseArtifacts( appId: appId,