diff --git a/packages/shorebird_cli/bin/shorebird.dart b/packages/shorebird_cli/bin/shorebird.dart index cf5be36c..934edca3 100644 --- a/packages/shorebird_cli/bin/shorebird.dart +++ b/packages/shorebird_cli/bin/shorebird.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:scoped/scoped.dart'; +import 'package:shorebird_cli/src/adb.dart'; import 'package:shorebird_cli/src/android_sdk.dart'; import 'package:shorebird_cli/src/android_studio.dart'; import 'package:shorebird_cli/src/auth/auth.dart'; @@ -18,6 +19,7 @@ Future main(List args) async { await runScoped( () async => ShorebirdCliCommandRunner().run(args), values: { + adbRef, androidSdkRef, androidStudioRef, authRef, diff --git a/packages/shorebird_cli/lib/src/adb.dart b/packages/shorebird_cli/lib/src/adb.dart index 81c7de20..f9be163d 100644 --- a/packages/shorebird_cli/lib/src/adb.dart +++ b/packages/shorebird_cli/lib/src/adb.dart @@ -1,9 +1,16 @@ import 'dart:async'; import 'dart:io'; +import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/android_sdk.dart'; import 'package:shorebird_cli/src/process.dart'; +/// A reference to a [Adb] instance. +final adbRef = create(Adb.new); + +/// The [Adb] instance available in the current zone. +Adb get adb => read(adbRef); + /// A wrapper around the `adb` command. class Adb { Future _exec(String command) async { diff --git a/packages/shorebird_cli/lib/src/command_runner.dart b/packages/shorebird_cli/lib/src/command_runner.dart index 2dbbb784..8e0388ac 100644 --- a/packages/shorebird_cli/lib/src/command_runner.dart +++ b/packages/shorebird_cli/lib/src/command_runner.dart @@ -65,6 +65,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { addCommand(LoginCiCommand()); addCommand(LogoutCommand()); addCommand(PatchCommand()); + addCommand(PreviewCommand()); addCommand(ReleaseCommand()); addCommand(ReleasesCommand()); addCommand(RunCommand()); diff --git a/packages/shorebird_cli/lib/src/commands/commands.dart b/packages/shorebird_cli/lib/src/commands/commands.dart index 2d37fca4..3b18800f 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -9,6 +9,7 @@ export 'login_ci_command.dart'; export 'login_command.dart'; export 'logout_command.dart'; export 'patch/patch.dart'; +export 'preview_command.dart'; export 'release/release.dart'; export 'releases/releases.dart'; export 'run_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/preview_command.dart b/packages/shorebird_cli/lib/src/commands/preview_command.dart new file mode 100644 index 00000000..3e1a8972 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/preview_command.dart @@ -0,0 +1,187 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/adb.dart'; +import 'package:shorebird_cli/src/bundletool.dart'; +import 'package:shorebird_cli/src/cache.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_validation_mixin.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; + +/// {@template preview_command} +/// `shorebird preview` command. +/// {@endtemplate} +class PreviewCommand extends ShorebirdCommand + with ShorebirdConfigMixin, ShorebirdValidationMixin { + /// {@macro preview_command} + PreviewCommand({super.validators}) { + argParser + ..addOption( + 'device-id', + help: 'The ID of the device or simulator to preview the release on.', + ) + ..addOption( + 'app-id', + help: 'The ID of the app to preview the release for.', + ) + ..addOption( + 'release-version', + help: 'The version of the release (e.g. "1.0.0").', + ); + } + + @override + String get name => 'preview'; + + @override + String get description => 'Preview a specific release on a device.'; + + @override + Future run() async { + try { + await validatePreconditions(checkUserIsAuthenticated: true); + } on PreconditionFailedException catch (error) { + return error.exitCode.code; + } + + const platform = 'android'; + final appId = results['app-id'] as String? ?? await promptForApp(); + + if (appId == null) { + logger.info('No apps found'); + return ExitCode.success.code; + } + + final releaseVersion = results['release-version'] as String? ?? + await promptForReleaseVersion(appId); + + if (releaseVersion == null) { + logger.info('No releases found'); + return ExitCode.success.code; + } + + final previewDirectory = cache.getPreviewDirectory(appId); + final aabPath = p.join( + previewDirectory.path, + '${platform}_$releaseVersion.aab', + ); + + if (!File(aabPath).existsSync()) { + final downloadArtifactProgress = logger.progress('Downloading release'); + try { + final release = await codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ); + final releaseAabArtifact = + await codePushClientWrapper.getReleaseArtifact( + releaseId: release.id, + // TODO(felangel): add iOS support + arch: 'aab', + platform: platform, + ); + + await releaseAabArtifact.url.download(aabPath); + downloadArtifactProgress.complete(); + } catch (error) { + downloadArtifactProgress.fail('$error'); + return ExitCode.software.code; + } + } + + final extractMetadataProgress = logger.progress('Extracting metadata'); + late String package; + try { + package = await bundletool.getPackageName(aabPath); + extractMetadataProgress.complete(); + } catch (error) { + extractMetadataProgress.fail('$error'); + return ExitCode.software.code; + } + + final apksPath = p.join( + previewDirectory.path, + '${platform}_$releaseVersion.apks', + ); + + if (!File(apksPath).existsSync()) { + final buildApksProgress = logger.progress('Building apks'); + try { + await bundletool.buildApks(bundle: aabPath, output: apksPath); + buildApksProgress.complete(); + } catch (error) { + buildApksProgress.fail('$error'); + return ExitCode.software.code; + } + } + + final installApksProgress = logger.progress('Installing apks'); + try { + await bundletool.installApks(apks: apksPath); + installApksProgress.complete(); + } catch (error) { + installApksProgress.fail('$error'); + return ExitCode.software.code; + } + + final startAppProgress = logger.progress('Starting app'); + try { + await adb.startApp(package); + startAppProgress.complete(); + } catch (error) { + startAppProgress.fail('$error'); + return ExitCode.software.code; + } + + final process = await adb.logcat(filter: 'flutter'); + process.stdout.listen((event) { + logger.info(utf8.decode(event)); + }); + process.stderr.listen((event) { + logger.err(utf8.decode(event)); + }); + + return process.exitCode; + } + + Future promptForApp() async { + final apps = await codePushClientWrapper.getApps(); + if (apps.isEmpty) return null; + final app = logger.chooseOne( + 'Which app would you like to preview?', + choices: apps, + display: (app) => app.displayName, + ); + return app.appId; + } + + Future promptForReleaseVersion(String appId) async { + final releases = await codePushClientWrapper.getReleases(appId: appId); + if (releases.isEmpty) return null; + final release = logger.chooseOne( + 'Which release would you like to preview?', + choices: releases, + display: (release) => release.version, + ); + return release.version; + } +} + +extension on String { + Future download(String path) async { + final uri = Uri.parse(this); + final client = HttpClient(); + final request = await client.getUrl(uri); + final response = await request.close(); + if (response.statusCode != 200) { + throw Exception('Failed to download artifact at $this'); + } + final file = File(path)..createSync(recursive: true); + await response.pipe(file.openWrite()); + } +} diff --git a/packages/shorebird_cli/test/src/commands/preview_command_test.dart b/packages/shorebird_cli/test/src/commands/preview_command_test.dart new file mode 100644 index 00000000..79bc65a6 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/preview_command_test.dart @@ -0,0 +1,410 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +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:scoped/scoped.dart'; +import 'package:shorebird_cli/src/adb.dart'; +import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/bundletool.dart'; +import 'package:shorebird_cli/src/cache.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +class _MockAdb extends Mock implements Adb {} + +class _MockAppMetadata extends Mock implements AppMetadata {} + +class _MockArgResults extends Mock implements ArgResults {} + +class _MockAuth extends Mock implements Auth {} + +class _MockBundletool extends Mock implements Bundletool {} + +class _MockCache extends Mock implements Cache {} + +class _MockCodePushClientWrapper extends Mock + implements CodePushClientWrapper {} + +class _MockHttpClient extends Mock implements HttpClient {} + +class _MockHttpClientRequest extends Mock implements HttpClientRequest {} + +class _MockHttpClientResponse extends Mock implements HttpClientResponse {} + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +class _MockProcess extends Mock implements Process {} + +class _MockRelease extends Mock implements Release {} + +class _MockReleaseArtifact extends Mock implements ReleaseArtifact {} + +void main() { + group(PreviewCommand, () { + const appId = 'test-app-id'; + const appDisplayName = 'Test App'; + const platform = 'android'; + const releaseVersion = '1.2.3'; + const releaseId = 42; + const releaseArtifactUrl = 'https://example.com/release.aab'; + const packageName = 'com.example.app'; + + late Adb adb; + late AppMetadata app; + late ArgResults argResults; + late Auth auth; + late Bundletool bundletool; + late Cache cache; + late CodePushClientWrapper codePushClientWrapper; + late HttpClient httpClient; + late HttpClientRequest httpClientRequest; + late HttpClientResponse httpClientResponse; + late Logger logger; + late Directory previewDirectory; + late Process process; + late Progress progress; + late Release release; + late ReleaseArtifact releaseArtifact; + late PreviewCommand command; + + R runWithOverrides(R Function() body) { + return HttpOverrides.runZoned( + () => runScoped( + body, + values: { + adbRef.overrideWith(() => adb), + authRef.overrideWith(() => auth), + bundletoolRef.overrideWith(() => bundletool), + cacheRef.overrideWith(() => cache), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + loggerRef.overrideWith(() => logger), + }, + ), + createHttpClient: (_) => httpClient, + ); + } + + String aabPath() => p.join( + previewDirectory.path, + '${platform}_$releaseVersion.aab', + ); + + String apksPath() => p.join( + previewDirectory.path, + '${platform}_$releaseVersion.apks', + ); + + setUpAll(() { + registerFallbackValue(Uri()); + registerFallbackValue(StreamController>()); + }); + + setUp(() { + adb = _MockAdb(); + app = _MockAppMetadata(); + argResults = _MockArgResults(); + auth = _MockAuth(); + bundletool = _MockBundletool(); + cache = _MockCache(); + codePushClientWrapper = _MockCodePushClientWrapper(); + httpClient = _MockHttpClient(); + httpClientRequest = _MockHttpClientRequest(); + httpClientResponse = _MockHttpClientResponse(); + logger = _MockLogger(); + previewDirectory = Directory.systemTemp.createTempSync(); + process = _MockProcess(); + progress = _MockProgress(); + release = _MockRelease(); + releaseArtifact = _MockReleaseArtifact(); + command = PreviewCommand()..testArgResults = argResults; + + when(() => argResults['app-id']).thenReturn(appId); + when(() => argResults['release-version']).thenReturn(releaseVersion); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => cache.getPreviewDirectory(any())).thenReturn(previewDirectory); + when( + () => codePushClientWrapper.getApps(), + ).thenAnswer((_) async => [app]); + when( + () => codePushClientWrapper.getReleases(appId: appId), + ).thenAnswer((_) async => [release]); + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClientWrapper.getReleaseArtifact( + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async => releaseArtifact); + when(() => app.appId).thenReturn(appId); + when(() => app.displayName).thenReturn(appDisplayName); + when(() => release.id).thenReturn(releaseId); + when(() => release.version).thenReturn(releaseVersion); + when(() => releaseArtifact.url).thenReturn(releaseArtifactUrl); + when(() => logger.progress(any())).thenReturn(progress); + when( + () => httpClient.getUrl(any()), + ).thenAnswer((_) async => httpClientRequest); + when( + () => httpClientRequest.close(), + ).thenAnswer((_) async => httpClientResponse); + when(() => httpClientResponse.statusCode).thenReturn(HttpStatus.ok); + when(() => httpClientResponse.pipe(any())).thenAnswer((_) async {}); + when( + () => bundletool.getPackageName(any()), + ).thenAnswer((_) async => packageName); + when( + () => bundletool.buildApks( + bundle: any(named: 'bundle'), + output: any(named: 'output'), + ), + ).thenAnswer((_) async {}); + when( + () => bundletool.installApks(apks: any(named: 'apks')), + ).thenAnswer((_) async {}); + when(() => adb.startApp(any())).thenAnswer((_) async {}); + when( + () => adb.logcat(filter: any(named: 'filter')), + ).thenAnswer((_) async => process); + when( + () => process.exitCode, + ).thenAnswer((_) async => ExitCode.success.code); + when(() => process.stdout).thenAnswer((_) => const Stream.empty()); + when(() => process.stderr).thenAnswer((_) => const Stream.empty()); + }); + + test('returns no user error when not logged in', () async { + when(() => auth.isAuthenticated).thenReturn(false); + final result = await runWithOverrides(command.run); + expect(result, ExitCode.noUser.code); + }); + + test('exits with code 70 when querying for release fails', () async { + final exception = Exception('oops'); + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenThrow(exception); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify( + () => codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: releaseVersion, + ), + ).called(1); + }); + + test('exits with code 70 when querying for release artifact fails', + () async { + final exception = Exception('oops'); + when( + () => codePushClientWrapper.getReleaseArtifact( + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + ), + ).thenThrow(exception); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify( + () => codePushClientWrapper.getReleaseArtifact( + releaseId: releaseId, + arch: 'aab', + platform: platform, + ), + ).called(1); + }); + + test('exits with code 70 when downloading release artifact fails', + () async { + final exception = Exception('oops'); + when(() => httpClient.getUrl(any())).thenThrow(exception); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify(() => httpClient.getUrl(Uri.parse(releaseArtifactUrl))).called(1); + }); + + test( + 'exits with code 70 when downloading release artifact ' + 'returns non-200 response', () async { + when( + () => httpClientResponse.statusCode, + ).thenReturn(HttpStatus.badRequest); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify(() => httpClientRequest.close()).called(1); + verify(() => httpClientResponse.statusCode).called(1); + }); + + test('exits with code 70 when extracting metadata fails', () async { + final exception = Exception('oops'); + when(() => bundletool.getPackageName(any())).thenThrow(exception); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify(() => bundletool.getPackageName(aabPath())).called(1); + }); + + test('exits with code 70 when building apks fails', () async { + final exception = Exception('oops'); + when( + () => bundletool.buildApks( + bundle: any(named: 'bundle'), + output: any(named: 'output'), + ), + ).thenThrow(exception); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify( + () => bundletool.buildApks(bundle: aabPath(), output: apksPath()), + ).called(1); + }); + + test('exits with code 70 when installing apks fails', () async { + final exception = Exception('oops'); + when( + () => bundletool.installApks(apks: any(named: 'apks')), + ).thenThrow(exception); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify(() => bundletool.installApks(apks: apksPath())).called(1); + }); + + test('exits with code 70 when starting app fails', () async { + final exception = Exception('oops'); + when(() => adb.startApp(any())).thenThrow(exception); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.software.code)); + verify(() => adb.startApp(packageName)).called(1); + }); + + test('exits with non-zero exit code when logcat process fails', () async { + when(() => process.exitCode).thenAnswer((_) async => 1); + final result = await runWithOverrides(command.run); + expect(result, equals(1)); + verify(() => adb.logcat(filter: 'flutter')).called(1); + }); + + test('pipes stdout output to logger', () async { + final completer = Completer(); + when(() => process.exitCode).thenAnswer((_) => completer.future); + const output = 'hello world'; + when( + () => process.stdout, + ).thenAnswer((_) => Stream.value(utf8.encode(output))); + final result = runWithOverrides(command.run); + completer.complete(0); + await expectLater(await result, equals(ExitCode.success.code)); + verify(() => logger.info(output)).called(1); + }); + + test('pipes stderr output to logger', () async { + final completer = Completer(); + when(() => process.exitCode).thenAnswer((_) => completer.future); + const output = 'hello world'; + when( + () => process.stderr, + ).thenAnswer((_) => Stream.value(utf8.encode(output))); + final result = runWithOverrides(command.run); + completer.complete(0); + await expectLater(await result, equals(ExitCode.success.code)); + verify(() => logger.err(output)).called(1); + }); + + test('queries for apps when app-id is not specified', () async { + when(() => argResults['app-id']).thenReturn(null); + when( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).thenReturn(app); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + final captured = verify( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: captureAny(named: 'display'), + ), + ).captured.single as String Function(AppMetadata); + expect(captured(app), equals(app.displayName)); + verify(() => codePushClientWrapper.getApps()).called(1); + }); + + test('exits early when no apps are found', () async { + when(() => argResults['app-id']).thenReturn(null); + when(() => codePushClientWrapper.getApps()).thenAnswer((_) async => []); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + verifyNever( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: captureAny(named: 'display'), + ), + ); + verify(() => codePushClientWrapper.getApps()).called(1); + verify(() => logger.info('No apps found')).called(1); + }); + + test('exits early when no releases are found', () async { + when(() => argResults['release-version']).thenReturn(null); + when( + () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + 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( + 'queries for releases 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); + final result = await runWithOverrides(command.run); + expect(result, equals(ExitCode.success.code)); + final captured = verify( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: captureAny(named: 'display'), + ), + ).captured.single as String Function(Release); + expect(captured(release), equals(releaseVersion)); + verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); + }); + }); +}