diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/ios_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch_new/ios_patcher.dart new file mode 100644 index 00000000..ed9f57f9 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patch_new/ios_patcher.dart @@ -0,0 +1,375 @@ +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/archive.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/patcher.dart'; +import 'package:shorebird_cli/src/doctor.dart'; +import 'package:shorebird_cli/src/executables/executables.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'; + +typedef _LinkResult = ({int exitCode, double? linkPercentage}); + +/// {@template ios_patcher} +/// Functions to create an iOS patch. +/// {@endtemplate} +class IosPatcher extends Patcher { + /// {@macro ios_patcher} + IosPatcher({ + required super.argResults, + required super.flavor, + required super.target, + }); + + // Link percentage that is considered the minimum before a user might notice. + // Our early testing has shown that about: + // - 1/3rd of patches link at 99% + // - 1/3rd of patches link between 20% and 99% + // - 1/3rd of patches link below 20% + // Most lowering is likely due to: + // https://github.com/shorebirdtech/shorebird/issues/1825 + static const double minLinkPercentage = 75; + + static String lowLinkPercentageWarning(double linkPercentage) { + return ''' +${lightCyan.wrap('shorebird patch')} was only able to share ${linkPercentage.toStringAsFixed(1)}% of Dart code with the released app. +This means the patched code may execute slower than expected. +https://docs.shorebird.dev/status#link-percentage-ios +'''; + } + + String get _buildDirectory => p.join( + shorebirdEnv.getShorebirdProjectRoot()!.path, + 'build', + ); + + String get _aotOutputPath => p.join( + _buildDirectory, + 'out.aot', + ); + + String get _vmcodeOutputPath => p.join( + _buildDirectory, + 'out.vmcode', + ); + + static const _linkDebugInfoFileName = 'linker_diagnostic.zip'; + String get _debugInfoOutputPath => p.join( + _buildDirectory, + _linkDebugInfoFileName, + ); + + @visibleForTesting + double? lastBuildLinkPercentage; + + @override + ReleaseType get releaseType => ReleaseType.ios; + + @override + String get primaryReleaseArtifactArch => 'ipa'; + + @override + ArchiveDiffer get archiveDiffer => IosArchiveDiffer(); + + @override + Future assertPreconditions() async { + try { + await shorebirdValidator.validatePreconditions( + checkShorebirdInitialized: true, + checkUserIsAuthenticated: true, + validators: doctor.iosCommandValidators, + supportedOperatingSystems: {Platform.macOS}, + ); + } on PreconditionFailedException catch (error) { + exit(error.exitCode.code); + } + } + + @override + Future buildPatchArtifact() async { + final File exportOptionsPlist; + try { + exportOptionsPlist = ios.exportOptionsPlistFromArgs(argResults); + } catch (error) { + logger.err('$error'); + return exit(ExitCode.usage.code); + } + + try { + final shouldCodesign = argResults['codesign'] == true; + final flutterVersionString = + await shorebirdFlutter.getVersionAndRevision(); + final buildProgress = logger.progress( + 'Building patch with Flutter $flutterVersionString', + ); + try { + // If buildIpa is called with a different codesign value than the + // release was, we will erroneously report native diffs. + await artifactBuilder.buildIpa( + codesign: shouldCodesign, + exportOptionsPlist: exportOptionsPlist, + flavor: flavor, + target: target, + ); + } on ProcessException catch (error) { + buildProgress.fail('Failed to build: ${error.message}'); + rethrow; + } on ArtifactBuildException catch (error) { + buildProgress.fail('Failed to build IPA'); + logger.err(error.message); + rethrow; + } + + try { + final newestDillFile = artifactManager.newestAppDill(); + await artifactBuilder.buildElfAotSnapshot( + appDillPath: newestDillFile.path, + outFilePath: _aotOutputPath, + ); + } catch (error) { + buildProgress.fail('$error'); + rethrow; + } + + buildProgress.complete(); + } catch (_) { + return exit(ExitCode.software.code); + } + + return artifactManager.getXcarchiveDirectory()!.zipToTempFile(); + } + + @override + Future> createPatchArtifacts({ + required String appId, + required int releaseId, + }) async { + final archivePath = artifactManager.getXcarchiveDirectory()?.path; + if (archivePath == null) { + logger.err('Unable to find .xcarchive directory'); + return exit(ExitCode.software.code); + } + final releaseArtifact = await codePushClientWrapper.getReleaseArtifact( + appId: appId, + releaseId: releaseId, + arch: 'xcarchive', + platform: ReleasePlatform.ios, + ); + + final downloadProgress = logger.progress('Downloading release artifact'); + final File releaseArtifactZipFile; + try { + releaseArtifactZipFile = await artifactManager.downloadFile( + Uri.parse(releaseArtifact.url), + ); + if (!releaseArtifactZipFile.existsSync()) { + throw Exception('Failed to download release artifact'); + } + } catch (error) { + downloadProgress.fail('$error'); + return 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 releaseXcarchivePath = tempDir.path; + + unzipProgress.complete(); + final appDirectory = artifactManager.getIosAppDirectory( + xcarchiveDirectory: Directory(releaseXcarchivePath), + ); + if (appDirectory == null) { + logger.err('Unable to find release artifact .app directory'); + return exit(ExitCode.software.code); + } + final releaseArtifactFile = File( + p.join( + appDirectory.path, + 'Frameworks', + 'App.framework', + 'App', + ), + ); + + final useLinker = AotTools.usesLinker(shorebirdEnv.flutterRevision); + if (useLinker) { + final (:exitCode, :linkPercentage) = await _runLinker( + releaseArtifact: releaseArtifactFile, + ); + if (exitCode != ExitCode.success.code) return exit(exitCode); + if (linkPercentage != null && linkPercentage < minLinkPercentage) { + logger.warn(lowLinkPercentageWarning(linkPercentage)); + } + lastBuildLinkPercentage = linkPercentage; + } + + final patchBuildFile = File(useLinker ? _vmcodeOutputPath : _aotOutputPath); + + final File patchFile; + if (useLinker && 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'); + return exit(ExitCode.software.code); + } + + patchFile = File( + await artifactManager.createDiff( + releaseArtifactPath: patchBaseFile.path, + patchArtifactPath: patchBuildFile.path, + ), + ); + } else { + patchFile = patchBuildFile; + } + + final patchFileSize = patchFile.statSync().size; + return { + Arch.arm64: PatchArtifactBundle( + arch: 'aarch64', + path: patchFile.path, + hash: sha256.convert(patchBuildFile.readAsBytesSync()).toString(), + size: patchFileSize, + ), + }; + } + + @override + Future extractReleaseVersionFromArtifact(File artifact) async { + final archivePath = artifactManager.getXcarchiveDirectory()?.path; + if (archivePath == null) { + logger.err('Unable to find .xcarchive directory'); + exit(ExitCode.software.code); + } + + final plistFile = File(p.join(archivePath, 'Info.plist')); + if (!plistFile.existsSync()) { + logger.err('No Info.plist file found at ${plistFile.path}.'); + exit(ExitCode.software.code); + } + + final plist = Plist(file: plistFile); + try { + return plist.versionNumber; + } catch (error) { + logger.err( + 'Failed to determine release version from ${plistFile.path}: $error', + ); + exit(ExitCode.software.code); + } + } + + @override + Future 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<_LinkResult> _runLinker({ + required File releaseArtifact, + }) async { + final patch = File(_aotOutputPath); + final dumpDebugInfo = argResults['debug-linker'] == true && + (await aotTools.isLinkDebugInfoSupported()); + + if (!patch.existsSync()) { + logger.err('Unable to find patch AOT file at ${patch.path}'); + return (exitCode: ExitCode.software.code, linkPercentage: null); + } + + final analyzeSnapshot = File( + shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.analyzeSnapshot, + ), + ); + + if (!analyzeSnapshot.existsSync()) { + logger.err('Unable to find analyze_snapshot at ${analyzeSnapshot.path}'); + return (exitCode: ExitCode.software.code, linkPercentage: null); + } + + final genSnapshot = shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.genSnapshot, + ); + + final linkProgress = logger.progress('Linking AOT files'); + double? linkPercentage; + try { + final dumpDebugInfoDir = + dumpDebugInfo ? Directory.systemTemp.createTempSync() : null; + + linkPercentage = await aotTools.link( + base: releaseArtifact.path, + patch: patch.path, + analyzeSnapshot: analyzeSnapshot.path, + genSnapshot: genSnapshot, + outputPath: _vmcodeOutputPath, + workingDirectory: _buildDirectory, + kernel: artifactManager.newestAppDill().path, + dumpDebugInfoPath: dumpDebugInfoDir?.path, + ); + + if (dumpDebugInfo && dumpDebugInfoDir != null) { + final debugInfoZip = await dumpDebugInfoDir.zipToTempFile(); + debugInfoZip.copySync( + p.join( + 'build', + _debugInfoOutputPath, + ), + ); + } + } catch (error) { + linkProgress.fail('Failed to link AOT files: $error'); + return (exitCode: ExitCode.software.code, linkPercentage: null); + } + linkProgress.complete(); + return (exitCode: ExitCode.success.code, linkPercentage: linkPercentage); + } +} diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/patch_new.dart b/packages/shorebird_cli/lib/src/commands/patch_new/patch_new.dart index 41481282..f363b0ba 100644 --- a/packages/shorebird_cli/lib/src/commands/patch_new/patch_new.dart +++ b/packages/shorebird_cli/lib/src/commands/patch_new/patch_new.dart @@ -1,5 +1,6 @@ export 'aar_patcher.dart'; export 'android_patcher.dart'; export 'ios_framework_patcher.dart'; +export 'ios_patcher.dart'; export 'patch_new_command.dart'; export 'patcher.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patch_new/patch_new_command.dart b/packages/shorebird_cli/lib/src/commands/patch_new/patch_new_command.dart index 9376accd..37d3992c 100644 --- a/packages/shorebird_cli/lib/src/commands/patch_new/patch_new_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch_new/patch_new_command.dart @@ -136,7 +136,11 @@ NOTE: this is ${styleBold.wrap('not')} recommended. Asset changes cannot be incl target: target, ); case ReleaseType.ios: - throw UnimplementedError(); + return IosPatcher( + argResults: results, + flavor: flavor, + target: target, + ); case ReleaseType.iosFramework: return IosFrameworkPatcher( argResults: results, diff --git a/packages/shorebird_cli/test/src/commands/patch_new/ios_framework_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch_new/ios_framework_patcher_test.dart index 8a28997c..aaa8ae35 100644 --- a/packages/shorebird_cli/test/src/commands/patch_new/ios_framework_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch_new/ios_framework_patcher_test.dart @@ -148,21 +148,25 @@ void main() { group('assertPreconditions', () { setUp(() { - when(() => doctor.iosCommandValidators) - .thenReturn([flutterValidator]); + when( + () => doctor.iosCommandValidators, + ).thenReturn([flutterValidator]); }); group('when validation succeeds', () { setUp(() { when( () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: - any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: - any(named: 'checkShorebirdInitialized'), + checkUserIsAuthenticated: any( + named: 'checkUserIsAuthenticated', + ), + checkShorebirdInitialized: any( + named: 'checkShorebirdInitialized', + ), validators: any(named: 'validators'), - supportedOperatingSystems: - any(named: 'supportedOperatingSystems'), + supportedOperatingSystems: any( + named: 'supportedOperatingSystems', + ), ), ).thenAnswer((_) async {}); }); @@ -180,10 +184,12 @@ void main() { final exception = ValidationFailedException(); when( () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: - any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: - any(named: 'checkShorebirdInitialized'), + checkUserIsAuthenticated: any( + named: 'checkUserIsAuthenticated', + ), + checkShorebirdInitialized: any( + named: 'checkShorebirdInitialized', + ), validators: any(named: 'validators'), ), ).thenThrow(exception); @@ -520,7 +526,8 @@ void main() { setUp(() { when( () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot), + artifact: ShorebirdArtifact.analyzeSnapshot, + ), ).thenReturn(''); setUpProjectRootArtifacts(); }); @@ -581,8 +588,9 @@ void main() { group('when generate patch diff base is supported', () { setUp(() { - when(() => aotTools.isGeneratePatchDiffBaseSupported()) - .thenAnswer((_) async => true); + when( + () => aotTools.isGeneratePatchDiffBaseSupported(), + ).thenAnswer((_) async => true); when( () => aotTools.generatePatchDiffBase( analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), @@ -642,8 +650,11 @@ void main() { expect(patchBundle, hasLength(1)); expect( patchBundle[Arch.arm64], - isA() - .having((b) => b.path, 'path', endsWith(diffPath)), + isA().having( + (b) => b.path, + 'path', + endsWith(diffPath), + ), ); }); }); @@ -651,8 +662,9 @@ void main() { group('when generate patch diff base is not supported', () { setUp(() { - when(aotTools.isGeneratePatchDiffBaseSupported) - .thenAnswer((_) async => false); + when( + aotTools.isGeneratePatchDiffBaseSupported, + ).thenAnswer((_) async => false); setUpProjectRootArtifacts(); }); @@ -667,8 +679,11 @@ void main() { expect(patchBundle, hasLength(1)); expect( patchBundle[Arch.arm64], - isA() - .having((b) => b.path, 'path', endsWith('out.vmcode')), + isA().having( + (b) => b.path, + 'path', + endsWith('out.vmcode'), + ), ); }); }); @@ -676,10 +691,12 @@ void main() { group('when does not use linker', () { setUp(() { - when(() => shorebirdEnv.flutterRevision) - .thenReturn(preLinkerFlutterRevision); - when(() => aotTools.isGeneratePatchDiffBaseSupported()) - .thenAnswer((_) async => false); + when( + () => shorebirdEnv.flutterRevision, + ).thenReturn(preLinkerFlutterRevision); + when( + () => aotTools.isGeneratePatchDiffBaseSupported(), + ).thenAnswer((_) async => false); setUpProjectRootArtifacts(); }); @@ -724,8 +741,9 @@ void main() { const xcodeVersion = '11'; setUp(() { - when(() => argResults['allow-asset-diffs']) - .thenReturn(allowAssetDiffs); + when( + () => argResults['allow-asset-diffs'], + ).thenReturn(allowAssetDiffs); when( () => argResults['allow-native-diffs'], ).thenReturn(allowNativeDiffs); @@ -734,8 +752,9 @@ void main() { () => platform.operatingSystemVersion, ).thenReturn(operatingSystemVersion); - when(() => xcodeBuild.version()) - .thenAnswer((_) async => xcodeVersion); + when( + () => xcodeBuild.version(), + ).thenAnswer((_) async => xcodeVersion); }); group('when linker is not enabled', () { diff --git a/packages/shorebird_cli/test/src/commands/patch_new/ios_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch_new/ios_patcher_test.dart new file mode 100644 index 00000000..2d1e7d86 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patch_new/ios_patcher_test.dart @@ -0,0 +1,1206 @@ +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 '../../fakes.dart'; +import '../../matchers.dart'; +import '../../mocks.dart'; + +void main() { + group( + IosPatcher, + () { + 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 Ios ios; + late IosPatcher patcher; + + R runWithOverrides(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), + iosRef.overrideWith(() => ios), + 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(FakeArgResults()); + 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(); + ios = MockIos(); + 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); + + when(() => ios.exportOptionsPlistFromArgs(any())).thenReturn(File('')); + + patcher = IosPatcher( + argResults: argResults, + flavor: null, + target: null, + ); + }); + + group('archiveDiffer', () { + test('is an IosArchiveDiffer', () { + expect(patcher.archiveDiffer, isA()); + }); + }); + + group('primaryReleaseArtifactArch', () { + test('is "ipa"', () { + expect(patcher.primaryReleaseArtifactArch, 'ipa'); + }); + }); + + group('releaseType', () { + test('is ReleaseType.ios', () { + expect(patcher.releaseType, ReleaseType.ios); + }); + }); + + 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('buildPatchArtifact', () { + const flutterVersionAndRevision = '3.10.6 (83305b5088)'; + + setUp(() { + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterVersionAndRevision); + }); + + group('when exportOptionsPlist fails', () { + setUp(() { + when(() => ios.exportOptionsPlistFromArgs(any())).thenThrow( + const FileSystemException('error'), + ); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.usage), + ); + }); + }); + + group('when build fails with ProcessException', () { + setUp(() { + when( + () => artifactBuilder.buildIpa( + exportOptionsPlist: any(named: 'exportOptionsPlist'), + codesign: any(named: 'codesign'), + argResultsRest: any(named: 'argResultsRest'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + ), + ).thenThrow( + const ProcessException( + 'flutter', + ['build', 'ipa'], + 'Build failed', + ), + ); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail('Failed to build: Build failed')); + }); + }); + + group('when build fails with ArtifactBuildException', () { + setUp(() { + when( + () => artifactBuilder.buildIpa( + exportOptionsPlist: any(named: 'exportOptionsPlist'), + codesign: any(named: 'codesign'), + argResultsRest: any(named: 'argResultsRest'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + ), + ).thenThrow( + ArtifactBuildException('Build failed'), + ); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail('Failed to build IPA')); + }); + }); + + group('when elf aot snapshot build fails', () { + setUp(() { + when( + () => artifactBuilder.buildIpa( + exportOptionsPlist: any(named: 'exportOptionsPlist'), + codesign: any(named: 'codesign'), + argResultsRest: any(named: 'argResultsRest'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + ), + ).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.buildIpa( + exportOptionsPlist: any(named: 'exportOptionsPlist'), + codesign: any(named: 'codesign'), + argResultsRest: any(named: 'argResultsRest'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + ), + ).thenAnswer((_) async {}); + when(() => artifactManager.getXcarchiveDirectory()).thenReturn( + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'App.xcframework', + ), + )..createSync(recursive: true), + ); + 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), + ); + }); + + test('returns xcarchive zip', () async { + final artifact = await runWithOverrides(patcher.buildPatchArtifact); + expect(p.basename(artifact.path), endsWith('.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, + ); + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'App.xcframework', + 'Products', + 'Applications', + 'Runner.app', + ), + ).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(() => artifactManager.getXcarchiveDirectory()).thenReturn( + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'App.xcframework', + ), + ), + ); + when( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: any(named: 'xcarchiveDirectory'), + ), + ).thenReturn(projectRoot); + when(() => engineConfig.localEngine).thenReturn(null); + }); + + group('when xcarchive does not exist', () { + setUp(() { + when( + () => artifactManager.getXcarchiveDirectory(), + ).thenReturn(null); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + ), + ), + exitsWithCode(ExitCode.software), + ); + }); + }); + + 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 release artifact does not exist', () { + setUp(() { + when( + () => artifactManager.downloadFile(any()), + ).thenAnswer((_) async => File('')); + }); + + 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: Failed to download release artifact', + ), + ); + }); + }); + + 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'), + dumpDebugInfoPath: any(named: 'dumpDebugInfoPath'), + ), + ).thenAnswer((_) async => linkPercentage); + when(() => artifactManager.newestAppDill()).thenReturn(File('')); + when( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: any(named: 'xcarchiveDirectory'), + ), + ).thenReturn( + Directory(p.join(projectRoot.path, 'build', 'ios')), + ); + when( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: any(named: 'xcarchiveDirectory'), + ), + ).thenReturn(Directory(p.join(projectRoot.path, 'build', 'ios'))); + when( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: any(named: 'xcarchiveDirectory'), + ), + ).thenReturn( + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'App.xcframework', + 'Products', + 'Applications', + 'Runner.app', + ), + ), + ); + 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 .app does not exist', () { + setUp(() { + when( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: any(named: 'xcarchiveDirectory'), + ), + ).thenReturn(null); + }); + + 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 release artifact .app directory', + ), + ), + ), + ).called(1); + }); + }); + + 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'), + dumpDebugInfoPath: any(named: 'dumpDebugInfoPath'), + ), + ).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().having( + (b) => b.path, + 'path', + endsWith(diffPath), + ), + ); + }); + + group('when isLinkDebugInfoSupported', () { + setUp(() { + when(() => argResults['debug-linker']).thenReturn(true); + when( + aotTools.isLinkDebugInfoSupported, + ).thenAnswer((_) async => true); + }); + + test('dumps debug info', () async { + await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + ), + ); + verify( + () => 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'), + dumpDebugInfoPath: any(named: 'dumpDebugInfoPath'), + ), + ).called(1); + }); + }); + }); + }); + + 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().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', () { + setUp(() { + when(() => artifactManager.getXcarchiveDirectory()).thenReturn( + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'App.xcframework', + ), + ), + ); + }); + + group('when xcarchive directory does not exist', () { + setUp(() { + when( + () => artifactManager.getXcarchiveDirectory(), + ).thenReturn(null); + }); + + test('exit with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.extractReleaseVersionFromArtifact(File('')), + ), + exitsWithCode(ExitCode.software), + ); + }); + }); + + group('when Info.plist does not exist', () { + setUp(() { + try { + File( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'App.xcframework', + 'Info.plist', + ), + ).deleteSync(recursive: true); + } catch (_) {} + }); + + test('exit with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.extractReleaseVersionFromArtifact(File('')), + ), + exitsWithCode(ExitCode.software), + ); + }); + }); + + group('when empty Info.plist does exist', () { + setUp(() { + File( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'App.xcframework', + 'Info.plist', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync(''' + + + + + +'''); + }); + + test('exits with code 70 and logs error', () async { + await expectLater( + runWithOverrides( + () => patcher.extractReleaseVersionFromArtifact(File('')), + ), + exitsWithCode(ExitCode.software), + ); + verify( + () => logger.err( + any( + that: startsWith('Failed to determine release version'), + ), + ), + ).called(1); + }); + }); + + group('when Info.plist does exist', () { + setUp(() { + File( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'App.xcframework', + 'Info.plist', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync(''' + + + + + ApplicationProperties + + ApplicationPath + Applications/Runner.app + Architectures + + arm64 + + CFBundleIdentifier + com.shorebird.timeShift + CFBundleShortVersionString + 1.2.3 + CFBundleVersion + 1 + + ArchiveVersion + 2 + Name + Runner + SchemeName + Runner + + +'''); + }); + + test('returns correct version', () async { + await expectLater( + runWithOverrides( + () => patcher.extractReleaseVersionFromArtifact(File('')), + ), + completion('1.2.3+1'), + ); + }); + }); + }); + + 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', + ); +} diff --git a/packages/shorebird_cli/test/src/commands/patch_new/patch_new_command_test.dart b/packages/shorebird_cli/test/src/commands/patch_new/patch_new_command_test.dart index 721e6eef..748d0534 100644 --- a/packages/shorebird_cli/test/src/commands/patch_new/patch_new_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch_new/patch_new_command_test.dart @@ -280,8 +280,8 @@ void main() { isA(), ); expect( - () => command.getPatcher(ReleaseType.ios), - throwsA(isA()), + command.getPatcher(ReleaseType.ios), + isA(), ); expect( command.getPatcher(ReleaseType.iosFramework), diff --git a/packages/shorebird_cli/test/src/fakes.dart b/packages/shorebird_cli/test/src/fakes.dart index eb9d2b96..4f548c47 100644 --- a/packages/shorebird_cli/test/src/fakes.dart +++ b/packages/shorebird_cli/test/src/fakes.dart @@ -1,9 +1,12 @@ +import 'package:args/args.dart'; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; import 'package:shorebird_cli/src/patch_diff_checker.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +class FakeArgResults extends Fake implements ArgResults {} + class FakeBaseRequest extends Fake implements http.BaseRequest {} class FakeDiffStatus extends Fake implements DiffStatus {}