feat(shorebird_cli): add run command (#29)

This commit is contained in:
Felix Angelov
2023-03-06 17:46:21 -06:00
committed by GitHub
parent c13091b721
commit 2fc2af6428
6 changed files with 417 additions and 0 deletions
@@ -60,6 +60,13 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
logger: _logger,
),
);
addCommand(
RunCommand(
auth: authentication,
codePushApiClientBuilder: buildCodePushApiClient,
logger: _logger,
),
);
addCommand(UpdateCommand(logger: _logger, pubUpdater: _pubUpdater));
}
@@ -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';
@@ -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<Process> Function(
String executable,
List<String> arguments, {
bool runInShell,
});
/// {@template run_command}
///
/// `shorebird run`
/// Run the Flutter application.
/// {@endtemplate}
class RunCommand extends Command<int> {
/// {@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<int> 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<void> _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<void> _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,
);
}
@@ -0,0 +1,41 @@
import 'dart:async';
import 'dart:isolate';
/// Perform [computation] with [input] in an [Isolate].
Future<R> compute<R, M>(FutureOr<R> Function(M) computation, M input) async {
final resultPort = ReceivePort();
final errorPort = ReceivePort();
await Isolate.spawn<_IsolateConfig<M, FutureOr<R>>>(
_spawn,
_IsolateConfig<M, FutureOr<R>>(computation, input, resultPort.sendPort),
onError: errorPort.sendPort,
);
final result = Completer<R>();
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<M, R> {
const _IsolateConfig(this.callback, this.message, this.resultPort);
final R Function(M message) callback;
final M message;
final SendPort resultPort;
FutureOr<R> compute() => callback(message);
}
Future<void> _spawn<R, M>(_IsolateConfig<R, FutureOr<M>> configuration) async {
Isolate.exit(configuration.resultPort, await configuration.compute());
}
@@ -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);
});
});
}
@@ -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<int> test1Async(int value) async => value + 1;
Future<int> 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);
});
}