refactor(shorebird_cli): introduce ArtifactBuilder to move away from ShorebirdBuildMixin (#1998)

This commit is contained in:
Bryan Oltman
2024-05-01 14:26:19 -04:00
committed by GitHub
parent cd497aa642
commit 9be157472a
6 changed files with 475 additions and 93 deletions
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/android_sdk.dart';
import 'package:shorebird_cli/src/android_studio.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/cache.dart';
@@ -35,6 +36,7 @@ Future<void> main(List<String> args) async {
androidSdkRef,
androidStudioRef,
aotToolsRef,
artifactBuilderRef,
artifactManagerRef,
authRef,
bundletoolRef,
@@ -0,0 +1,135 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
/// Used to wrap code that invokes `flutter build` with Shorebird's fork of
/// Flutter.
typedef ShorebirdBuildCommand = Future<void> Function();
/// {@template artifact_build_exception}
/// Thrown when a build fails.
/// {@endtemplate}
class ArtifactBuildException implements Exception {
/// {@macro artifact_build_exception}
ArtifactBuildException(this.message);
/// Information about the build failure.
final String message;
}
/// A reference to a [ArtifactBuilder] instance.
final artifactBuilderRef = create(ArtifactBuilder.new);
/// The [ArtifactBuilder] instance available in the current zone.
ArtifactBuilder get artifactBuilder => read(artifactBuilderRef);
/// @{template artifact_builder}
/// Builds aabs, ipas, and other artifacts produced by `flutter build`.
/// @{endtemplate}
class ArtifactBuilder {
/// Builds an aab using `flutter build appbundle`. Runs `flutter pub get` with
/// the system installation of Flutter to reset
/// `.dart_tool/package_config.json` after the build completes or fails.
Future<File> buildAppBundle({
String? flavor,
String? target,
Iterable<Arch>? targetPlatforms,
List<String> argResultsRest = const [],
}) async {
await _runShorebirdBuildCommand(() async {
const executable = 'flutter';
final targetPlatformArgs = targetPlatforms?.targetPlatformArg;
final arguments = [
'build',
'appbundle',
'--release',
if (flavor != null) '--flavor=$flavor',
if (target != null) '--target=$target',
if (targetPlatformArgs != null) '--target-platform=$targetPlatformArgs',
...argResultsRest,
];
final result = await process.run(
executable,
arguments,
runInShell: true,
);
if (result.exitCode != ExitCode.success.code) {
throw ArtifactBuildException(
'Failed to build: ${result.stderr}',
);
}
});
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
try {
return shorebirdAndroidArtifacts.findAab(
project: projectRoot,
flavor: flavor,
);
} on MultipleArtifactsFoundException catch (error) {
throw ArtifactBuildException(
'Build succeeded, but it generated multiple AABs in the '
'build directory. ${error.foundArtifacts.map((e) => e.path)}',
);
} on ArtifactNotFoundException catch (error) {
throw ArtifactBuildException(
'Build succeeded, but could not find the AAB in the build directory. '
'Expected to find ${error.artifactName}',
);
}
}
/// A wrapper around [command] (which runs a `flutter build` command with
/// Shorebird's fork of Flutter) with a try/finally that runs
/// `flutter pub get` with the system installation of Flutter to reset
/// `.dart_tool/package_config.json` to the system Flutter.
Future<void> _runShorebirdBuildCommand(ShorebirdBuildCommand command) async {
try {
await command();
} finally {
await _systemFlutterPubGet();
}
}
/// This is a hack to reset `.dart_tool/package_config.json` to point to the
/// Flutter SDK on the user's PATH. This is necessary because Flutter commands
/// run by shorebird update the package_config.json file to point to
/// shorebird's version of Flutter, which confuses VS Code. See
/// https://github.com/shorebirdtech/shorebird/issues/1101 for more info.
Future<void> _systemFlutterPubGet() async {
const executable = 'flutter';
if (osInterface.which(executable) == null) {
// If the user doesn't have Flutter on their PATH, then we can't run
// `flutter pub get` with the system Flutter.
return;
}
final arguments = ['--no-version-check', 'pub', 'get', '--offline'];
final result = await process.run(
executable,
arguments,
runInShell: true,
useVendedFlutter: false,
);
if (result.exitCode != ExitCode.success.code) {
logger.warn(
'''
Build was successful, but `flutter pub get` failed to run after the build completed. You may see unexpected behavior in VS Code.
Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.parse('https://docs.shorebird.dev/troubleshooting#i-installed-shorebird-and-now-i-cant-run-my-app-in-vs-code'))}.
''',
);
}
}
}
@@ -1,9 +1,9 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
/// {@template build_app_bundle_command}
@@ -11,7 +11,7 @@ import 'package:shorebird_cli/src/shorebird_validator.dart';
/// `shorebird build appbundle`
/// Build an Android App Bundle file from your app.
/// {@endtemplate}
class BuildAppBundleCommand extends ShorebirdCommand with ShorebirdBuildMixin {
class BuildAppBundleCommand extends ShorebirdCommand {
/// {@macro build_app_bundle_command}
BuildAppBundleCommand() {
argParser
@@ -48,8 +48,8 @@ class BuildAppBundleCommand extends ShorebirdCommand with ShorebirdBuildMixin {
final target = results['target'] as String?;
final buildProgress = logger.progress('Building appbundle');
try {
await buildAppBundle(flavor: flavor, target: target);
} on BuildException catch (error) {
await artifactBuilder.buildAppBundle(flavor: flavor, target: target);
} on ArtifactBuildException catch (error) {
buildProgress.fail(error.message);
return ExitCode.software.code;
}
@@ -0,0 +1,314 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:test/test.dart';
import 'fakes.dart';
import 'mocks.dart';
void main() {
group(ArtifactBuilder, () {
late Logger logger;
late OperatingSystemInterface operatingSystemInterface;
late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts;
late ShorebirdEnv shorebirdEnv;
late ShorebirdProcess shorebirdProcess;
late ShorebirdProcessResult buildProcessResult;
late ShorebirdProcessResult pubGetProcessResult;
late ArtifactBuilder builder;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
loggerRef.overrideWith(() => logger),
osInterfaceRef.overrideWith(() => operatingSystemInterface),
processRef.overrideWith(() => shorebirdProcess),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdAndroidArtifactsRef
.overrideWith(() => shorebirdAndroidArtifacts),
},
);
}
setUpAll(() {
registerFallbackValue(FakeShorebirdProcess());
registerFallbackValue(Directory(''));
});
setUp(() {
buildProcessResult = MockProcessResult();
logger = MockLogger();
operatingSystemInterface = MockOperatingSystemInterface();
pubGetProcessResult = MockProcessResult();
shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts();
shorebirdEnv = MockShorebirdEnv();
shorebirdProcess = MockShorebirdProcess();
when(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).thenAnswer((_) async => pubGetProcessResult);
when(() => pubGetProcessResult.exitCode)
.thenReturn(ExitCode.success.code);
when(
() => shorebirdProcess.run(
any(),
any(),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((_) async => buildProcessResult);
when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code);
when(() => logger.progress(any())).thenReturn(MockProgress());
when(() => logger.info(any())).thenReturn(null);
when(() => operatingSystemInterface.which('flutter'))
.thenReturn('/path/to/flutter');
when(() => shorebirdEnv.flutterRevision).thenReturn('1234');
when(shorebirdEnv.getShorebirdProjectRoot).thenReturn(Directory(''));
when(
() => shorebirdAndroidArtifacts.findAab(
project: any(named: 'project'),
flavor: any(named: 'flavor'),
),
).thenReturn(File('app-release.aab'));
builder = ArtifactBuilder();
});
group('buildAppBundle', () {
test('invokes the correct flutter build command', () async {
await runWithOverrides(() => builder.buildAppBundle());
verify(
() => shorebirdProcess.run(
'flutter',
['build', 'appbundle', '--release'],
runInShell: any(named: 'runInShell'),
environment: any(named: 'environment'),
),
).called(1);
});
test('forward arguments to flutter build', () async {
await runWithOverrides(
() => builder.buildAppBundle(
flavor: 'flavor',
target: 'target',
targetPlatforms: [Arch.arm64],
argResultsRest: ['--foo', 'bar'],
),
);
verify(
() => shorebirdProcess.run(
'flutter',
[
'build',
'appbundle',
'--release',
'--flavor=flavor',
'--target=target',
'--target-platform=android-arm64',
'--foo',
'bar',
],
runInShell: any(named: 'runInShell'),
),
).called(1);
});
group('when multiple artifacts are found', () {
setUp(() {
when(
() => shorebirdAndroidArtifacts.findAab(
project: any(named: 'project'),
flavor: any(named: 'flavor'),
),
).thenThrow(
MultipleArtifactsFoundException(
foundArtifacts: [File('a'), File('b')],
buildDir: 'buildDir',
),
);
});
test('throws BuildException', () async {
expect(
() async => runWithOverrides(() => builder.buildAppBundle()),
throwsA(
isA<ArtifactBuildException>().having(
(e) => e.message,
'message',
'''Build succeeded, but it generated multiple AABs in the build directory. (a, b)''',
),
),
);
});
});
group('when no artifacts are found', () {
setUp(() {
when(
() => shorebirdAndroidArtifacts.findAab(
project: any(named: 'project'),
flavor: any(named: 'flavor'),
),
).thenThrow(
ArtifactNotFoundException(
artifactName: 'app-release.aab',
buildDir: 'buildDir',
),
);
});
test('throws BuildException', () async {
expect(
() async => runWithOverrides(() => builder.buildAppBundle()),
throwsA(
isA<ArtifactBuildException>().having(
(e) => e.message,
'message',
'''Build succeeded, but could not find the AAB in the build directory. Expected to find app-release.aab''',
),
),
);
});
});
group('after a build', () {
group('when the build is successful', () {
setUp(() {
when(() => buildProcessResult.exitCode)
.thenReturn(ExitCode.success.code);
});
group('when flutter is installed', () {
setUp(() {
when(() => operatingSystemInterface.which('flutter'))
.thenReturn('/path/to/flutter');
});
test('runs flutter pub get with system flutter', () async {
await runWithOverrides(() => builder.buildAppBundle());
verify(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).called(1);
});
});
group('when flutter is not installed', () {
setUp(() {
when(() => operatingSystemInterface.which('flutter'))
.thenReturn(null);
});
test('does not attempt to run flutter pub get', () async {
await runWithOverrides(() => builder.buildAppBundle());
verifyNever(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
);
});
});
});
group('when the build fails', () {
setUp(() {
when(() => buildProcessResult.exitCode)
.thenReturn(ExitCode.software.code);
});
group('when flutter is installed', () {
setUp(() {
when(() => operatingSystemInterface.which('flutter'))
.thenReturn('/path/to/flutter');
});
test('runs flutter pub get with system flutter', () async {
await expectLater(
() async => runWithOverrides(() => builder.buildAppBundle()),
throwsA(isA<ArtifactBuildException>()),
);
verify(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).called(1);
});
test('prints error message if system flutter pub get fails',
() async {
when(() => pubGetProcessResult.exitCode).thenReturn(1);
await expectLater(
() async => runWithOverrides(() => builder.buildAppBundle()),
throwsA(isA<ArtifactBuildException>()),
);
verify(
() => logger.warn(
'''
Build was successful, but `flutter pub get` failed to run after the build completed. You may see unexpected behavior in VS Code.
Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.parse('https://docs.shorebird.dev/troubleshooting#i-installed-shorebird-and-now-i-cant-run-my-app-in-vs-code'))}.
''',
),
).called(1);
});
});
group('when flutter is not installed', () {
setUp(() {
when(() => operatingSystemInterface.which('flutter'))
.thenReturn(null);
});
test('does not attempt to run flutter pub get', () async {
await expectLater(
() async => runWithOverrides(() => builder.buildAppBundle()),
throwsA(isA<ArtifactBuildException>()),
);
verifyNever(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
);
});
});
});
});
});
});
}
@@ -5,15 +5,12 @@ 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/artifact_builder.dart';
import 'package:shorebird_cli/src/commands/build/build.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:test/test.dart';
@@ -24,31 +21,22 @@ import '../../mocks.dart';
void main() {
group(BuildAppBundleCommand, () {
late ArgResults argResults;
late ArtifactBuilder artifactBuilder;
late Doctor doctor;
late Logger logger;
late OperatingSystemInterface operatingSystemInterface;
late ShorebirdProcessResult flutterPubGetProcessResult;
late ShorebirdProcessResult buildProcessResult;
late BuildAppBundleCommand command;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutterValidator flutterValidator;
late ShorebirdProcess shorebirdProcess;
late ShorebirdValidator shorebirdValidator;
late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
artifactBuilderRef.overrideWith(() => artifactBuilder),
doctorRef.overrideWith(() => doctor),
engineConfigRef.overrideWith(() => const EngineConfig.empty()),
loggerRef.overrideWith(() => logger),
osInterfaceRef.overrideWith(() => operatingSystemInterface),
processRef.overrideWith(() => shorebirdProcess),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
shorebirdAndroidArtifactsRef
.overrideWith(() => shorebirdAndroidArtifacts),
},
);
}
@@ -60,44 +48,18 @@ void main() {
setUp(() {
argResults = MockArgResults();
artifactBuilder = MockArtifactBuilder();
doctor = MockDoctor();
logger = MockLogger();
operatingSystemInterface = MockOperatingSystemInterface();
buildProcessResult = MockProcessResult();
flutterPubGetProcessResult = MockProcessResult();
flutterValidator = MockShorebirdFlutterValidator();
shorebirdEnv = MockShorebirdEnv();
shorebirdProcess = MockShorebirdProcess();
shorebirdValidator = MockShorebirdValidator();
shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts();
when(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).thenAnswer((_) async => flutterPubGetProcessResult);
when(() => flutterPubGetProcessResult.exitCode)
.thenReturn(ExitCode.success.code);
when(
() => shorebirdProcess.run(
any(),
any(),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((_) async => buildProcessResult);
when(() => argResults.rest).thenReturn([]);
when(() => logger.progress(any())).thenReturn(MockProgress());
when(() => logger.info(any())).thenReturn(null);
when(() => operatingSystemInterface.which('flutter'))
.thenReturn('/path/to/flutter');
when(
() => doctor.androidCommandValidators,
).thenReturn([flutterValidator]);
when(() => shorebirdEnv.flutterRevision).thenReturn('1234');
when(shorebirdEnv.getShorebirdProjectRoot).thenReturn(Directory(''));
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
@@ -106,11 +68,13 @@ void main() {
),
).thenAnswer((_) async {});
when(
() => shorebirdAndroidArtifacts.findAab(
project: any(named: 'project'),
() => artifactBuilder.buildAppBundle(
flavor: any(named: 'flavor'),
target: any(named: 'target'),
),
).thenReturn(File('app-release.aab'));
).thenAnswer(
(_) async => File(''),
);
command = runWithOverrides(BuildAppBundleCommand.new)
..testArgResults = argResults;
@@ -143,33 +107,21 @@ void main() {
});
test('exits with code 70 when building appbundle fails', () async {
when(() => buildProcessResult.exitCode).thenReturn(1);
when(() => buildProcessResult.stderr).thenReturn('oops');
when(() => artifactBuilder.buildAppBundle()).thenThrow(
ArtifactBuildException('Failed to build: oops'),
);
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.software.code));
verify(
() => shorebirdProcess.run(
'flutter',
['build', 'appbundle', '--release'],
runInShell: any(named: 'runInShell'),
),
).called(1);
verify(() => artifactBuilder.buildAppBundle()).called(1);
});
test('exits with code 0 when building appbundle succeeds', () async {
when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code);
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => shorebirdProcess.run(
'flutter',
['build', 'appbundle', '--release'],
runInShell: true,
),
).called(1);
verify(() => artifactBuilder.buildAppBundle()).called(1);
verify(
() => logger.info(
@@ -187,21 +139,13 @@ ${lightCyan.wrap(p.join('build', 'app', 'outputs', 'bundle', 'release', 'app-rel
final target = p.join('lib', 'main_development.dart');
when(() => argResults['flavor']).thenReturn(flavor);
when(() => argResults['target']).thenReturn(target);
when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code);
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => shorebirdProcess.run(
'flutter',
[
'build',
'appbundle',
'--release',
'--flavor=$flavor',
'--target=$target',
],
runInShell: true,
() => artifactBuilder.buildAppBundle(
flavor: flavor,
target: target,
),
).called(1);
@@ -253,21 +197,5 @@ ${lightCyan.wrap(p.join('build', 'app', 'outputs', 'bundle', '${flavor}Release',
throwsException,
);
});
test('runs flutter pub get with system flutter after successful build',
() async {
when(() => buildProcessResult.exitCode).thenReturn(ExitCode.success.code);
await runWithOverrides(command.run);
verify(
() => shorebirdProcess.run(
'flutter',
['--no-version-check', 'pub', 'get', '--offline'],
runInShell: any(named: 'runInShell'),
useVendedFlutter: false,
),
).called(1);
});
});
}
@@ -11,6 +11,7 @@ import 'package:shorebird_cli/src/android_sdk.dart';
import 'package:shorebird_cli/src/android_studio.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/cache.dart' show Cache;
@@ -54,6 +55,8 @@ class MockArchiveDiffer extends Mock implements ArchiveDiffer {}
class MockArgResults extends Mock implements ArgResults {}
class MockArtifactBuilder extends Mock implements ArtifactBuilder {}
class MockArtifactManager extends Mock implements ArtifactManager {}
class MockAuth extends Mock implements Auth {}