feat(shorebird_cli): add run command (#29)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user