feat(shorebird_cli): add shorebird preview command (#845)

This commit is contained in:
Felix Angelov
2023-07-13 12:28:05 -05:00
committed by GitHub
parent 5f939759b0
commit c853d3c9e1
6 changed files with 608 additions and 0 deletions
@@ -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<void> main(List<String> args) async {
await runScoped(
() async => ShorebirdCliCommandRunner().run(args),
values: {
adbRef,
androidSdkRef,
androidStudioRef,
authRef,
+7
View File
@@ -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<ShorebirdProcessResult> _exec(String command) async {
@@ -65,6 +65,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
addCommand(LoginCiCommand());
addCommand(LogoutCommand());
addCommand(PatchCommand());
addCommand(PreviewCommand());
addCommand(ReleaseCommand());
addCommand(ReleasesCommand());
addCommand(RunCommand());
@@ -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';
@@ -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<int> 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<String?> 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<String?> 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<void> 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());
}
}
@@ -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>(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<List<int>>());
});
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<int>();
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<int>();
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<AppMetadata>(
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<AppMetadata>(
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<AppMetadata>(
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<AppMetadata>(
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<Release>(
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<Release>(
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);
});
});
}