refactor(shorebird_cli): Add IosFrameworkPatcher (#2036)

This commit is contained in:
Bryan Oltman
2024-05-09 11:59:11 -04:00
committed by GitHub
parent 1ede8d895f
commit d94182ae53
15 changed files with 1294 additions and 7 deletions
@@ -6,6 +6,7 @@ import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
@@ -321,4 +322,33 @@ Either run `flutter pub get` manually, or follow the steps in ${link(uri: Uri.pa
);
}
}
/// Creates an AOT snapshot of the given [appDillPath] at [outFilePath] and
/// returns the resulting file.
Future<File> buildElfAotSnapshot({
required String appDillPath,
required String outFilePath,
}) async {
final arguments = [
'--deterministic',
'--snapshot-kind=app-aot-elf',
'--elf=$outFilePath',
appDillPath,
];
final result = await process.run(
shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.genSnapshot,
),
arguments,
);
if (result.exitCode != ExitCode.success.code) {
throw ArtifactBuildException(
'Failed to create snapshot: ${result.stderr}',
);
}
return File(outFilePath);
}
}
@@ -270,4 +270,27 @@ class ArtifactManager {
),
);
}
/// Finds the most recently-edited app.dill file in the .dart_tool directory.
// TODO(bryanoltman): This is an enormous hack we don't know that this is
// the correct file.
File newestAppDill() {
final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!;
final dartToolBuildDir = Directory(
p.join(
projectRoot.path,
'.dart_tool',
'flutter_build',
),
);
return dartToolBuildDir
.listSync(recursive: true)
.whereType<File>()
.where((f) => p.basename(f.path) == 'app.dill')
.reduce(
(a, b) =>
a.statSync().modified.isAfter(b.statSync().modified) ? a : b,
);
}
}
@@ -0,0 +1,290 @@
import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:shorebird_cli/src/archive/directory_archive.dart';
import 'package:shorebird_cli/src/archive_analysis/archive_differ.dart';
import 'package:shorebird_cli/src/archive_analysis/ios_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/doctor.dart';
import 'package:shorebird_cli/src/executables/aot_tools.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.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/release_type.dart';
import 'package:shorebird_cli/src/shorebird_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.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';
import 'package:shorebird_cli/src/version.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// {@template ios_framework_patcher}
/// Functions to patch an iOS Framework release.
/// {@endtemplate}
class IosFrameworkPatcher extends Patcher {
/// {@macro ios_framework_patcher}
IosFrameworkPatcher({
required super.argResults,
required super.flavor,
required super.target,
});
String get _buildDirectory => p.join(
shorebirdEnv.getShorebirdProjectRoot()!.path,
'build',
);
String get _vmcodeOutputPath => p.join(
_buildDirectory,
'out.vmcode',
);
@override
ArchiveDiffer get archiveDiffer => IosArchiveDiffer();
@override
String get primaryReleaseArtifactArch => 'xcframework';
@override
ReleaseType get releaseType => ReleaseType.iosFramework;
@visibleForTesting
double? lastBuildLinkPercentage;
@override
Future<void> assertPreconditions() async {
try {
await shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
validators: doctor.iosCommandValidators,
supportedOperatingSystems: {Platform.macOS},
);
} on PreconditionFailedException catch (e) {
exit(e.exitCode.code);
}
}
@override
Future<void> assertArgsAreValid() async {
if (!argResults.wasParsed('release-version')) {
logger.err('Missing required argument: --release-version');
exit(ExitCode.usage.code);
}
}
@override
Future<File> buildPatchArtifact() async {
final flutterVersionString = await shorebirdFlutter.getVersionAndRevision();
final buildProgress = logger.progress(
'Building patch with Flutter $flutterVersionString',
);
try {
await artifactBuilder.buildIosFramework();
} on ArtifactBuildException catch (error) {
buildProgress.fail(error.message);
exit(ExitCode.software.code);
}
try {
final newestDillFile = artifactManager.newestAppDill();
await artifactBuilder.buildElfAotSnapshot(
appDillPath: newestDillFile.path,
outFilePath: p.join(
shorebirdEnv.getShorebirdProjectRoot()!.path,
'build',
'out.aot',
),
);
} catch (error) {
buildProgress.fail('$error');
exit(ExitCode.software.code);
}
buildProgress.complete();
return Directory(
p.join(
artifactManager.getAppXcframeworkDirectory().path,
ArtifactManager.appXcframeworkName,
),
).zipToTempFile();
}
@override
Future<Map<Arch, PatchArtifactBundle>> createPatchArtifacts({
required String appId,
required int releaseId,
}) async {
final releaseArtifact = await codePushClientWrapper.getReleaseArtifact(
appId: appId,
releaseId: releaseId,
arch: 'xcframework',
platform: ReleasePlatform.ios,
);
final downloadProgress = logger.progress('Downloading release artifact');
final File releaseArtifactZipFile;
try {
releaseArtifactZipFile = await artifactManager.downloadFile(
Uri.parse(releaseArtifact.url),
);
} catch (error) {
downloadProgress.fail('$error');
exit(ExitCode.software.code);
}
downloadProgress.complete();
final unzipProgress = logger.progress('Extracting release artifact');
final tempDir = Directory.systemTemp.createTempSync();
await artifactManager.extractZip(
zipFile: releaseArtifactZipFile,
outputDirectory: tempDir,
);
final releaseXcframeworkPath = tempDir.path;
unzipProgress
.complete('Extracted release artifact to $releaseXcframeworkPath');
final releaseArtifactFile = File(
p.join(
releaseXcframeworkPath,
'ios-arm64',
'App.framework',
'App',
),
);
final aotSnapshotFile = File(
p.join(
shorebirdEnv.getShorebirdProjectRoot()!.path,
'build',
'out.aot',
),
);
final useLinker = AotTools.usesLinker(shorebirdEnv.flutterRevision);
if (useLinker) {
await _runLinker(
aotSnapshot: aotSnapshotFile,
releaseArtifact: releaseArtifactFile,
);
}
final patchBuildFile =
useLinker ? File(_vmcodeOutputPath) : aotSnapshotFile;
final File patchFile;
if (await aotTools.isGeneratePatchDiffBaseSupported()) {
final patchBaseProgress = logger.progress('Generating patch diff base');
final analyzeSnapshotPath = shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot,
);
final File patchBaseFile;
try {
// If the aot_tools executable supports the dump_blobs command, we
// can generate a stable diff base and use that to create a patch.
patchBaseFile = await aotTools.generatePatchDiffBase(
analyzeSnapshotPath: analyzeSnapshotPath,
releaseSnapshot: releaseArtifactFile,
);
patchBaseProgress.complete();
} catch (error) {
patchBaseProgress.fail('$error');
exit(ExitCode.software.code);
}
patchFile = File(
await artifactManager.createDiff(
releaseArtifactPath: patchBaseFile.path,
patchArtifactPath: patchBuildFile.path,
),
);
} else {
patchFile = patchBuildFile;
}
return {
Arch.arm64: PatchArtifactBundle(
arch: 'aarch64',
path: patchFile.path,
hash: sha256.convert(patchBuildFile.readAsBytesSync()).toString(),
size: patchFile.statSync().size,
),
};
}
@override
Future<String> extractReleaseVersionFromArtifact(File artifact) {
// Not implemented - release verison must be specified by the user.
throw UnimplementedError(
'Release version must be specified using --release-version.',
);
}
@override
Future<CreatePatchMetadata> createPatchMetadata(DiffStatus diffStatus) async {
return CreatePatchMetadata(
releasePlatform: releaseType.releasePlatform,
usedIgnoreAssetChangesFlag: allowAssetDiffs,
hasAssetChanges: diffStatus.hasAssetChanges,
usedIgnoreNativeChangesFlag: allowNativeDiffs,
hasNativeChanges: diffStatus.hasNativeChanges,
linkPercentage: lastBuildLinkPercentage,
environment: BuildEnvironmentMetadata(
operatingSystem: platform.operatingSystem,
operatingSystemVersion: platform.operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: await xcodeBuild.version(),
),
);
}
Future<void> _runLinker({
required File aotSnapshot,
required File releaseArtifact,
}) async {
if (!aotSnapshot.existsSync()) {
logger.err('Unable to find patch AOT file at ${aotSnapshot.path}');
exit(ExitCode.software.code);
}
final analyzeSnapshot = File(
shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot,
),
);
if (!analyzeSnapshot.existsSync()) {
logger.err('Unable to find analyze_snapshot at ${analyzeSnapshot.path}');
exit(ExitCode.software.code);
}
final genSnapshot = shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.genSnapshot,
);
final linkProgress = logger.progress('Linking AOT files');
try {
lastBuildLinkPercentage = await aotTools.link(
base: releaseArtifact.path,
patch: aotSnapshot.path,
analyzeSnapshot: analyzeSnapshot.path,
genSnapshot: genSnapshot,
kernel: artifactManager.newestAppDill().path,
outputPath: _vmcodeOutputPath,
workingDirectory: _buildDirectory,
);
} catch (error) {
linkProgress.fail('Failed to link AOT files: $error');
exit(ExitCode.software.code);
}
linkProgress.complete();
}
}
@@ -1,4 +1,5 @@
export 'aar_patcher.dart';
export 'android_patcher.dart';
export 'ios_framework_patcher.dart';
export 'patch_new_command.dart';
export 'patcher.dart';
@@ -3,6 +3,7 @@ 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/cache.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/patch_new.dart';
@@ -137,7 +138,11 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl
case ReleaseType.ios:
throw UnimplementedError();
case ReleaseType.iosFramework:
throw UnimplementedError();
return IosFrameworkPatcher(
argResults: results,
flavor: flavor,
target: target,
);
case ReleaseType.aar:
return AarPatcher(
argResults: results,
@@ -157,6 +162,8 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl
await patcher.assertPreconditions();
await patcher.assertArgsAreValid();
await cache.updateAll();
final app = await codePushClientWrapper.getApp(appId: appId);
File? patchArtifact;
@@ -186,6 +193,8 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl
return await runScoped(
() async {
await cache.updateAll();
// Don't built the patch artifact twice with the same Flutter revision.
if (lastBuiltFlutterRevision != release.flutterRevision) {
patchArtifact = await patcher.buildPatchArtifact();
@@ -1,8 +1,8 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/artifact_builder.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/commands/release_new/release_new.dart';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
@@ -5,12 +5,12 @@ import 'package:platform/platform.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/release_type.dart';
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/platform.dart';
import 'package:shorebird_cli/src/release_type.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_flutter.dart';
import 'package:shorebird_cli/src/shorebird_validator.dart';
@@ -92,6 +92,20 @@ class IosFrameworkReleaser extends Releaser {
targetLibraryDirectory.path,
);
// Rename Flutter.xcframework to ShorebirdFlutter.xcframework to avoid
// Xcode warning users about the .xcframework signature changing.
Directory(
p.join(
targetLibraryDirectory.path,
'Flutter.xcframework',
),
).renameSync(
p.join(
targetLibraryDirectory.path,
'ShorebirdFlutter.xcframework',
),
);
return targetLibraryDirectory;
}
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/release_new/release_new.dart';
@@ -178,6 +179,8 @@ of the iOS app that is using this module.''',
await releaser.assertPreconditions();
await releaser.assertArgsAreValid();
await cache.updateAll();
// This command handles logging, we don't need to provide our own
// progress, error logs, etc.
final app = await codePushClientWrapper.getApp(appId: appId);
@@ -193,6 +196,8 @@ of the iOS app that is using this module.''',
);
return await runScoped(
() async {
await cache.updateAll();
final releaseArtifact = await releaser.buildReleaseArtifacts();
final releaseVersion = await releaser.getReleaseVersion(
releaseArtifactRoot: releaseArtifact,
@@ -9,6 +9,7 @@ import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
import 'package:shorebird_cli/src/platform/platform.dart';
import 'package:shorebird_cli/src/shorebird_android_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_artifacts.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:test/test.dart';
@@ -22,6 +23,7 @@ void main() {
late ShorebirdLogger logger;
late OperatingSystemInterface operatingSystemInterface;
late ShorebirdAndroidArtifacts shorebirdAndroidArtifacts;
late ShorebirdArtifacts shorebirdArtifacts;
late ShorebirdEnv shorebirdEnv;
late ShorebirdProcess shorebirdProcess;
late ShorebirdProcessResult buildProcessResult;
@@ -36,6 +38,7 @@ void main() {
loggerRef.overrideWith(() => logger),
osInterfaceRef.overrideWith(() => operatingSystemInterface),
processRef.overrideWith(() => shorebirdProcess),
shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdAndroidArtifactsRef
.overrideWith(() => shorebirdAndroidArtifacts),
@@ -55,6 +58,7 @@ void main() {
operatingSystemInterface = MockOperatingSystemInterface();
pubGetProcessResult = MockProcessResult();
shorebirdAndroidArtifacts = MockShorebirdAndroidArtifacts();
shorebirdArtifacts = MockShorebirdArtifacts();
shorebirdEnv = MockShorebirdEnv();
shorebirdProcess = MockShorebirdProcess();
@@ -772,6 +776,48 @@ Failed to build:
});
});
});
group('buildElfAotSnapshot', () {
setUp(() {
when(
() => shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.genSnapshot,
),
).thenReturn('gen_snapshot');
});
group('when build fails', () {
setUp(() {
when(() => buildProcessResult.exitCode)
.thenReturn(ExitCode.software.code);
});
test('throws ArtifactBuildException', () {
expect(
() => runWithOverrides(
() => builder.buildElfAotSnapshot(
appDillPath: 'asdf',
outFilePath: 'asdf',
),
),
throwsA(isA<ArtifactBuildException>()),
);
});
});
group('when build succeeds', () {
test('returns outFile', () async {
final outFile = await runWithOverrides(
() => builder.buildElfAotSnapshot(
appDillPath: '/app/dill/path',
outFilePath: '/path/to/out',
),
);
expect(outFile.path, '/path/to/out');
});
});
});
},
testOn: 'mac-os',
);
@@ -592,5 +592,30 @@ void main() {
);
});
});
group('newestAppDill', () {
late File appDill2;
setUp(() {
final tempDir = Directory.systemTemp.createTempSync();
when(() => shorebirdEnv.getShorebirdProjectRoot()).thenReturn(tempDir);
final flutterBuildDir = Directory(
p.join(tempDir.path, '.dart_tool', 'flutter_build'),
)..createSync(recursive: true);
File(p.join(flutterBuildDir.path, 'app1', 'app.dill'))
..createSync(recursive: true)
..setLastModified(DateTime.now().subtract(const Duration(days: 1)));
appDill2 = File(p.join(flutterBuildDir.path, 'app2', 'app.dill'))
..createSync(recursive: true)
..setLastModified(DateTime.now());
});
test('selects the most recently edited .app.dill file', () {
final result = runWithOverrides(artifactManager.newestAppDill);
expect(result, isNotNull);
expect(result.path, equals(appDill2.path));
});
});
});
}
@@ -0,0 +1,816 @@
import 'package:args/args.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package: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/executables/aot_tools.dart';
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/operating_system_interface.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/release_type.dart';
import 'package:shorebird_cli/src/shorebird_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_cli/src/version.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(
IosFrameworkPatcher,
() {
late AotTools aotTools;
late ArgResults argResults;
late ArtifactBuilder artifactBuilder;
late ArtifactManager artifactManager;
late CodePushClientWrapper codePushClientWrapper;
late Doctor doctor;
late EngineConfig engineConfig;
late Directory flutterDirectory;
late Directory projectRoot;
late ShorebirdLogger logger;
late OperatingSystemInterface operatingSystemInterface;
late Platform platform;
late Progress progress;
late ShorebirdArtifacts shorebirdArtifacts;
late ShorebirdFlutterValidator flutterValidator;
late ShorebirdProcess shorebirdProcess;
late ShorebirdEnv shorebirdEnv;
late ShorebirdFlutter shorebirdFlutter;
late ShorebirdValidator shorebirdValidator;
late XcodeBuild xcodeBuild;
late IosFrameworkPatcher patcher;
R runWithOverrides<R>(R Function() body) {
return runScoped(
body,
values: {
aotToolsRef.overrideWith(() => aotTools),
artifactBuilderRef.overrideWith(() => artifactBuilder),
artifactManagerRef.overrideWith(() => artifactManager),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
doctorRef.overrideWith(() => doctor),
engineConfigRef.overrideWith(() => engineConfig),
loggerRef.overrideWith(() => logger),
osInterfaceRef.overrideWith(() => operatingSystemInterface),
platformRef.overrideWith(() => platform),
processRef.overrideWith(() => shorebirdProcess),
shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
xcodeBuildRef.overrideWith(() => xcodeBuild),
},
);
}
setUpAll(() {
registerFallbackValue(Directory(''));
registerFallbackValue(File(''));
registerFallbackValue(ReleasePlatform.ios);
registerFallbackValue(Uri.parse('https://example.com'));
setExitFunctionForTests();
});
tearDownAll(restoreExitFunction);
setUp(() {
aotTools = MockAotTools();
argResults = MockArgResults();
artifactBuilder = MockArtifactBuilder();
artifactManager = MockArtifactManager();
codePushClientWrapper = MockCodePushClientWrapper();
doctor = MockDoctor();
engineConfig = MockEngineConfig();
operatingSystemInterface = MockOperatingSystemInterface();
platform = MockPlatform();
progress = MockProgress();
projectRoot = Directory.systemTemp.createTempSync();
logger = MockShorebirdLogger();
shorebirdArtifacts = MockShorebirdArtifacts();
shorebirdProcess = MockShorebirdProcess();
shorebirdEnv = MockShorebirdEnv();
flutterValidator = MockShorebirdFlutterValidator();
shorebirdFlutter = MockShorebirdFlutter();
shorebirdValidator = MockShorebirdValidator();
xcodeBuild = MockXcodeBuild();
when(() => argResults['build-number']).thenReturn('1.0');
when(() => logger.progress(any())).thenReturn(progress);
when(
() => shorebirdEnv.getShorebirdProjectRoot(),
).thenReturn(projectRoot);
patcher = IosFrameworkPatcher(
argResults: argResults,
flavor: null,
target: null,
);
});
group('archiveDiffer', () {
test('is an IosArchiveDiffer', () {
expect(patcher.archiveDiffer, isA<IosArchiveDiffer>());
});
});
group('primaryReleaseArtifactArch', () {
test('is "xcframework"', () {
expect(patcher.primaryReleaseArtifactArch, 'xcframework');
});
});
group('releaseType', () {
test('is ReleaseType.iosFramework', () {
expect(patcher.releaseType, ReleaseType.iosFramework);
});
});
group('assertPreconditions', () {
setUp(() {
when(() => doctor.iosCommandValidators)
.thenReturn([flutterValidator]);
});
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'),
supportedOperatingSystems:
any(named: 'supportedOperatingSystems'),
),
).thenThrow(exception);
await expectLater(
() => runWithOverrides(patcher.assertPreconditions),
exitsWithCode(exception.exitCode),
);
verify(
() => shorebirdValidator.validatePreconditions(
checkUserIsAuthenticated: true,
checkShorebirdInitialized: true,
validators: [flutterValidator],
supportedOperatingSystems: {Platform.macOS},
),
).called(1);
});
});
});
group('assertArgsAreValid', () {
group('when release-version is not provided', () {
setUp(() {
when(() => argResults.wasParsed('release-version'))
.thenReturn(false);
});
test('exits with code 64', () async {
await expectLater(
() => runWithOverrides(patcher.assertArgsAreValid),
exitsWithCode(ExitCode.usage),
);
});
});
group('when arguments are valid', () {
setUp(() {
when(() => argResults.wasParsed('release-version'))
.thenReturn(true);
});
test('returns normally', () {
expect(
() => runWithOverrides(patcher.assertArgsAreValid),
returnsNormally,
);
});
});
});
group('buildPatchArtifact', () {
const flutterVersionAndRevision = '3.10.6 (83305b5088)';
setUp(() {
when(
() => shorebirdFlutter.getVersionAndRevision(),
).thenAnswer((_) async => flutterVersionAndRevision);
});
group('when build fails', () {
setUp(() {
when(() => artifactBuilder.buildIosFramework()).thenThrow(
ArtifactBuildException('Build failed'),
);
});
test('exits with code 70', () async {
await expectLater(
() => runWithOverrides(patcher.buildPatchArtifact),
exitsWithCode(ExitCode.software),
);
verify(() => progress.fail('Build failed'));
});
});
group('when elf aot snapshot build fails', () {
setUp(() {
when(() => artifactBuilder.buildIosFramework()).thenAnswer(
(_) async {},
);
when(() => artifactManager.newestAppDill()).thenReturn(File(''));
when(
() => artifactBuilder.buildElfAotSnapshot(
appDillPath: any(named: 'appDillPath'),
outFilePath: any(named: 'outFilePath'),
),
).thenThrow(const FileSystemException('error'));
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(patcher.buildPatchArtifact),
exitsWithCode(ExitCode.software),
);
verify(
() => progress.fail("FileSystemException: error, path = ''"),
);
});
});
group('when build succeeds', () {
setUp(() {
when(() => artifactBuilder.buildIosFramework()).thenAnswer(
(_) async {},
);
when(() => artifactManager.newestAppDill()).thenReturn(File(''));
when(
() => artifactBuilder.buildElfAotSnapshot(
appDillPath: any(named: 'appDillPath'),
outFilePath: any(named: 'outFilePath'),
),
).thenAnswer(
(invocation) async =>
File(invocation.namedArguments[#outFilePath] as String)
..createSync(recursive: true),
);
Directory(
p.join(projectRoot.path, ArtifactManager.appXcframeworkName),
).createSync(recursive: true);
when(() => artifactManager.getAppXcframeworkDirectory())
.thenReturn(projectRoot);
});
test('returns zipped xcframework', () async {
final artifact = await runWithOverrides(patcher.buildPatchArtifact);
expect(p.basename(artifact.path), equals('App.xcframework.zip'));
});
});
});
group('createPatchArtifacts', () {
const postLinkerFlutterRevision =
'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef';
const preLinkerFlutterRevision =
'83305b5088e6fe327fb3334a73ff190828d85713';
const appId = 'appId';
const arch = 'aarch64';
const releaseId = 1;
const linkFileName = 'out.vmcode';
const elfAotSnapshotFileName = 'out.aot';
const releaseArtifact = ReleaseArtifact(
id: 0,
releaseId: releaseId,
arch: arch,
platform: ReleasePlatform.android,
hash: '#',
size: 42,
url: 'https://example.com',
);
void setUpProjectRootArtifacts() {
// Create a second app.dill for coverage of newestAppDill file.
File(
p.join(
projectRoot.path,
'.dart_tool',
'flutter_build',
'subdir',
'app.dill',
),
).createSync(recursive: true);
File(
p.join(projectRoot.path, '.dart_tool', 'flutter_build', 'app.dill'),
).createSync(recursive: true);
File(p.join(projectRoot.path, 'build', elfAotSnapshotFileName))
.createSync(
recursive: true,
);
Directory(
p.join(
projectRoot.path,
'build',
'ios',
'framework',
'Release',
'App.xcframework',
),
).createSync(
recursive: true,
);
File(
p.join(projectRoot.path, 'build', linkFileName),
).createSync(recursive: true);
}
setUp(() {
when(
() => codePushClientWrapper.getReleaseArtifact(
appId: any(named: 'appId'),
releaseId: any(named: 'releaseId'),
arch: any(named: 'arch'),
platform: any(named: 'platform'),
),
).thenAnswer((_) async => releaseArtifact);
when(() => artifactManager.downloadFile(any())).thenAnswer((_) async {
final tempDirectory = Directory.systemTemp.createTempSync();
final file = File(p.join(tempDirectory.path, 'libapp.so'))
..createSync();
return file;
});
when(
() => artifactManager.extractZip(
zipFile: any(named: 'zipFile'),
outputDirectory: any(named: 'outputDirectory'),
),
).thenAnswer((invocation) async {
final zipFile = invocation.namedArguments[#zipFile] as File;
final outDir =
invocation.namedArguments[#outputDirectory] as Directory;
File(p.join(outDir.path, '${p.basename(zipFile.path)}.zip'))
.createSync();
});
when(() => engineConfig.localEngine).thenReturn(null);
});
group('when release artifact download fails', () {
setUp(() {
when(
() => artifactManager.downloadFile(any()),
).thenThrow(Exception('Failed to download release artifact'));
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
),
),
exitsWithCode(ExitCode.software),
);
});
});
group('when uses linker', () {
const linkPercentage = 50.0;
late File analyzeSnapshotFile;
late File genSnapshotFile;
setUp(() {
final shorebirdRoot = Directory.systemTemp.createTempSync();
flutterDirectory = Directory(
p.join(shorebirdRoot.path, 'bin', 'cache', 'flutter'),
);
genSnapshotFile = File(
p.join(
flutterDirectory.path,
'bin',
'cache',
'artifacts',
'engine',
'ios-release',
'gen_snapshot_arm64',
),
);
analyzeSnapshotFile = File(
p.join(
flutterDirectory.path,
'bin',
'cache',
'artifacts',
'engine',
'ios-release',
'analyze_snapshot_arm64',
),
)..createSync(recursive: true);
when(
() => aotTools.link(
base: any(named: 'base'),
patch: any(named: 'patch'),
analyzeSnapshot: any(named: 'analyzeSnapshot'),
genSnapshot: any(named: 'genSnapshot'),
kernel: any(named: 'kernel'),
outputPath: any(named: 'outputPath'),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async => linkPercentage);
when(() => artifactManager.newestAppDill()).thenReturn(File(''));
when(() => shorebirdEnv.flutterRevision)
.thenReturn(postLinkerFlutterRevision);
when(
() => shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot,
),
).thenReturn(analyzeSnapshotFile.path);
when(
() => shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.genSnapshot,
),
).thenReturn(genSnapshotFile.path);
});
group('when linking fails', () {
group('when aot snapshot does not exist', () {
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
),
),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err(
any(that: startsWith('Unable to find patch AOT file at')),
),
).called(1);
});
});
group('when analyzeSnapshot binary does not exist', () {
setUp(() {
when(
() => shorebirdArtifacts.getArtifactPath(
artifact: ShorebirdArtifact.analyzeSnapshot),
).thenReturn('');
setUpProjectRootArtifacts();
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
),
),
exitsWithCode(ExitCode.software),
);
verify(
() => logger.err('Unable to find analyze_snapshot at '),
).called(1);
});
});
group('when call to aotTools.link fails', () {
setUp(() {
when(
() => aotTools.link(
base: any(named: 'base'),
patch: any(named: 'patch'),
analyzeSnapshot: any(named: 'analyzeSnapshot'),
genSnapshot: any(named: 'genSnapshot'),
kernel: any(named: 'kernel'),
outputPath: any(named: 'outputPath'),
workingDirectory: any(named: 'workingDirectory'),
),
).thenThrow(Exception('oops'));
setUpProjectRootArtifacts();
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
),
),
exitsWithCode(ExitCode.software),
);
verify(
() => progress.fail(
'Failed to link AOT files: Exception: oops',
),
).called(1);
});
});
});
group('when generate patch diff base is supported', () {
setUp(() {
when(() => aotTools.isGeneratePatchDiffBaseSupported())
.thenAnswer((_) async => true);
when(
() => aotTools.generatePatchDiffBase(
analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'),
releaseSnapshot: any(named: 'releaseSnapshot'),
),
).thenAnswer((_) async => File(''));
});
group('when we fail to generate patch diff base', () {
setUp(() {
when(
() => aotTools.generatePatchDiffBase(
analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'),
releaseSnapshot: any(named: 'releaseSnapshot'),
),
).thenThrow(Exception('oops'));
setUpProjectRootArtifacts();
});
test('logs error and exits with code 70', () async {
await expectLater(
() => runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
),
),
exitsWithCode(ExitCode.software),
);
verify(() => progress.fail('Exception: oops')).called(1);
});
});
group('when linking and patch diff generation succeeds', () {
const diffPath = 'path/to/diff';
setUp(() {
when(
() => artifactManager.createDiff(
releaseArtifactPath: any(named: 'releaseArtifactPath'),
patchArtifactPath: any(named: 'patchArtifactPath'),
),
).thenAnswer((_) async => diffPath);
setUpProjectRootArtifacts();
});
test('returns linked patch artifact in patch bundle', () async {
final patchBundle = await runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
),
);
expect(patchBundle, hasLength(1));
expect(
patchBundle[Arch.arm64],
isA<PatchArtifactBundle>()
.having((b) => b.path, 'path', endsWith(diffPath)),
);
});
});
});
group('when generate patch diff base is not supported', () {
setUp(() {
when(aotTools.isGeneratePatchDiffBaseSupported)
.thenAnswer((_) async => false);
setUpProjectRootArtifacts();
});
test('returns vmcode file as patch file', () async {
final patchBundle = await runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
),
);
expect(patchBundle, hasLength(1));
expect(
patchBundle[Arch.arm64],
isA<PatchArtifactBundle>()
.having((b) => b.path, 'path', endsWith('out.vmcode')),
);
});
});
});
group('when does not use linker', () {
setUp(() {
when(() => shorebirdEnv.flutterRevision)
.thenReturn(preLinkerFlutterRevision);
when(() => aotTools.isGeneratePatchDiffBaseSupported())
.thenAnswer((_) async => false);
setUpProjectRootArtifacts();
});
test('returns base patch artifact in patch bundle', () async {
final patchArtifacts = await runWithOverrides(
() => patcher.createPatchArtifacts(
appId: appId,
releaseId: releaseId,
),
);
expect(patchArtifacts, hasLength(1));
verifyNever(
() => aotTools.link(
base: any(named: 'base'),
patch: any(named: 'patch'),
analyzeSnapshot: any(named: 'analyzeSnapshot'),
genSnapshot: any(named: 'genSnapshot'),
kernel: any(named: 'kernel'),
outputPath: any(named: 'outputPath'),
),
);
});
});
});
group('extractReleaseVersionFromArtifact', () {
test('throws UnimplementedError', () {
expect(
() => patcher.extractReleaseVersionFromArtifact(File('')),
throwsUnimplementedError,
);
});
});
group('createPatchMetadata', () {
const allowAssetDiffs = false;
const allowNativeDiffs = true;
const operatingSystem = 'Mac OS X';
const operatingSystemVersion = '10.15.7';
const xcodeVersion = '11';
setUp(() {
when(() => argResults['allow-asset-diffs'])
.thenReturn(allowAssetDiffs);
when(
() => argResults['allow-native-diffs'],
).thenReturn(allowNativeDiffs);
when(() => platform.operatingSystem).thenReturn(operatingSystem);
when(
() => platform.operatingSystemVersion,
).thenReturn(operatingSystemVersion);
when(() => xcodeBuild.version())
.thenAnswer((_) async => xcodeVersion);
});
group('when linker is not enabled', () {
test('returns correct metadata', () async {
final diffStatus = DiffStatus(
hasAssetChanges: false,
hasNativeChanges: false,
);
final metadata = await runWithOverrides(
() => patcher.createPatchMetadata(diffStatus),
);
expect(
metadata,
equals(
CreatePatchMetadata(
releasePlatform: ReleasePlatform.ios,
usedIgnoreAssetChangesFlag: allowAssetDiffs,
hasAssetChanges: diffStatus.hasAssetChanges,
usedIgnoreNativeChangesFlag: allowNativeDiffs,
hasNativeChanges: diffStatus.hasNativeChanges,
linkPercentage: null,
environment: const BuildEnvironmentMetadata(
operatingSystem: operatingSystem,
operatingSystemVersion: operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: xcodeVersion,
),
),
),
);
});
});
group('when linker is enabled', () {
const linkPercentage = 100.0;
setUp(() {
patcher.lastBuildLinkPercentage = linkPercentage;
});
test('returns correct metadata', () async {
final diffStatus = DiffStatus(
hasAssetChanges: false,
hasNativeChanges: false,
);
final metadata = await runWithOverrides(
() => patcher.createPatchMetadata(diffStatus),
);
expect(
metadata,
equals(
CreatePatchMetadata(
releasePlatform: ReleasePlatform.ios,
usedIgnoreAssetChangesFlag: allowAssetDiffs,
hasAssetChanges: diffStatus.hasAssetChanges,
usedIgnoreNativeChangesFlag: allowNativeDiffs,
hasNativeChanges: diffStatus.hasNativeChanges,
linkPercentage: linkPercentage,
environment: const BuildEnvironmentMetadata(
operatingSystem: operatingSystem,
operatingSystemVersion: operatingSystemVersion,
shorebirdVersion: packageVersion,
xcodeVersion: xcodeVersion,
),
),
),
);
});
});
});
},
testOn: 'mac-os',
);
}
@@ -6,6 +6,7 @@ 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/cache.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/config/config.dart';
@@ -75,6 +76,7 @@ void main() {
late ArgResults argResults;
late ArtifactBuilder artifactBuilder;
late ArtifactManager artifactManager;
late Cache cache;
late CodePushClientWrapper codePushClientWrapper;
late ShorebirdLogger logger;
late PatchDiffChecker patchDiffChecker;
@@ -91,6 +93,7 @@ void main() {
values: {
artifactBuilderRef.overrideWith(() => artifactBuilder),
artifactManagerRef.overrideWith(() => artifactManager),
cacheRef.overrideWith(() => cache),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
loggerRef.overrideWith(() => logger),
patchDiffCheckerRef.overrideWith(() => patchDiffChecker),
@@ -119,6 +122,7 @@ void main() {
argResults = MockArgResults();
artifactBuilder = MockArtifactBuilder();
artifactManager = MockArtifactManager();
cache = MockCache();
codePushClientWrapper = MockCodePushClientWrapper();
logger = MockShorebirdLogger();
progress = MockProgress();
@@ -136,6 +140,8 @@ void main() {
() => artifactManager.downloadFile(any()),
).thenAnswer((_) async => File(''));
when(() => cache.updateAll()).thenAnswer((_) async => {});
when(() => codePushClientWrapper.getApp(appId: any(named: 'appId')))
.thenAnswer((_) async => appMetadata);
when(
@@ -278,8 +284,8 @@ void main() {
throwsA(isA<UnimplementedError>()),
);
expect(
() => command.getPatcher(ReleaseType.iosFramework),
throwsA(isA<UnimplementedError>()),
command.getPatcher(ReleaseType.iosFramework),
isA<IosFrameworkPatcher>(),
);
});
});
@@ -296,6 +302,7 @@ void main() {
verifyInOrder([
() => patcher.assertPreconditions(),
() => patcher.assertArgsAreValid(),
() => cache.updateAll(),
() => codePushClientWrapper.getApp(appId: appId),
() => codePushClientWrapper.getRelease(
appId: appId,
@@ -347,6 +354,7 @@ void main() {
verifyInOrder([
() => patcher.assertPreconditions(),
() => patcher.assertArgsAreValid(),
() => cache.updateAll(),
() => codePushClientWrapper.getApp(appId: appId),
() => patcher.buildPatchArtifact(),
() => patcher.extractReleaseVersionFromArtifact(any()),
@@ -419,6 +427,19 @@ void main() {
() => patcher.buildPatchArtifact(),
]);
});
test('updates cache with both default and release Flutter revisions',
() async {
await runWithOverrides(command.run);
verifyInOrder([
cache.updateAll,
() => shorebirdEnv.copyWith(
flutterRevisionOverride: releaseFlutterRevision,
),
cache.updateAll,
]);
});
});
});
@@ -197,7 +197,7 @@ void main() {
});
group('assertArgsAreValid', () {
group('when split-per-abi is true', () {
group('when release-version was not provided', () {
setUp(() {
when(() => argResults.wasParsed('release-version')).thenReturn(false);
});
@@ -256,7 +256,6 @@ void main() {
'ios',
'framework',
'Release',
'Flutter.xcframework',
),
),
);
@@ -2,6 +2,7 @@ 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/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/commands/release_new/release_new.dart';
import 'package:shorebird_cli/src/config/config.dart';
@@ -42,6 +43,7 @@ void main() {
);
late ArgResults argResults;
late Cache cache;
late CodePushClientWrapper codePushClientWrapper;
late Directory shorebirdRoot;
late Directory projectRoot;
@@ -57,6 +59,7 @@ void main() {
return runScoped(
body,
values: {
cacheRef.overrideWith(() => cache),
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
loggerRef.overrideWith(() => logger),
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
@@ -77,6 +80,7 @@ void main() {
setUp(() {
argResults = MockArgResults();
cache = MockCache();
codePushClientWrapper = MockCodePushClientWrapper();
logger = MockShorebirdLogger();
progress = MockProgress();
@@ -90,6 +94,8 @@ void main() {
when(() => argResults['platform']).thenReturn(['android']);
when(() => argResults.wasParsed(any())).thenReturn(true);
when(cache.updateAll).thenAnswer((_) async => {});
when(() => codePushClientWrapper.getApp(appId: any(named: 'appId')))
.thenAnswer((_) async => appMetadata);
when(
@@ -204,6 +210,7 @@ void main() {
verifyInOrder([
releaser.assertPreconditions,
releaser.assertArgsAreValid,
cache.updateAll,
() => codePushClientWrapper.getApp(appId: appId),
releaser.buildReleaseArtifacts,
() => releaser.getReleaseVersion(
@@ -291,6 +298,7 @@ Note: ${lightCyan.wrap('shorebird patch --platform=android')} without the --rele
verifyInOrder([
releaser.assertPreconditions,
releaser.assertArgsAreValid,
cache.updateAll,
() => codePushClientWrapper.getApp(appId: appId),
releaser.buildReleaseArtifacts,
() => releaser.getReleaseVersion(