feat(shorebird_cli): refactor publish command flow (#159)

This commit is contained in:
Felix Angelov
2023-03-24 11:58:28 -05:00
committed by Felix Angelov
parent 29e1793f86
commit d96c75adbd
8 changed files with 475 additions and 172 deletions
@@ -43,11 +43,4 @@ abstract class ShorebirdCommand extends Command<int> {
/// [ArgResults] for the current command.
ArgResults get results => testArgResults ?? argResults!;
/// [CommandRunner] used for testing purposes only.
@visibleForTesting
CommandRunner<int>? testCommandRunner;
@override
CommandRunner<int>? get runner => testCommandRunner ?? super.runner;
}
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_config_mixin.dart';
import 'package:shorebird_cli/src/shorebird_engine_mixin.dart';
@@ -17,7 +18,7 @@ typedef RunProcess = Future<ProcessResult> Function(
/// Build a new release of your application.
/// {@endtemplate}
class BuildCommand extends ShorebirdCommand
with ShorebirdConfigMixin, ShorebirdEngineMixin {
with ShorebirdConfigMixin, ShorebirdEngineMixin, ShorebirdBuildMixin {
/// {@macro build_command}
BuildCommand({
required super.logger,
@@ -50,7 +51,7 @@ class BuildCommand extends ShorebirdCommand
final buildProgress = logger.progress('Building release ');
try {
await _build(shorebirdEnginePath);
await buildRelease();
buildProgress.complete();
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
@@ -59,37 +60,4 @@ class BuildCommand extends ShorebirdCommand
return ExitCode.success.code;
}
Future<void> _build(String shorebirdEnginePath) async {
const executable = 'flutter';
final arguments = [
'build',
// This is temporary because the Shorebird engine currently
// only supports Android.
'appbundle',
'--release',
'--local-engine-src-path',
shorebirdEnginePath,
'--local-engine',
// This is temporary because the Shorebird engine currently
// only supports Android arm64.
'android_release_arm64',
...results.rest,
];
final result = await runProcess(
executable,
arguments,
runInShell: true,
);
if (result.exitCode != ExitCode.success.code) {
throw ProcessException(
'flutter',
arguments,
result.stderr.toString(),
result.exitCode,
);
}
}
}
@@ -1,21 +1,27 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_config_mixin.dart';
import 'package:shorebird_cli/src/shorebird_engine_mixin.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// {@template publish_command}
///
/// `shorebird publish <path/to/artifact>`
/// Publish new releases to the Shorebird CodePush server.
/// {@endtemplate}
class PublishCommand extends ShorebirdCommand with ShorebirdConfigMixin {
class PublishCommand extends ShorebirdCommand
with ShorebirdConfigMixin, ShorebirdEngineMixin, ShorebirdBuildMixin {
/// {@macro publish_command}
PublishCommand({
required super.logger,
super.auth,
super.buildCodePushClient,
super.runProcess,
});
@override
@@ -39,55 +45,123 @@ class PublishCommand extends ShorebirdCommand with ShorebirdConfigMixin {
return ExitCode.noUser.code;
}
final args = results.rest;
if (args.length > 1) {
usageException('A single file path must be specified.');
}
final artifactPath = args.isEmpty
? p.join(
Directory.current.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
)
: args.first;
final artifact = File(artifactPath);
if (!artifact.existsSync()) {
logger.err('Artifact not found: "${artifact.path}"');
return ExitCode.noInput.code;
}
try {
final pubspecYaml = getPubspecYaml()!;
final shorebirdYaml = getShorebirdYaml()!;
final codePushClient = buildCodePushClient(
apiKey: session.apiKey,
hostedUri: hostedUri,
);
logger.detail(
'''Deploying ${artifact.path} to ${shorebirdYaml.appId} (${pubspecYaml.version})''',
);
final version = pubspecYaml.version!;
await codePushClient.createPatch(
artifactPath: artifact.path,
releaseVersion: '${version.major}.${version.minor}.${version.patch}',
appId: shorebirdYaml.appId,
channel: 'stable',
);
await ensureEngineExists();
} catch (error) {
logger.err('$error');
logger.err(error.toString());
return ExitCode.software.code;
}
logger.success('Successfully deployed.');
final buildProgress = logger.progress('Building release');
try {
await buildRelease();
buildProgress.complete();
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
}
final artifactPath = p.join(
Directory.current.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
);
final artifact = File(artifactPath);
if (!artifact.existsSync()) {
logger.err('Artifact not found: "${artifact.path}"');
return ExitCode.software.code;
}
final pubspecYaml = getPubspecYaml()!;
final shorebirdYaml = getShorebirdYaml()!;
final codePushClient = buildCodePushClient(
apiKey: session.apiKey,
hostedUri: hostedUri,
);
final version = pubspecYaml.version!;
final versionString = '${version.major}.${version.minor}.${version.patch}';
logger.info(
'''
Ready to publish the following patch:
App: ${pubspecYaml.name} (${shorebirdYaml.appId})
Release Version: $versionString
Patch Number: [NEW]
''',
);
final confirm = logger.confirm('Are you sure you want to continue?');
if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
late final List<Release> releases;
final fetchReleasesProgress = logger.progress('Fetching releases');
try {
releases = await codePushClient.getReleases(
appId: shorebirdYaml.appId,
);
fetchReleasesProgress.complete();
} catch (error) {
fetchReleasesProgress.fail('$error');
return ExitCode.software.code;
}
var release = releases.firstWhereOrNull(
(r) => r.version == versionString,
);
if (release == null) {
final createReleaseProgress = logger.progress('Creating release');
try {
release = await codePushClient.createRelease(
appId: shorebirdYaml.appId,
version: versionString,
);
createReleaseProgress.complete();
} catch (error) {
createReleaseProgress.fail('$error');
return ExitCode.software.code;
}
}
late final Patch patch;
final createPatchProgress = logger.progress('Creating patch');
try {
patch = await codePushClient.createPatch(releaseId: release.id);
createPatchProgress.complete();
} catch (error) {
createPatchProgress.fail('$error');
return ExitCode.software.code;
}
final createArtifactProgress = logger.progress('Creating artifact');
try {
await codePushClient.createArtifact(
patchId: patch.id,
artifactPath: artifact.path,
arch: 'aarch64',
platform: 'android',
hash: '#',
);
createArtifactProgress.complete();
} catch (error) {
createArtifactProgress.fail('$error');
return ExitCode.software.code;
}
logger.success('Published!');
return ExitCode.success.code;
}
}
@@ -0,0 +1,39 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/shorebird_engine_mixin.dart';
mixin ShorebirdBuildMixin on ShorebirdEngineMixin {
Future<void> buildRelease() async {
const executable = 'flutter';
final arguments = [
'build',
// This is temporary because the Shorebird engine currently
// only supports Android.
'appbundle',
'--release',
'--local-engine-src-path',
shorebirdEnginePath,
'--local-engine',
// This is temporary because the Shorebird engine currently
// only supports Android arm64.
'android_release_arm64',
...results.rest,
];
final result = await runProcess(
executable,
arguments,
runInShell: true,
);
if (result.exitCode != ExitCode.success.code) {
throw ProcessException(
'flutter',
arguments,
result.stderr.toString(),
result.exitCode,
);
}
}
}
@@ -67,7 +67,7 @@ mixin ShorebirdEngineMixin on ShorebirdConfigMixin {
String path,
) async {
final engine = await codePushClient.downloadEngine(
requiredFlutterEngineRevision,
revision: requiredFlutterEngineRevision,
);
final targetFile = File(path);
@@ -52,7 +52,7 @@ void main() {
when(() => argResults.rest).thenReturn([]);
when(
() => codePushClient.downloadEngine(any()),
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenAnswer((_) async => Uint8List.fromList([]));
when(() => logger.progress(any())).thenReturn(_MockProgress());
});
@@ -71,7 +71,7 @@ void main() {
test('exits with code 70 when pulling engine fails', () async {
when(
() => codePushClient.downloadEngine(any()),
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenThrow(Exception('oops'));
when(() => auth.currentSession).thenReturn(session);
@@ -103,7 +103,7 @@ void main() {
'${tempDir.path}/.shorebird/engine',
).createSync(recursive: true);
when(
() => codePushClient.downloadEngine(any()),
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenAnswer((_) async => Uint8List.fromList([]));
when(() => auth.currentSession).thenReturn(session);
@@ -1,7 +1,7 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
@@ -19,18 +19,30 @@ class _MockLogger extends Mock implements Logger {}
class _MockProgress extends Mock implements Progress {}
class _MockCodePushClient extends Mock implements CodePushClient {}
class _MockProcessResult extends Mock implements ProcessResult {}
class _FakeCommandRunner extends Fake implements CommandRunner<int> {
@override
String get executableName => 'shorebird_test';
}
class _MockCodePushClient extends Mock implements CodePushClient {}
void main() {
group('publish', () {
const session = Session(apiKey: 'test-api-key');
const appId = 'test-app-id';
const version = '1.2.3';
const artifact = Artifact(
id: 0,
patchId: 0,
arch: 'aarch64',
platform: 'android',
hash: '#',
url: 'https://example.com',
);
const release = Release(
id: 0,
appId: appId,
version: version,
displayName: '1.2.3',
);
const patch = Patch(id: 0, number: 1);
const pubspecYamlContent = '''
name: example
version: $version
@@ -43,7 +55,9 @@ flutter:
late ArgResults argResults;
late Auth auth;
late Progress progress;
late Logger logger;
late ProcessResult processResult;
late CodePushClient codePushClient;
late PublishCommand command;
late Uri? capturedHostedUri;
@@ -62,7 +76,9 @@ flutter:
setUp(() {
argResults = _MockArgResults();
auth = _MockAuth();
progress = _MockProgress();
logger = _MockLogger();
processResult = _MockProcessResult();
codePushClient = _MockCodePushClient();
command = PublishCommand(
auth: auth,
@@ -70,22 +86,41 @@ flutter:
capturedHostedUri = hostedUri;
return codePushClient;
},
runProcess: (executable, arguments, {bool runInShell = false}) async {
return processResult;
},
logger: logger,
)
..testArgResults = argResults
..testCommandRunner = _FakeCommandRunner();
)..testArgResults = argResults;
when(() => argResults.rest).thenReturn([]);
when(() => auth.currentSession).thenReturn(session);
when(() => logger.progress(any())).thenReturn(_MockProgress());
when(() => logger.progress(any())).thenReturn(progress);
when(() => logger.confirm(any())).thenReturn(true);
when(() => processResult.exitCode).thenReturn(ExitCode.success.code);
when(
() => codePushClient.createPatch(
releaseVersion: any(named: 'releaseVersion'),
artifactPath: any(named: 'artifactPath'),
channel: any(named: 'channel'),
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenAnswer((_) async => Uint8List.fromList([]));
when(
() => codePushClient.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => [release]);
when(
() => codePushClient.createRelease(
appId: any(named: 'appId'),
version: any(named: 'version'),
),
).thenAnswer((_) async {});
).thenAnswer((_) async => release);
when(
() => codePushClient.createPatch(releaseId: any(named: 'releaseId')),
).thenAnswer((_) async => patch);
when(
() => codePushClient.createArtifact(
artifactPath: any(named: 'artifactPath'),
patchId: any(named: 'patchId'),
arch: any(named: 'arch'),
platform: any(named: 'platform'),
hash: any(named: 'hash'),
),
).thenAnswer((_) async => artifact);
});
test('throws config error when shorebird is not initialized', () async {
@@ -112,21 +147,48 @@ flutter:
expect(exitCode, equals(ExitCode.noUser.code));
});
test('throws usage error when multiple args are passed.', () async {
when(() => argResults.rest).thenReturn(['arg1', 'arg2']);
test('exits with code 70 when pulling engine fails', () async {
when(
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenThrow(Exception('oops'));
when(() => auth.currentSession).thenReturn(session);
final tempDir = setUpTempDir();
await expectLater(
IOOverrides.runZoned(
() => command.run(),
getCurrentDirectory: () => tempDir,
),
throwsA(isA<UsageException>()),
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
});
test('throws no input error when artifact is not found (default).',
test('exits with code 70 when building fails', () async {
when(() => processResult.exitCode).thenReturn(1);
when(() => processResult.stderr).thenReturn('oops');
when(() => auth.currentSession).thenReturn(session);
final tempDir = setUpTempDir();
Directory(
'${tempDir.path}/.shorebird/engine',
).createSync(recursive: true);
Directory(
'${tempDir.path}/.shorebird/cache',
).createSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
() async => command.run(),
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.software.code));
});
test('throws software error when artifact is not found (default).',
() async {
final tempDir = setUpTempDir();
Directory(
'${tempDir.path}/.shorebird/engine',
).createSync(recursive: true);
Directory(
'${tempDir.path}/.shorebird/cache',
).createSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
@@ -134,66 +196,216 @@ flutter:
verify(
() => logger.err(any(that: contains('Artifact not found:'))),
).called(1);
expect(exitCode, ExitCode.noInput.code);
});
test('throws no input error when artifact is not found (custom).',
() async {
final tempDir = setUpTempDir();
final artifact = File(p.join(tempDir.path, 'patch.txt'));
when(() => argResults.rest).thenReturn([artifact.path]);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(
() => logger.err(
any(
that: contains('Artifact not found: "${artifact.path}"'),
),
),
).called(1);
expect(exitCode, ExitCode.noInput.code);
});
test('throws error when publish fails.', () async {
const error = 'something went wrong';
when(
() => codePushClient.createPatch(
releaseVersion: any(named: 'releaseVersion'),
artifactPath: any(named: 'artifactPath'),
channel: any(named: 'channel'),
appId: any(named: 'appId'),
),
).thenThrow(error);
final tempDir = setUpTempDir();
final artifact = File(p.join(tempDir.path, 'patch.txt'))..createSync();
when(() => argResults.rest).thenReturn([artifact.path]);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => logger.err(error)).called(1);
expect(exitCode, ExitCode.software.code);
});
test('succeeds when publish is successful using existing app id', () async {
test('aborts when user opts out', () async {
when(() => logger.confirm(any())).thenReturn(false);
final tempDir = setUpTempDir();
final artifact = File(p.join(tempDir.path, 'patch.txt'))..createSync();
when(() => argResults.rest).thenReturn([artifact.path]);
Directory(
'${tempDir.path}/.shorebird/engine',
).createSync(recursive: true);
Directory(
'${tempDir.path}/.shorebird/cache',
).createSync(recursive: true);
final artifactPath = p.join(
tempDir.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => logger.success('Successfully deployed.')).called(1);
verify(
() => codePushClient.createPatch(
releaseVersion: version,
appId: appId,
artifactPath: artifact.path,
channel: 'stable',
expect(exitCode, ExitCode.success.code);
verify(() => logger.info('Aborting.')).called(1);
});
test('throws error when fetching releases fails.', () async {
const error = 'something went wrong';
when(
() => codePushClient.getReleases(appId: any(named: 'appId')),
).thenThrow(error);
final tempDir = setUpTempDir();
Directory(
'${tempDir.path}/.shorebird/engine',
).createSync(recursive: true);
Directory(
'${tempDir.path}/.shorebird/cache',
).createSync(recursive: true);
final artifactPath = p.join(
tempDir.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => progress.fail(error)).called(1);
expect(exitCode, ExitCode.software.code);
});
test('throws error when creating release fails.', () async {
const error = 'something went wrong';
when(
() => codePushClient.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => []);
when(
() => codePushClient.createRelease(
appId: any(named: 'appId'),
version: any(named: 'version'),
displayName: any(named: 'displayName'),
),
).called(1);
).thenThrow(error);
final tempDir = setUpTempDir();
Directory(
'${tempDir.path}/.shorebird/engine',
).createSync(recursive: true);
Directory(
'${tempDir.path}/.shorebird/cache',
).createSync(recursive: true);
final artifactPath = p.join(
tempDir.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => progress.fail(error)).called(1);
expect(exitCode, ExitCode.software.code);
});
test('throws error when creating patch fails.', () async {
const error = 'something went wrong';
when(
() => codePushClient.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => []);
when(
() => codePushClient.createPatch(releaseId: any(named: 'releaseId')),
).thenThrow(error);
final tempDir = setUpTempDir();
Directory(
'${tempDir.path}/.shorebird/engine',
).createSync(recursive: true);
Directory(
'${tempDir.path}/.shorebird/cache',
).createSync(recursive: true);
final artifactPath = p.join(
tempDir.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => progress.fail(error)).called(1);
expect(exitCode, ExitCode.software.code);
});
test('throws error when uploading artifact fails.', () async {
const error = 'something went wrong';
when(
() => codePushClient.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => []);
when(
() => codePushClient.createArtifact(
artifactPath: any(named: 'artifactPath'),
patchId: any(named: 'patchId'),
arch: any(named: 'arch'),
platform: any(named: 'platform'),
hash: any(named: 'hash'),
),
).thenThrow(error);
final tempDir = setUpTempDir();
Directory(
'${tempDir.path}/.shorebird/engine',
).createSync(recursive: true);
Directory(
'${tempDir.path}/.shorebird/cache',
).createSync(recursive: true);
final artifactPath = p.join(
tempDir.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => progress.fail(error)).called(1);
expect(exitCode, ExitCode.software.code);
});
test('succeeds when publish is successful', () async {
final tempDir = setUpTempDir();
Directory(
'${tempDir.path}/.shorebird/engine',
).createSync(recursive: true);
Directory(
'${tempDir.path}/.shorebird/cache',
).createSync(recursive: true);
final artifactPath = p.join(
tempDir.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => logger.success('Published!')).called(1);
expect(exitCode, ExitCode.success.code);
expect(capturedHostedUri, isNull);
});
@@ -208,8 +420,25 @@ flutter:
app_id: $appId
base_url: $baseUrl''',
);
final artifact = File(p.join(tempDir.path, 'patch.txt'))..createSync();
when(() => argResults.rest).thenReturn([artifact.path]);
Directory(
'${tempDir.path}/.shorebird/engine',
).createSync(recursive: true);
Directory(
'${tempDir.path}/.shorebird/cache',
).createSync(recursive: true);
final artifactPath = p.join(
tempDir.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
@@ -74,7 +74,7 @@ void main() {
final error = Exception('oops');
when(() => auth.currentSession).thenReturn(session);
when(
() => codePushClient.downloadEngine(any()),
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenThrow(error);
final progress = _MockProgress();
when(() => logger.progress(any())).thenReturn(progress);
@@ -98,7 +98,7 @@ void main() {
when(() => auth.currentSession).thenReturn(session);
when(
() => codePushClient.downloadEngine(any()),
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenAnswer((_) async => Uint8List(0));
final progress = _MockProgress();
when(() => logger.progress(any())).thenReturn(progress);
@@ -129,7 +129,7 @@ void main() {
when(() => auth.currentSession).thenReturn(session);
when(
() => codePushClient.downloadEngine(any()),
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenAnswer((_) async => Uint8List(0));
final progress = _MockProgress();
@@ -163,7 +163,7 @@ void main() {
.createSync(recursive: true);
when(() => auth.currentSession).thenReturn(session);
when(
() => codePushClient.downloadEngine(any()),
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenAnswer((_) async => Uint8List(0));
final progress = _MockProgress();