refactor(shorebird_cli): introduce new patch command (#2030)
This commit is contained in:
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user