From 2fc2af64286acfaebf8f4bb0b1f7f16c1d33fc40 Mon Sep 17 00:00:00 2001 From: Felix Angelov Date: Mon, 6 Mar 2023 17:46:21 -0600 Subject: [PATCH] feat(shorebird_cli): add run command (#29) --- .../shorebird_cli/lib/src/command_runner.dart | 7 + .../lib/src/commands/commands.dart | 1 + .../lib/src/commands/run_command.dart | 168 ++++++++++++++++ packages/shorebird_cli/lib/src/compute.dart | 41 ++++ .../test/src/commands/run_command_test.dart | 183 ++++++++++++++++++ .../shorebird_cli/test/src/compute_test.dart | 17 ++ 6 files changed, 417 insertions(+) create mode 100644 packages/shorebird_cli/lib/src/commands/run_command.dart create mode 100644 packages/shorebird_cli/lib/src/compute.dart create mode 100644 packages/shorebird_cli/test/src/commands/run_command_test.dart create mode 100644 packages/shorebird_cli/test/src/compute_test.dart diff --git a/packages/shorebird_cli/lib/src/command_runner.dart b/packages/shorebird_cli/lib/src/command_runner.dart index 9c4e9bf9..7dfa90fa 100644 --- a/packages/shorebird_cli/lib/src/command_runner.dart +++ b/packages/shorebird_cli/lib/src/command_runner.dart @@ -60,6 +60,13 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner { logger: _logger, ), ); + addCommand( + RunCommand( + auth: authentication, + codePushApiClientBuilder: buildCodePushApiClient, + logger: _logger, + ), + ); addCommand(UpdateCommand(logger: _logger, pubUpdater: _pubUpdater)); } diff --git a/packages/shorebird_cli/lib/src/commands/commands.dart b/packages/shorebird_cli/lib/src/commands/commands.dart index 0d2ba70c..b7a4e731 100644 --- a/packages/shorebird_cli/lib/src/commands/commands.dart +++ b/packages/shorebird_cli/lib/src/commands/commands.dart @@ -2,4 +2,5 @@ export 'build_command.dart'; export 'login_command.dart'; export 'logout_command.dart'; export 'publish_command.dart'; +export 'run_command.dart'; export 'update_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/run_command.dart b/packages/shorebird_cli/lib/src/commands/run_command.dart new file mode 100644 index 00000000..ba633faa --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/run_command.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive_io.dart'; +import 'package:args/command_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/command_runner.dart'; +import 'package:shorebird_cli/src/compute.dart'; +import 'package:shorebird_code_push_api_client/shorebird_code_push_api_client.dart'; + +typedef StartProcess = Future Function( + String executable, + List arguments, { + bool runInShell, +}); + +/// {@template run_command} +/// +/// `shorebird run` +/// Run the Flutter application. +/// {@endtemplate} +class RunCommand extends Command { + /// {@macro run_command} + RunCommand({ + required Auth auth, + required ShorebirdCodePushApiClientBuilder codePushApiClientBuilder, + required Logger logger, + StartProcess? startProcess, + }) : _auth = auth, + _buildCodePushApiClient = codePushApiClientBuilder, + _logger = logger, + _startProcess = startProcess ?? Process.start; + + @override + String get description => 'Run the Flutter application.'; + + @override + String get name => 'run'; + + final Auth _auth; + final ShorebirdCodePushApiClientBuilder _buildCodePushApiClient; + final Logger _logger; + final StartProcess _startProcess; + + @override + Future run() async { + final session = _auth.currentSession; + if (session == null) { + _logger + ..err('You must be logged in to run.') + ..err("Run 'shorebird login' to log in and try again."); + return ExitCode.noUser.code; + } + + // This will likely change in the future as each Flutter application + // will not need to cache its own copy of the Shorebird engine. + final shorebirdEnginePath = p.join( + Directory.current.path, + '.shorebird', + 'engine', + ); + final shorebirdEngine = Directory(shorebirdEnginePath); + final shorebirdEngineCache = File( + p.join(Directory.current.path, '.shorebird', 'cache', 'engine.zip'), + ); + + if (!shorebirdEngineCache.existsSync()) { + final downloadEngineProgress = _logger.progress( + 'Downloading shorebird engine', + ); + try { + final codePushApiClient = _buildCodePushApiClient( + apiKey: session.apiKey, + ); + await _downloadShorebirdEngine( + codePushApiClient, + shorebirdEngineCache.path, + ); + downloadEngineProgress.complete(); + } catch (error) { + downloadEngineProgress.fail( + 'Failed to download shorebird engine: $error', + ); + return ExitCode.software.code; + } + } + + if (!shorebirdEngine.existsSync()) { + final buildingEngine = _logger.progress( + 'Building shorebird engine', + ); + try { + await _extractShorebirdEngine( + shorebirdEngineCache.path, + shorebirdEngine.path, + ); + buildingEngine.complete(); + } catch (error) { + buildingEngine.fail( + 'Failed to build shorebird engine: $error', + ); + return ExitCode.software.code; + } + } + + _logger.info('Running app...'); + final process = await _startProcess( + 'flutter', + [ + 'run', + // Eventually we should support running in both debug and release mode. + '--release', + '--local-engine-src-path', + shorebirdEnginePath, + '--local-engine', + // This is temporary because the Shorebird engine currently + // only supports Android arm64. + 'android_release_arm64', + if (argResults?.rest != null) ...argResults!.rest + ], + runInShell: true, + ); + + process.stdout.listen((event) { + _logger.info(utf8.decode(event)); + }); + process.stderr.listen((event) { + _logger.err(utf8.decode(event)); + }); + + return process.exitCode; + } +} + +Future _downloadShorebirdEngine( + ShorebirdCodePushApiClient codePushClient, + String path, +) async { + final engine = await codePushClient.downloadEngine('latest'); + final targetFile = File(path); + + if (targetFile.existsSync()) targetFile.deleteSync(recursive: true); + + targetFile.createSync(recursive: true); + await targetFile.writeAsBytes(engine, flush: true); +} + +Future _extractShorebirdEngine( + String archivePath, + String targetPath, +) async { + final targetDirectory = Directory(targetPath); + + if (targetDirectory.existsSync()) targetDirectory.deleteSync(recursive: true); + + targetDirectory.createSync(recursive: true); + + await compute( + (path) async { + final inputStream = InputFileStream(path); + final archive = ZipDecoder().decodeBuffer(inputStream); + extractArchiveToDisk(archive, targetPath); + }, + archivePath, + ); +} diff --git a/packages/shorebird_cli/lib/src/compute.dart b/packages/shorebird_cli/lib/src/compute.dart new file mode 100644 index 00000000..8395c02a --- /dev/null +++ b/packages/shorebird_cli/lib/src/compute.dart @@ -0,0 +1,41 @@ +import 'dart:async'; +import 'dart:isolate'; + +/// Perform [computation] with [input] in an [Isolate]. +Future compute(FutureOr Function(M) computation, M input) async { + final resultPort = ReceivePort(); + final errorPort = ReceivePort(); + + await Isolate.spawn<_IsolateConfig>>( + _spawn, + _IsolateConfig>(computation, input, resultPort.sendPort), + onError: errorPort.sendPort, + ); + + final result = Completer(); + errorPort.listen((dynamic errorData) { + final data = errorData as List; + final exception = Exception(data[0]); + final stack = StackTrace.fromString(data[1] as String); + result.completeError(exception, stack); + }); + resultPort.listen((dynamic resultData) => result.complete(resultData as R)); + await result.future; + resultPort.close(); + errorPort.close(); + return result.future; +} + +class _IsolateConfig { + const _IsolateConfig(this.callback, this.message, this.resultPort); + + final R Function(M message) callback; + final M message; + final SendPort resultPort; + + FutureOr compute() => callback(message); +} + +Future _spawn(_IsolateConfig> configuration) async { + Isolate.exit(configuration.resultPort, await configuration.compute()); +} diff --git a/packages/shorebird_cli/test/src/commands/run_command_test.dart b/packages/shorebird_cli/test/src/commands/run_command_test.dart new file mode 100644 index 00000000..651e39a5 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/run_command_test.dart @@ -0,0 +1,183 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:archive/archive_io.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/run_command.dart'; +import 'package:shorebird_code_push_api_client/shorebird_code_push_api_client.dart'; +import 'package:test/test.dart'; + +class _MockAuth extends Mock implements Auth {} + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +class _MockProcess extends Mock implements Process {} + +class _MockShorebirdCodePushApiClient extends Mock + implements ShorebirdCodePushApiClient {} + +void main() { + group('run', () { + const session = Session( + apiKey: 'test-api-key', + projectId: 'test-project-id', + ); + + late Auth auth; + late Logger logger; + late Process process; + late ShorebirdCodePushApiClient codePushApiClient; + late RunCommand runCommand; + + setUp(() { + auth = _MockAuth(); + logger = _MockLogger(); + process = _MockProcess(); + codePushApiClient = _MockShorebirdCodePushApiClient(); + runCommand = RunCommand( + auth: auth, + logger: logger, + codePushApiClientBuilder: ({required String apiKey}) { + return codePushApiClient; + }, + startProcess: (executable, arguments, {bool runInShell = false}) async { + return process; + }, + ); + + when(() => logger.progress(any())).thenReturn(_MockProgress()); + }); + + test('exits with no user when not logged in', () async { + when(() => auth.currentSession).thenReturn(null); + + final result = await runCommand.run(); + expect(result, equals(ExitCode.noUser.code)); + + verify(() => logger.err('You must be logged in to run.')).called(1); + verify( + () => logger.err("Run 'shorebird login' to log in and try again."), + ).called(1); + }); + + test('exits with code 70 when downloading engine fails', () async { + final error = Exception('oops'); + when(() => auth.currentSession).thenReturn(session); + when( + () => codePushApiClient.downloadEngine(any()), + ).thenThrow(error); + final progress = _MockProgress(); + when(() => logger.progress(any())).thenReturn(progress); + + final result = await runCommand.run(); + expect(result, equals(ExitCode.software.code)); + + verify( + () => progress.fail('Failed to download shorebird engine: $error'), + ).called(1); + }); + + test('exits with code 70 when building the engine fails', () async { + final tempDir = Directory.systemTemp.createTempSync(); + Directory(p.join(tempDir.path, '.shorebird', 'cache')) + .createSync(recursive: true); + + when(() => auth.currentSession).thenReturn(session); + when( + () => codePushApiClient.downloadEngine(any()), + ).thenAnswer((_) async => Uint8List(0)); + final progress = _MockProgress(); + when(() => logger.progress(any())).thenReturn(progress); + + final result = await IOOverrides.runZoned( + () => runCommand.run(), + getCurrentDirectory: () => tempDir, + ); + expect(result, equals(ExitCode.software.code)); + + verify( + () => progress.fail( + any(that: contains('Failed to build shorebird engine:')), + ), + ).called(1); + }); + + test('exits with code when running the app fails', () async { + final tempDir = Directory.systemTemp.createTempSync(); + final engineCacheDir = Directory( + p.join(tempDir.path, '.shorebird', 'cache'), + )..createSync(recursive: true); + + ZipFileEncoder() + ..create(p.join(engineCacheDir.path, 'engine.zip')) + ..close(); + + when(() => auth.currentSession).thenReturn(session); + when( + () => codePushApiClient.downloadEngine(any()), + ).thenAnswer((_) async => Uint8List(0)); + + final progress = _MockProgress(); + when(() => logger.progress(any())).thenReturn(progress); + + const error = 'oops something went wrong'; + const exitCode = 1; + + when( + () => process.stdout, + ).thenAnswer((_) => const Stream.empty()); + when(() => process.stderr).thenAnswer( + (_) => Stream.value(utf8.encode(error)), + ); + when(() => process.exitCode).thenAnswer((_) async => exitCode); + + final result = await IOOverrides.runZoned( + () => runCommand.run(), + getCurrentDirectory: () => tempDir, + ); + + await expectLater(result, equals(exitCode)); + verify(() => logger.err(error)).called(1); + }); + + test('exits with code 0 when running the app succeeds', () async { + final tempDir = Directory.systemTemp.createTempSync(); + Directory(p.join(tempDir.path, '.shorebird', 'cache')) + .createSync(recursive: true); + Directory(p.join(tempDir.path, '.shorebird', 'engine')) + .createSync(recursive: true); + when(() => auth.currentSession).thenReturn(session); + when( + () => codePushApiClient.downloadEngine(any()), + ).thenAnswer((_) async => Uint8List(0)); + + final progress = _MockProgress(); + when(() => logger.progress(any())).thenReturn(progress); + + const output = 'some output'; + when( + () => process.stdout, + ).thenAnswer((_) => Stream.value(utf8.encode(output))); + when(() => process.stderr).thenAnswer((_) => const Stream.empty()); + when( + () => process.exitCode, + ).thenAnswer((_) async => ExitCode.success.code); + + final result = await IOOverrides.runZoned( + () => runCommand.run(), + getCurrentDirectory: () => tempDir, + ); + + await expectLater(result, equals(ExitCode.success.code)); + verify(() => logger.info(output)).called(1); + }); + }); +} diff --git a/packages/shorebird_cli/test/src/compute_test.dart b/packages/shorebird_cli/test/src/compute_test.dart new file mode 100644 index 00000000..458e555f --- /dev/null +++ b/packages/shorebird_cli/test/src/compute_test.dart @@ -0,0 +1,17 @@ +import 'package:shorebird_cli/src/compute.dart'; +import 'package:test/test.dart'; + +int test1(int value) => value + 1; +int test2(int value) => throw Exception(); +Future test1Async(int value) async => value + 1; +Future test2Async(int value) async => throw Exception(); + +void main() { + test('compute()', () async { + expect(await compute(test1, 0), 1); + expect(compute(test2, 0), throwsException); + + expect(await compute(test1Async, 0), 1); + expect(compute(test2Async, 0), throwsException); + }); +}