refactor(shorebird_cli): introduce new patch command (#2030)

This commit is contained in:
Bryan Oltman
2024-05-07 20:22:23 -04:00
committed by GitHub
parent 0b3f4ade50
commit 320ab4b071
9 changed files with 1567 additions and 1 deletions
@@ -6,6 +6,7 @@ import 'package:cli_completion/cli_completion.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/release_new/release_new.dart';
import 'package:shorebird_cli/src/engine_config.dart';
import 'package:shorebird_cli/src/logger.dart';
@@ -74,6 +75,7 @@ class ShorebirdCliCommandRunner extends CompletionCommandRunner<int> {
addCommand(LoginCiCommand());
addCommand(LogoutCommand());
addCommand(PatchCommand());
addCommand(PatchNewCommand());
addCommand(PreviewCommand());
addCommand(ReleaseCommand());
addCommand(ReleaseNewCommand());
@@ -0,0 +1,165 @@
import 'package:crypto/crypto.dart';
import 'package:io/io.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/archive_analysis/android_archive_differ.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/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/patch_new/patcher.dart';
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
/// {@template android_patcher}
/// Functions to create an Android patch.
/// {@endtemplate}
class AndroidPatcher extends Patcher {
/// {@macro android_patcher}
AndroidPatcher({required super.flavor, required super.target});
@override
ReleaseType get releaseType => ReleaseType.android;
@override
String get primaryReleaseArtifactArch => 'aab';
@override
ArchiveDiffer get archiveDiffer => AndroidArchiveDiffer();
@override
Future<void> assertPreconditions() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
validators: doctor.androidCommandValidators,
);
} on PreconditionFailedException catch (e) {
exit(e.exitCode.code);
}
}
@override
Future<File> buildPatchArtifact() async {
final File aabFile;
final flutterVersionString = await shorebirdFlutter.getVersionAndRevision();
final buildProgress =
logger.progress('Building patch with Flutter $flutterVersionString');
try {
aabFile =
await artifactBuilder.buildAppBundle(flavor: flavor, target: target);
buildProgress.complete();
} on ArtifactBuildException catch (error) {
buildProgress.fail(error.message);
exit(ExitCode.software.code);
}
final patchArchsBuildDir = ArtifactManager.androidArchsDirectory(
projectRoot: projectRoot,
flavor: flavor,
);
if (patchArchsBuildDir == null) {
logger
..err('Cannot find patch build artifacts.')
..info(
'''
Please run `shorebird cache clean` and try again. If the issue persists, please
file a bug report at https://github.com/shorebirdtech/shorebird/issues/new.
Looked in:
- build/app/intermediates/stripped_native_libs/stripReleaseDebugSymbols/release/out/lib
- build/app/intermediates/stripped_native_libs/strip{flavor}ReleaseDebugSymbols/{flavor}Release/out/lib
- build/app/intermediates/stripped_native_libs/release/out/lib
- build/app/intermediates/stripped_native_libs/{flavor}Release/out/lib''',
);
exit(ExitCode.software.code);
}
return aabFile;
}
@override
Future<Map<Arch, PatchArtifactBundle>> createPatchArtifacts({
required String appId,
required int releaseId,
}) async {
final releaseArtifacts = await codePushClientWrapper.getReleaseArtifacts(
appId: appId,
releaseId: releaseId,
architectures: AndroidArch.availableAndroidArchs,
platform: releaseType.releasePlatform,
);
final releaseArtifactPaths = <Arch, String>{};
final downloadReleaseArtifactProgress = logger.progress(
'Downloading release artifacts',
);
for (final releaseArtifact in releaseArtifacts.entries) {
try {
final releaseArtifactFile = await artifactManager.downloadFile(
Uri.parse(releaseArtifact.value.url),
);
releaseArtifactPaths[releaseArtifact.key] = releaseArtifactFile.path;
} catch (error) {
downloadReleaseArtifactProgress.fail('$error');
exit(ExitCode.software.code);
}
}
downloadReleaseArtifactProgress.complete();
final patchArchsBuildDir = ArtifactManager.androidArchsDirectory(
projectRoot: projectRoot,
flavor: flavor,
);
if (patchArchsBuildDir == null) {
logger.err('Could not find patch artifacts');
exit(ExitCode.software.code);
}
final patchArtifactBundles = <Arch, PatchArtifactBundle>{};
final createDiffProgress = logger.progress('Creating patch artifacts');
for (final releaseArtifactPath in releaseArtifactPaths.entries) {
final arch = releaseArtifactPath.key;
final patchArtifactPath = p.join(
patchArchsBuildDir.path,
arch.androidBuildPath,
'libapp.so',
);
logger.detail('Creating artifact for $patchArtifactPath');
final patchArtifact = File(patchArtifactPath);
final hash = sha256.convert(await patchArtifact.readAsBytes()).toString();
try {
final diffPath = await artifactManager.createDiff(
releaseArtifactPath: releaseArtifactPath.value,
patchArtifactPath: patchArtifactPath,
);
patchArtifactBundles[releaseArtifactPath.key] = PatchArtifactBundle(
arch: arch.arch,
path: diffPath,
hash: hash,
size: await File(diffPath).length(),
);
} catch (error) {
createDiffProgress.fail('$error');
exit(ExitCode.software.code);
}
}
createDiffProgress.complete();
return patchArtifactBundles;
}
@override
Future<String> extractReleaseVersionFromArtifact(File artifact) async {
return shorebirdAndroidArtifacts.extractReleaseVersionFromAppBundle(
artifact.path,
);
}
}
@@ -0,0 +1,3 @@
export 'android_patcher.dart';
export 'patch_new_command.dart';
export 'patcher.dart';
@@ -0,0 +1,330 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/patch_new/android_patcher.dart';
import 'package:shorebird_cli/src/commands/patch_new/patcher.dart';
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/extensions/arg_results.dart';
import 'package:shorebird_cli/src/formatters/formatters.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
typedef ResolvePatcher = Patcher Function(ReleaseType releaseType);
class PatchNewCommand extends ShorebirdCommand {
PatchNewCommand({
ResolvePatcher? resolvePatcher,
}) {
_resolvePatcher = resolvePatcher ?? getPatcher;
argParser
..addMultiOption(
'platform',
abbr: 'p',
help: 'The platform(s) to to build this release for.',
allowed: ReleaseType.values.map((e) => e.cliName).toList(),
// TODO(bryanoltman): uncomment this once https://github.com/dart-lang/args/pull/273 lands
// mandatory: true.
)
..addOption(
'target',
abbr: 't',
help: 'The main entrypoint file of the application.',
)
..addOption(
'flavor',
help: 'The product flavor to use when building the app.',
)
..addOption(
'release-version',
help: '''
The version of the associated release (e.g. "1.0.0"). This should be the version
of the iOS app that is using this module.''',
)
..addFlag(
'allow-native-diffs',
help: allowNativeDiffsHelpText,
negatable: false,
)
..addFlag(
'allow-asset-diffs',
help: allowAssetDiffsHelpText,
negatable: false,
)
..addFlag(
'staging',
negatable: false,
help: 'Whether to publish the patch to the staging environment.',
);
}
static final allowNativeDiffsHelpText = '''
Patch even if native code diffs are detected.
NOTE: this is ${styleBold.wrap('not')} recommended. Native code changes cannot be included in a patch and attempting to do so can cause your app to crash or behave unexpectedly.''';
static final allowAssetDiffsHelpText = '''
Patch even if asset diffs are detected.
NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be included in a patch can cause your app to behave unexpectedly.''';
late final ResolvePatcher _resolvePatcher;
@override
bool get hidden => true;
@override
String get description =>
'Creates a shorebird patch for the provided target platforms';
@override
String get name => 'patch-new';
/// The shorebird app ID for the current project.
String get appId => shorebirdEnv.getShorebirdYaml()!.getAppId(flavor: flavor);
/// The build flavor, if provided.
late String? flavor = results.findOption('flavor', argParser: argParser);
/// The target script, if provided.
late String? target = results.findOption('target', argParser: argParser);
bool get isStaging => results['staging'] == true;
@override
Future<int> run() async {
final patcherFutures = (results['platform'] as List<String>)
.map(
(platformArg) => ReleaseType.values.firstWhere(
(target) => target.cliName == platformArg,
),
)
.map(_resolvePatcher)
.map(createPatch);
for (final patcherFuture in patcherFutures) {
await patcherFuture;
}
return ExitCode.success.code;
}
@visibleForTesting
Patcher getPatcher(ReleaseType releaseType) {
switch (releaseType) {
case ReleaseType.android:
return AndroidPatcher(flavor: flavor, target: target);
case ReleaseType.ios:
throw UnimplementedError();
case ReleaseType.iosFramework:
throw UnimplementedError();
case ReleaseType.aar:
throw UnimplementedError();
}
}
bool get allowAssetDiffs => results['allow-asset-diffs'] == true;
bool get allowNativeDiffs => results['allow-native-diffs'] == true;
String? lastBuiltFlutterRevision;
@visibleForTesting
Future<void> createPatch(Patcher patcher) async {
await patcher.assertPreconditions();
await patcher.assertArgsAreValid();
final app = await codePushClientWrapper.getApp(appId: appId);
File? patchArtifact;
final String releaseVersion;
if (results.wasParsed('release-version')) {
releaseVersion = results['release-version'] as String;
} else {
patchArtifact = await patcher.buildPatchArtifact();
lastBuiltFlutterRevision = shorebirdEnv.flutterRevision;
releaseVersion = await patcher.extractReleaseVersionFromArtifact(
patchArtifact,
);
}
final release = await getRelease(
releaseVersion: releaseVersion,
patcher: patcher,
);
final releaseArtifact = await downloadPrimaryReleaseArtifact(
release: release,
patcher: patcher,
);
final releaseFlutterShorebirdEnv = shorebirdEnv.copyWith(
flutterRevisionOverride: release.flutterRevision,
);
return await runScoped(
() async {
// Don't built the patch artifact twice with the same Flutter revision.
if (lastBuiltFlutterRevision != release.flutterRevision) {
patchArtifact = await patcher.buildPatchArtifact();
}
final diffStatus = await assertUnpatchableDiffs(
releaseArtifact: releaseArtifact,
patchArtifact: patchArtifact!,
archiveDiffer: patcher.archiveDiffer,
);
final patchArtifactBundles = await patcher.createPatchArtifacts(
appId: appId,
releaseId: release.id,
);
await confirmCreatePatch(
app: app,
releaseVersion: releaseVersion,
patcher: patcher,
patchArtifactBundles: patchArtifactBundles,
);
await codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
metadata: CreatePatchMetadata(
releasePlatform: patcher.releaseType.releasePlatform,
usedIgnoreAssetChangesFlag: allowAssetDiffs,
hasAssetChanges: diffStatus.hasAssetChanges,
usedIgnoreNativeChangesFlag: allowNativeDiffs,
hasNativeChanges: diffStatus.hasNativeChanges,
linkPercentage: null,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: null,
),
),
platform: patcher.releaseType.releasePlatform,
track:
isStaging ? DeploymentTrack.staging : DeploymentTrack.production,
patchArtifactBundles: patchArtifactBundles,
);
},
values: {
shorebirdEnvRef.overrideWith(() => releaseFlutterShorebirdEnv),
},
);
}
Future<DiffStatus> assertUnpatchableDiffs({
required File releaseArtifact,
required File patchArtifact,
required ArchiveDiffer archiveDiffer,
}) async {
try {
return patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: patchArtifact,
releaseArtifact: releaseArtifact,
archiveDiffer: archiveDiffer,
allowAssetChanges: allowAssetDiffs,
allowNativeChanges: allowNativeDiffs,
);
} on UserCancelledException {
exit(ExitCode.success.code);
} on UnpatchableChangeException {
logger.info('Exiting.');
exit(ExitCode.software.code);
}
}
Future<void> confirmCreatePatch({
required AppMetadata app,
required String releaseVersion,
required Patcher patcher,
required Map<Arch, PatchArtifactBundle> patchArtifactBundles,
}) async {
final archMetadata = patchArtifactBundles.keys.map((arch) {
final size = formatBytes(patchArtifactBundles[arch]!.size);
return '${arch.name} ($size)';
});
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.appId})')}''',
if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'''🕹️ Platform: ${lightCyan.wrap(patcher.releaseType.releasePlatform.name)} ${lightCyan.wrap('[${archMetadata.join(', ')}]')}''',
if (isStaging)
'🟠 Track: ${lightCyan.wrap('Staging')}'
else
'🟢 Track: ${lightCyan.wrap('Production')}',
];
logger.info(
'''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to publish a new patch!'))}
${summary.join('\n')}
''',
);
if (shorebirdEnv.canAcceptUserInput) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
exit(ExitCode.success.code);
}
}
}
Future<Release> getRelease({
required String releaseVersion,
required Patcher patcher,
}) async {
final release = await codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
);
final releaseStatus =
release.platformStatuses[patcher.releaseType.releasePlatform];
if (releaseStatus != ReleaseStatus.active) {
logger.err('''
Release ${release.version} is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.''');
exit(ExitCode.software.code);
}
return release;
}
Future<File> downloadPrimaryReleaseArtifact({
required Release release,
required Patcher patcher,
}) async {
final artifact = await codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
arch: patcher.primaryReleaseArtifactArch,
platform: patcher.releaseType.releasePlatform,
);
final downloadProgress =
logger.progress('Downloading ${patcher.primaryReleaseArtifactArch}');
final File artifactFile;
try {
artifactFile =
await artifactManager.downloadFile(Uri.parse(artifact.url));
} catch (e) {
downloadProgress.fail(e.toString());
exit(ExitCode.software.code);
}
downloadProgress.complete();
return artifactFile;
}
}
@@ -0,0 +1,54 @@
import 'dart:io';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
/// {@template patcher}
/// Platform-specific functionality to create a patch.
/// {@endtemplate}
abstract class Patcher {
/// {@macro patcher}
Patcher({required this.flavor, required this.target});
/// The flavor of the release, if any.
final String? flavor;
/// The target script to run, if any.
final String? target;
/// The type of artifact we are creating a release for.
ReleaseType get releaseType;
/// Used to compare release and patch artifacts to determine if a patch can
/// be applied to a release.
ArchiveDiffer get archiveDiffer;
/// The identifier used for the "primary" release artifact, usually a bundle.
/// For example, 'aab' for Android, 'xcarchive' for iOS.
String get primaryReleaseArtifactArch;
/// The root directory of the current project.
Directory get projectRoot => shorebirdEnv.getShorebirdProjectRoot()!;
/// Asserts that the command can be run.
Future<void> assertPreconditions();
/// Asserts that the combination arguments passed to the command are valid.
Future<void> assertArgsAreValid() async {}
/// Builds the release artifacts for the given platform. Returns the "primary"
/// artifact for the platform (e.g. the AAB for Android, the IPA for iOS).
Future<File> buildPatchArtifact();
/// Determines the release version from the provided app artifact.
Future<String> extractReleaseVersionFromArtifact(File artifact);
/// Creates the patch artifacts required to apply a patch to a release.
Future<Map<Arch, PatchArtifactBundle>> createPatchArtifacts({
required String appId,
required int releaseId,
});
}
@@ -0,0 +1,444 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/artifact_manager.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/patch_new/patch_new.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/platform.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_flutter.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:test/test.dart';
import '../../matchers.dart';
import '../../mocks.dart';
void main() {
group(AndroidPatcher, () {
late ArtifactBuilder artifactBuilder;
late ArtifactManager artifactManager;
late CodePushClientWrapper codePushClientWrapper;
late Doctor doctor;
late Platform platform;
late Directory projectRoot;
late Logger logger;
late Progress progress;
late ShorebirdFlutterValidator flutterValidator;
late ShorebirdProcess shorebirdProcess;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late ShorebirdValidator shorebirdValidator;
late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts;
late AndroidPatcher patcher;
void setUpProjectRootArtifacts({String? flavor}) {
for (final archMetadata in Arch.values) {
final artifactPath = p.join(
projectRoot.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
flavor != null ? '${flavor}Release' : 'release',
'out',
'lib',
archMetadata.androidBuildPath,
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
}
}
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
artifactBuilderRef.overrideWith(() => artifactBuilder),
artifactManagerRef.overrideWith(() => artifactManager),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
doctorRef.overrideWith(() => doctor),
engineConfigRef.overrideWith(() => const EngineConfig.empty()),
loggerRef.overrideWith(() => logger),
platformRef.overrideWith(() => platform),
processRef.overrideWith(() => shorebirdProcess),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
shorebirdAndroidArtifactsRef
.overrideWith(() => shorebirdAndroidArtifacts),
},
);
}
setUpAll(() {
registerFallbackValue(Directory(''));
registerFallbackValue(ReleasePlatform.android);
registerFallbackValue(Uri.parse('https://example.com'));
setExitFunctionForTests();
});
tearDownAll(restoreExitFunction);
setUp(() {
artifactBuilder = MockArtifactBuilder();
artifactManager = MockArtifactManager();
codePushClientWrapper = MockCodePushClientWrapper();
doctor = MockDoctor();
platform = MockPlatform();
progress = MockProgress();
projectRoot = Directory.systemTemp.createTempSync();
logger = MockLogger();
flutterValidator = MockShorebirdFlutterValidator();
shorebirdProcess = MockShorebirdProcess();
shorebirdEnv = MockShorebirdEnv();
shorebirdFlutter = MockShorebirdFlutter();
shorebirdValidator = MockShorebirdValidator();
shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts();
when(() => logger.progress(any())).thenReturn(progress);
when(
() => shorebirdEnv.getShorebirdProjectRoot(),
).thenReturn(projectRoot);
patcher = AndroidPatcher(flavor: null, target: null);
});
group('archiveDiffer', () {
test('is an AndroidArchiveDiffer', () {
expect(patcher.archiveDiffer, isA<AndroidArchiveDiffer>());
});
});
group('primaryReleaseArtifactArch', () {
test('is "aab"', () {
expect(patcher.primaryReleaseArtifactArch, equals('aab'));
});
});
group('assertArgsAreValid', () {
test('does nothing', () async {
await expectLater(patcher.assertArgsAreValid(), completes);
});
});
group('assertPreconditions', () {
setUp(() {
when(() => doctor.androidCommandValidators)
.thenReturn([flutterValidator]);
when(flutterValidator.validate).thenAnswer((_) async => []);
});
group('when validation succeeds', () {
setUp(() {
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized:
any(named: 'checkShorebirdInitialized'),
validators: any(named: 'validators'),
supportedOperatingSystems:
any(named: 'supportedOperatingSystems'),
),
).thenAnswer((_) async {});
});
test('returns normally', () async {
await expectLater(
() => runWithOverrides(patcher.assertPreconditions),
returnsNormally,
);
});
});
group('when validation fails', () {
setUp(() {
final exception = ValidationFailedException();
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized:
any(named: 'checkShorebirdInitialized'),
validators: any(named: 'validators'),
),
).thenThrow(exception);
});
test('exits with code 70', () async {
final exception = ValidationFailedException();
when(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'),
checkShorebirdInitialized:
any(named: 'checkShorebirdInitialized'),
validators: any(named: 'validators'),
),
).thenThrow(exception);
await expectLater(
() => runWithOverrides(patcher.assertPreconditions),
exitsWithCode(exception.exitCode),
);
verify(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
validators: [flutterValidator],
),
).called(1);
});
});
});
group('buildPatchArtifact', () {
const flutterVersionAndRevision = '3.10.6 (83305b5088)';
late File aabFile;
setUp(() {
aabFile = File('');
when(
() => shorebirdFlutter.getVersionAndRevision(),
).thenAnswer((_) async => flutterVersionAndRevision);
when(
() => artifactBuilder.buildAppBundle(
flavor: any(named: 'flavor'),
target: any(named: 'target'),
targetPlatforms: any(named: 'targetPlatforms'),
),
).thenAnswer((_) async => aabFile);
});
group('when build fails', () {
final exception = ArtifactBuildException('error');
setUp(() {
when(
() => artifactBuilder.buildAppBundle(
flavor: any(named: 'flavor'),
target: any(named: 'target'),
),
).thenThrow(exception);
when(() => logger.progress(any())).thenReturn(progress);
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(patcher.buildPatchArtifact),
exitsWithCode(ExitCode.software),
);
verify(() => progress.fail('error')).called(1);
});
});
group('when patch artifacts cannot be found', () {
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(patcher.buildPatchArtifact),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err('Cannot find patch build artifacts.'),
).called(1);
verify(
() => logger.info(
'''
Please run `shorebird cache clean` and try again. If the issue persists, please
file a bug report at https://github.com/shorebirdtech/shorebird/issues/new.
Looked in:
- build/app/intermediates/stripped_native_libs/stripReleaseDebugSymbols/release/out/lib
- build/app/intermediates/stripped_native_libs/strip{flavor}ReleaseDebugSymbols/{flavor}Release/out/lib
- build/app/intermediates/stripped_native_libs/release/out/lib
- build/app/intermediates/stripped_native_libs/{flavor}Release/out/lib''',
),
).called(1);
});
});
group('when build succeeds', () {
setUp(setUpProjectRootArtifacts);
test('returns the aab file', () async {
final result = await runWithOverrides(patcher.buildPatchArtifact);
expect(result, equals(aabFile));
});
});
});
group('createPatchArtifacts', () {
const arch = 'aarch64';
const releaseArtifact = ReleaseArtifact(
id: 0,
releaseId: 0,
arch: arch,
platform: ReleasePlatform.android,
hash: '#',
size: 42,
url: 'https://example.com',
);
setUp(() {
when(
() => codePushClientWrapper.getReleaseArtifacts(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
architectures: any(named: 'architectures'),
platform: any(named: 'platform'),
),
).thenAnswer(
(_) async => {
Arch.arm32: releaseArtifact,
Arch.arm64: releaseArtifact,
Arch.x86_64: releaseArtifact,
},
);
when(() => artifactManager.downloadFile(any()))
.thenAnswer((_) async => File(''));
});
group('when release artifact fails to download', () {
setUp(() {
when(
() => artifactManager.downloadFile(any()),
).thenThrow(Exception('error'));
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(
() => patcher.createPatchArtifacts(
appId: 'appId',
releaseId: 0,
),
),
exitsWithCode(ExitCode.software),
);
verify(() => progress.fail('Exception: error')).called(1);
});
});
group('when unable to find patch build artifacts', () {
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(
() => patcher.createPatchArtifacts(
appId: 'appId',
releaseId: 0,
),
),
exitsWithCode(ExitCode.software),
);
verify(() => logger.err('Could not find patch artifacts')).called(1);
});
});
group('when unable to create diffs', () {
setUp(() {
setUpProjectRootArtifacts();
when(
() => artifactManager.createDiff(
releaseArtifactPath: any(named: 'releaseArtifactPath'),
patchArtifactPath: any(named: 'patchArtifactPath'),
),
).thenThrow(Exception('error'));
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(
() => patcher.createPatchArtifacts(
appId: 'appId',
releaseId: 0,
),
),
exitsWithCode(ExitCode.software),
);
verify(() => progress.fail('Exception: error')).called(1);
});
});
group('when patch artifacts successfully created', () {
setUp(() {
setUpProjectRootArtifacts();
when(
() => artifactManager.createDiff(
releaseArtifactPath: any(named: 'releaseArtifactPath'),
patchArtifactPath: any(named: 'patchArtifactPath'),
),
).thenAnswer((_) async {
final tempDir = Directory.systemTemp.createTempSync();
final diffPath = p.join(tempDir.path, 'diff');
File(diffPath)
..createSync()
..writeAsStringSync('test');
return diffPath;
});
});
test('returns patch artifact bundles', () async {
final result = await runWithOverrides(
() => patcher.createPatchArtifacts(
appId: 'appId',
releaseId: 0,
),
);
expect(result, hasLength(Arch.values.length));
});
});
});
group('extractReleaseVersionFromArtifact', () {
setUp(() {
when(
() => shorebirdAndroidArtifacts.extractReleaseVersionFromAppBundle(
any(),
),
).thenAnswer((_) async => '1.0.0');
});
test(
'''returns value of shorebirdAndroidArtifacts.extractReleaseVersionFromAppBundle''',
() async {
expect(
await runWithOverrides(
() => patcher.extractReleaseVersionFromArtifact(File('')),
),
equals('1.0.0'),
);
});
});
group('patchArtifactForDiffCheck', () {
late File aabFile;
setUp(() {
aabFile = File('');
when(
() => shorebirdAndroidArtifacts.findAab(
project: any(named: 'project'),
flavor: any(named: 'flavor'),
),
).thenReturn(aabFile);
});
});
});
}
@@ -0,0 +1,565 @@
import 'package:args/args.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:scoped/scoped.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/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
import 'package:shorebird_code_push_protocol/shorebird_code_push_protocol.dart';
import 'package:test/test.dart';
import '../../matchers.dart';
import '../../mocks.dart';
void main() {
group('PatchNewCommand', () {
const appId = 'test-app-id';
const appDisplayName = 'Test App';
const arch = 'aarch64';
const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713';
const releasePlatform = ReleasePlatform.android;
const releaseVersion = '1.2.3+1';
const shorebirdYaml = ShorebirdYaml(appId: appId);
final appMetadata = AppMetadata(
appId: appId,
displayName: appDisplayName,
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
);
final release = Release(
id: 0,
appId: appId,
version: releaseVersion,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {releasePlatform: ReleaseStatus.active},
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
);
const releaseArtifact = ReleaseArtifact(
id: 0,
releaseId: 0,
arch: arch,
platform: releasePlatform,
hash: '#',
size: 42,
url: 'https://example.com',
);
const aabArtifact = ReleaseArtifact(
id: 0,
releaseId: 0,
arch: arch,
platform: releasePlatform,
hash: '#',
size: 42,
url: 'https://example.com/release.aab',
);
late ArchiveDiffer archiveDiffer;
late ArgResults argResults;
late ArtifactBuilder artifactBuilder;
late ArtifactManager artifactManager;
late CodePushClientWrapper codePushClientWrapper;
late Logger logger;
late PatchDiffChecker patchDiffChecker;
late Patcher patcher;
late Progress progress;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late PatchNewCommand command;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
artifactBuilderRef.overrideWith(() => artifactBuilder),
artifactManagerRef.overrideWith(() => artifactManager),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
loggerRef.overrideWith(() => logger),
patchDiffCheckerRef.overrideWith(() => patchDiffChecker),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
},
);
}
setUpAll(() {
registerFallbackValue(CreatePatchMetadata.forTest());
registerFallbackValue(DeploymentTrack.production);
registerFallbackValue(Directory(''));
registerFallbackValue(File(''));
registerFallbackValue(FileSetDiff.empty());
registerFallbackValue(ReleasePlatform.android);
registerFallbackValue(Uri.parse('https://example.com'));
setExitFunctionForTests();
});
tearDownAll(restoreExitFunction);
setUp(() {
archiveDiffer = MockAndroidArchiveDiffer();
argResults = MockArgResults();
artifactBuilder = MockArtifactBuilder();
artifactManager = MockArtifactManager();
codePushClientWrapper = MockCodePushClientWrapper();
logger = MockLogger();
progress = MockProgress();
patchDiffChecker = MockPatchDiffChecker();
patcher = MockPatcher();
shorebirdEnv = MockShorebirdEnv();
shorebirdFlutter = MockShorebirdFlutter();
when(() => argResults['platform']).thenReturn(['android']);
when(() => argResults['release-version']).thenReturn(releaseVersion);
when(() => argResults.wasParsed(any())).thenReturn(true);
when(() => artifactManager.downloadFile(any()))
.thenAnswer((_) async => File(''));
when(() => codePushClientWrapper.getApp(appId: any(named: 'appId')))
.thenAnswer((_) async => appMetadata);
when(
() => codePushClientWrapper.getRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer((_) async => release);
when(
() => codePushClientWrapper.publishPatch(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
platform: any(named: 'platform'),
track: any(named: 'track'),
patchArtifactBundles: any(named: 'patchArtifactBundles'),
metadata: any(named: 'metadata'),
),
).thenAnswer((_) async {});
when(
() => codePushClientWrapper.getReleaseArtifacts(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
architectures: any(named: 'architectures'),
platform: any(named: 'platform'),
),
).thenAnswer(
(_) async => {
Arch.arm32: releaseArtifact,
Arch.arm64: releaseArtifact,
Arch.x86_64: releaseArtifact,
},
);
when(
() => codePushClientWrapper.getReleaseArtifact(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
arch: 'aab',
platform: ReleasePlatform.android,
),
).thenAnswer((_) async => aabArtifact);
when(() => logger.confirm(any())).thenReturn(true);
when(() => logger.progress(any())).thenReturn(progress);
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
archiveDiffer: archiveDiffer,
allowAssetChanges: any(named: 'allowAssetChanges'),
allowNativeChanges: any(named: 'allowNativeChanges'),
),
).thenAnswer(
(_) async => DiffStatus(
hasAssetChanges: false,
hasNativeChanges: false,
),
);
when(() => patcher.archiveDiffer).thenReturn(archiveDiffer);
when(() => patcher.assertArgsAreValid()).thenAnswer((_) async {});
when(() => patcher.assertPreconditions()).thenAnswer((_) async {});
when(() => patcher.extractReleaseVersionFromArtifact(any()))
.thenAnswer((_) async => releaseVersion);
when(() => patcher.buildPatchArtifact())
.thenAnswer((_) async => File(''));
when(() => patcher.releaseType).thenReturn(ReleaseType.android);
when(() => patcher.primaryReleaseArtifactArch).thenReturn('aab');
when(
() => patcher.createPatchArtifacts(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
),
).thenAnswer(
(_) async => {
Arch.arm32: const PatchArtifactBundle(
arch: 'arm32',
hash: '#',
size: 42,
path: '',
),
},
);
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml);
when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision);
when(
() => shorebirdEnv.copyWith(
flutterRevisionOverride: any(named: 'flutterRevisionOverride'),
),
).thenAnswer((invocation) {
when(() => shorebirdEnv.flutterRevision).thenReturn(
invocation.namedArguments[#flutterRevisionOverride] as String,
);
return shorebirdEnv;
});
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true);
when(
() => shorebirdFlutter.getVersionAndRevision(),
).thenAnswer((_) async => flutterRevision);
when(
() => shorebirdFlutter.installRevision(
revision: any(named: 'revision'),
),
).thenAnswer((_) async => {});
command = PatchNewCommand(resolvePatcher: (_) => patcher)
..testArgResults = argResults;
});
test('has non-empty description', () {
expect(command.description, isNotEmpty);
});
group('hidden', () {
test('is true', () {
expect(command.hidden, true);
});
});
group('getPatcher', () {
test('maps the correct platform to the patcher', () async {
expect(
command.getPatcher(ReleaseType.android),
isA<AndroidPatcher>(),
);
expect(
() => command.getPatcher(ReleaseType.aar),
throwsA(isA<UnimplementedError>()),
);
expect(
() => command.getPatcher(ReleaseType.ios),
throwsA(isA<UnimplementedError>()),
);
expect(
() => command.getPatcher(ReleaseType.iosFramework),
throwsA(isA<UnimplementedError>()),
);
});
});
group('when release version is specified', () {
setUp(() {
when(() => argResults['release-version']).thenReturn(releaseVersion);
});
test('executes commands in order, only builds app once', () async {
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verifyInOrder([
() => patcher.assertPreconditions(),
() => patcher.assertArgsAreValid(),
() => codePushClientWrapper.getApp(appId: appId),
() => codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
),
() => codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
arch: patcher.primaryReleaseArtifactArch,
platform: releasePlatform,
),
() => patcher.buildPatchArtifact(),
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
allowAssetChanges: false,
allowNativeChanges: false,
archiveDiffer: archiveDiffer,
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
),
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: release.id,
),
() => logger.confirm('Would you like to continue?'),
() => codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
metadata: any(named: 'metadata'),
platform: releasePlatform,
patchArtifactBundles: any(named: 'patchArtifactBundles'),
track: DeploymentTrack.production,
),
]);
});
});
group('when release version is not specified', () {
setUp(() {
when(() => argResults.wasParsed('release-version')).thenReturn(false);
});
test(
'executes commands in order, builds app to determine release version',
() async {
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verifyInOrder([
() => patcher.assertPreconditions(),
() => patcher.assertArgsAreValid(),
() => codePushClientWrapper.getApp(appId: appId),
() => patcher.buildPatchArtifact(),
() => patcher.extractReleaseVersionFromArtifact(any()),
() => codePushClientWrapper.getRelease(
appId: appId,
releaseVersion: releaseVersion,
),
() => codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: release.id,
arch: patcher.primaryReleaseArtifactArch,
platform: releasePlatform,
),
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
allowAssetChanges: false,
allowNativeChanges: false,
archiveDiffer: archiveDiffer,
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
),
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: release.id,
),
() => logger.confirm('Would you like to continue?'),
() => codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
metadata: any(named: 'metadata'),
platform: releasePlatform,
patchArtifactBundles: any(named: 'patchArtifactBundles'),
track: DeploymentTrack.production,
),
]);
});
group('when release Flutter version is not default', () {
const releaseFlutterRevision = 'different-revision';
setUp(() {
when(
() => codePushClientWrapper.getRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer(
(_) async => Release(
id: 0,
appId: appId,
version: releaseVersion,
flutterRevision: releaseFlutterRevision,
displayName: '1.2.3+1',
platformStatuses: {releasePlatform: ReleaseStatus.active},
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
),
);
});
test('builds app twice if release flutter version is not default',
() async {
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verifyInOrder([
() => patcher.buildPatchArtifact(),
() => shorebirdEnv.copyWith(
flutterRevisionOverride: releaseFlutterRevision,
),
() => patcher.buildPatchArtifact(),
]);
});
});
});
group('when running on CI', () {
test('does not prompt for confirmation', () async {
when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false);
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verifyNever(() => logger.confirm(any()));
});
});
group('when user declines to continue', () {
setUp(() {
when(() => logger.confirm(any())).thenReturn(false);
});
test('exits with message and success code', () async {
await expectLater(
() => runWithOverrides(command.run),
exitsWithCode(ExitCode.success),
);
verify(() => logger.info('Aborting.')).called(1);
});
});
group('when the target release is in a draft state', () {
setUp(() {
when(
() => codePushClientWrapper.getRelease(
appId: any(named: 'appId'),
releaseVersion: any(named: 'releaseVersion'),
),
).thenAnswer(
(_) async => Release(
id: 0,
appId: appId,
version: releaseVersion,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
platformStatuses: {releasePlatform: ReleaseStatus.draft},
createdAt: DateTime(2023),
updatedAt: DateTime(2023),
),
);
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(command.run),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
'''
Release ${release.version} is in an incomplete state. It's possible that the original release was terminated or failed to complete.
Please re-run the release command for this version or create a new release.''',
),
).called(1);
});
});
group('when primary release artifact fails to download', () {
final error = Exception('Failed to download primary release artifact.');
setUp(() {
when(() => artifactManager.downloadFile(any())).thenThrow(error);
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(command.run),
exitsWithCode(ExitCode.software),
);
verify(
() => progress.fail(
'Exception: Failed to download primary release artifact.',
),
).called(1);
});
});
group('when unpatchable diffs exist', () {
group('when user cancels', () {
setUp(() {
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
allowAssetChanges: any(named: 'allowAssetChanges'),
allowNativeChanges: any(named: 'allowNativeChanges'),
archiveDiffer: archiveDiffer,
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
),
).thenThrow(UserCancelledException());
});
test('exits with code 0', () async {
await expectLater(
() => runWithOverrides(command.run),
exitsWithCode(ExitCode.success),
);
});
});
group('when UnpatchableChangeException is thrown', () {
setUp(() {
when(
() => patchDiffChecker.confirmUnpatchableDiffsIfNecessary(
allowAssetChanges: any(named: 'allowAssetChanges'),
allowNativeChanges: any(named: 'allowNativeChanges'),
archiveDiffer: archiveDiffer,
localArtifact: any(named: 'localArtifact'),
releaseArtifact: any(named: 'releaseArtifact'),
),
).thenThrow(UnpatchableChangeException());
});
test('logs and exits with code 70', () async {
await expectLater(
() => runWithOverrides(command.run),
exitsWithCode(ExitCode.software),
);
verify(() => logger.info('Exiting.')).called(1);
});
});
});
group('when patching to the staging track', () {
setUp(() {
when(() => argResults['staging']).thenReturn(true);
});
test('publishes to the staging track', () async {
final exitCode = await runWithOverrides(command.run);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => codePushClientWrapper.publishPatch(
appId: appId,
releaseId: release.id,
metadata: any(named: 'metadata'),
platform: releasePlatform,
patchArtifactBundles: any(named: 'patchArtifactBundles'),
track: DeploymentTrack.staging,
),
).called(1);
});
});
});
}
@@ -171,7 +171,7 @@ void main() {
..testArgResults = argResults;
});
test('has description', () {
test('has non-empty description', () {
expect(command.description, isNotEmpty);
});
@@ -16,6 +16,7 @@ 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;
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/patch_new/patch_new.dart';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/config/config.dart';
import 'package:shorebird_cli/src/doctor.dart';
@@ -111,6 +112,8 @@ class MockOperatingSystemInterface extends Mock
class MockPatchDiffChecker extends Mock implements PatchDiffChecker {}
class MockPatcher extends Mock implements Patcher {}
class MockPlatform extends Mock implements Platform {}
class MockProcessResult extends Mock implements ShorebirdProcessResult {}