diff --git a/packages/shorebird_cli/lib/src/artifact_manager.dart b/packages/shorebird_cli/lib/src/artifact_manager.dart index 80d535249..35f7627de 100644 --- a/packages/shorebird_cli/lib/src/artifact_manager.dart +++ b/packages/shorebird_cli/lib/src/artifact_manager.dart @@ -264,6 +264,7 @@ class ArtifactManager { return archsDirectory.existsSync() ? archsDirectory : null; } + /// The directory containing the compiled macOS .app file, if it exists. Directory? getMacOSAppDirectory() { final projectRoot = shorebirdEnv.getShorebirdProjectRoot()!; @@ -346,12 +347,6 @@ class ArtifactManager { .firstWhereOrNull((directory) => directory.path.endsWith('.app')); } - Directory? getMacosAppDirectory({required Directory parentDirectory}) { - return parentDirectory.listSync().whereType().firstWhereOrNull( - (directory) => directory.path.endsWith('.app'), - ); - } - /// Returns the path to the .ipa file generated by `flutter build ipa`. /// /// Returns null if: diff --git a/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart b/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart index 0fd1a0c9e..13046d59f 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/macos_patcher.dart @@ -99,10 +99,10 @@ class MacosPatcher extends Patcher { ).wait; if ((flutterVersion ?? minimumSupportedMacosFlutterVersion) < - minimumSupportedIosFlutterVersion) { + minimumSupportedMacosFlutterVersion) { logger.err( ''' -macos patches are not supported with Flutter versions older than $minimumSupportedIosFlutterVersion. +macOS patches are not supported with Flutter versions older than $minimumSupportedMacosFlutterVersion. For more information see: ${supportedFlutterVersionsUrl.toLink()}''', ); throw ProcessExit(ExitCode.software.code); @@ -128,7 +128,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', buildProgress.fail('Failed to build: ${error.message}'); rethrow; } on ArtifactBuildException catch (error) { - buildProgress.fail('Failed to build macos app'); + buildProgress.fail('Failed to build macOS app'); logger.err(error.message); rethrow; } @@ -282,7 +282,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', if (useLinker && await aotTools.isGeneratePatchDiffBaseSupported()) { final patchBaseProgress = logger.progress('Generating patch diff base'); final analyzeSnapshotPath = shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshotMacos, + artifact: ShorebirdArtifact.analyzeSnapshotMacOS, ); final File patchBaseFile; @@ -369,7 +369,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', final analyzeSnapshot = File( shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshotMacos, + artifact: ShorebirdArtifact.analyzeSnapshotMacOS, ), ); diff --git a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart index 8474299d1..86d2c88d9 100644 --- a/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release/macos_releaser.dart @@ -109,7 +109,7 @@ For more information see: ${supportedFlutterVersionsUrl.toLink()}''', // This is to ensure that we don't accidentally upload stale artifacts // when building with older versions of Flutter. final shorebirdSupplementDir = - artifactManager.getIosReleaseSupplementDirectory(); + artifactManager.getMacosReleaseSupplementDirectory(); if (shorebirdSupplementDir?.existsSync() ?? false) { shorebirdSupplementDir!.deleteSync(recursive: true); } diff --git a/packages/shorebird_cli/lib/src/shorebird_artifacts.dart b/packages/shorebird_cli/lib/src/shorebird_artifacts.dart index 1780d76e0..d51c4c098 100644 --- a/packages/shorebird_cli/lib/src/shorebird_artifacts.dart +++ b/packages/shorebird_cli/lib/src/shorebird_artifacts.dart @@ -14,7 +14,7 @@ enum ShorebirdArtifact { analyzeSnapshotIos, /// The macOS analyze_snapshot executable. - analyzeSnapshotMacos, + analyzeSnapshotMacOS, /// The aot_tools executable or kernel file. aotTools, @@ -56,7 +56,7 @@ class ShorebirdCachedArtifacts implements ShorebirdArtifacts { switch (artifact) { case ShorebirdArtifact.analyzeSnapshotIos: return _analyzeSnapshotIosFile.path; - case ShorebirdArtifact.analyzeSnapshotMacos: + case ShorebirdArtifact.analyzeSnapshotMacOS: return _analyzeSnapshotMacosFile.path; case ShorebirdArtifact.aotTools: return _aotToolsFile.path; @@ -159,24 +159,37 @@ class ShorebirdLocalEngineArtifacts implements ShorebirdArtifacts { String getArtifactPath({required ShorebirdArtifact artifact}) { switch (artifact) { case ShorebirdArtifact.analyzeSnapshotIos: - case ShorebirdArtifact.analyzeSnapshotMacos: - return _analyzeSnapshotFile.path; + return _analyzeSnapshotIosFile.path; + case ShorebirdArtifact.analyzeSnapshotMacOS: + return _analyzeSnapshotMacosFile.path; case ShorebirdArtifact.aotTools: return _aotToolsFile.path; case ShorebirdArtifact.genSnapshotIos: + return _genSnapshotIosFile.path; case ShorebirdArtifact.genSnapshotMacOS: - return _genSnapshotFile.path; + return _genSnapshotMacosFile.path; } } - File get _analyzeSnapshotFile { + File get _analyzeSnapshotIosFile { + return File( + p.join( + engineConfig.localEngineSrcPath!, + 'out', + engineConfig.localEngine, + 'clang_x64', + 'analyze_snapshot_arm64', + ), + ); + } + + File get _analyzeSnapshotMacosFile { return File( p.join( engineConfig.localEngineSrcPath!, 'out', engineConfig.localEngine, 'clang_x64', - // 'analyze_snapshot_arm64', 'analyze_snapshot', ), ); @@ -197,14 +210,25 @@ class ShorebirdLocalEngineArtifacts implements ShorebirdArtifacts { ); } - File get _genSnapshotFile { + File get _genSnapshotIosFile { + return File( + p.join( + engineConfig.localEngineSrcPath!, + 'out', + engineConfig.localEngine, + 'clang_x64', + 'gen_snapshot_arm64', + ), + ); + } + + File get _genSnapshotMacosFile { return File( p.join( engineConfig.localEngineSrcPath!, 'out', engineConfig.localEngine, 'clang_x64', - // 'gen_snapshot_arm64', 'gen_snapshot', ), ); diff --git a/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart b/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart index e264b230b..01480a683 100644 --- a/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/macos_patcher_test.dart @@ -1,8 +1,1350 @@ +import 'dart:io'; + +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:pub_semver/pub_semver.dart'; +import 'package:scoped_deps/scoped_deps.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/patch.dart'; +import 'package:shorebird_cli/src/config/config.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/logging/shorebird_logger.dart'; +import 'package:shorebird_cli/src/metadata/metadata.dart'; +import 'package:shorebird_cli/src/os/operating_system_interface.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_documentation.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/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(MacosPatcher, () { - // TODO: Add tests - }); + group( + MacosPatcher, + () { + late AotTools aotTools; + late ArgParser argParser; + late ArgResults argResults; + late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; + late CodePushClientWrapper codePushClientWrapper; + // late CodeSigner codeSigner; + late Doctor doctor; + late EngineConfig engineConfig; + late Directory flutterDirectory; + late Directory projectRoot; + late ShorebirdLogger logger; + late OperatingSystemInterface operatingSystemInterface; + // late PatchDiffChecker patchDiffChecker; + 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 MacosPatcher patcher; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + aotToolsRef.overrideWith(() => aotTools), + artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + // codeSignerRef.overrideWith(() => codeSigner), + doctorRef.overrideWith(() => doctor), + engineConfigRef.overrideWith(() => engineConfig), + // iosRef.overrideWith(() => ios), + loggerRef.overrideWith(() => logger), + osInterfaceRef.overrideWith(() => operatingSystemInterface), + // patchDiffCheckerRef.overrideWith(() => patchDiffChecker), + 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.macos); + registerFallbackValue(ShorebirdArtifact.genSnapshotMacOS); + registerFallbackValue(Uri.parse('https://example.com')); + }); + + setUp(() { + aotTools = MockAotTools(); + argParser = MockArgParser(); + argResults = MockArgResults(); + artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); + codePushClientWrapper = MockCodePushClientWrapper(); + // codeSigner = MockCodeSigner(); + doctor = MockDoctor(); + engineConfig = MockEngineConfig(); + operatingSystemInterface = MockOperatingSystemInterface(); + // patchDiffChecker = MockPatchDiffChecker(); + progress = MockProgress(); + projectRoot = Directory.systemTemp.createTempSync(); + logger = MockShorebirdLogger(); + shorebirdArtifacts = MockShorebirdArtifacts(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdEnv = MockShorebirdEnv(); + flutterValidator = MockShorebirdFlutterValidator(); + shorebirdFlutter = MockShorebirdFlutter(); + shorebirdValidator = MockShorebirdValidator(); + xcodeBuild = MockXcodeBuild(); + + when(() => argParser.options).thenReturn({}); + + when(() => argResults.options).thenReturn([]); + when(() => argResults.rest).thenReturn([]); + when(() => argResults.wasParsed(any())).thenReturn(false); + + when(() => logger.progress(any())).thenReturn(progress); + + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + + when(aotTools.isLinkDebugInfoSupported).thenAnswer((_) async => false); + + patcher = MacosPatcher( + argParser: argParser, + argResults: argResults, + flavor: null, + target: null, + ); + }); + + group('primaryReleaseArtifactArch', () { + test('is "app"', () { + expect(patcher.primaryReleaseArtifactArch, 'app'); + }); + }); + + group('supplementaryReleaseArtifactArch', () { + test('is "macos_supplement"', () { + expect(patcher.supplementaryReleaseArtifactArch, 'macos_supplement'); + }); + }); + + group('releaseType', () { + test('is ReleaseType.macos', () { + expect(patcher.releaseType, ReleaseType.macos); + }); + }); + + group('linkPercentage', () {}); + + group('assertPreconditions', () { + setUp(() { + when( + () => doctor.macosCommandValidators, + ).thenReturn([flutterValidator]); + }); + }); + + group('assertUnpatchableDiffs', () {}); + + group('buildPatchArtifact', () { + const flutterVersionAndRevision = '3.24.5 (83305b5088)'; + + setUp(() { + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterVersionAndRevision); + when( + () => shorebirdFlutter.getVersion(), + ).thenAnswer((_) async => Version(3, 24, 5)); + }); + + group('when specified flutter version is less than minimum', () { + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any( + named: 'checkUserIsAuthenticated', + ), + checkShorebirdInitialized: any( + named: 'checkShorebirdInitialized', + ), + validators: any(named: 'validators'), + supportedOperatingSystems: any( + named: 'supportedOperatingSystems', + ), + ), + ).thenAnswer((_) async {}); + when( + () => shorebirdFlutter.getVersion(), + ).thenAnswer((_) async => Version(3, 0, 0)); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + ''' +macOS patches are not supported with Flutter versions older than $minimumSupportedMacosFlutterVersion. +For more information see: ${supportedFlutterVersionsUrl.toLink()}''', + ), + ).called(1); + }); + }); + + group('when build fails with ProcessException', () { + setUp(() { + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: any(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenThrow( + const ProcessException( + 'flutter', + ['build', 'macos'], + '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.buildMacos( + codesign: any(named: 'codesign'), + args: any(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenThrow( + ArtifactBuildException('Build failed'), + ); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides(patcher.buildPatchArtifact), + exitsWithCode(ExitCode.software), + ); + + verify(() => progress.fail('Failed to build macOS app')); + }); + }); + + group('when elf aot snapshot build fails', () { + setUp(() { + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: any(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenAnswer( + (_) async => + IpaBuildResult(kernelFile: File('/path/to/app.dill')), + ); + when( + () => artifactBuilder.buildElfAotSnapshot( + appDillPath: any(named: 'appDillPath'), + outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), + ), + ).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', () { + late File kernelFile; + setUp(() { + kernelFile = File( + p.join(Directory.systemTemp.createTempSync().path, 'app.dill'), + )..createSync(recursive: true); + when( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: any(named: 'args'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + base64PublicKey: any(named: 'base64PublicKey'), + buildProgress: any(named: 'buildProgress'), + ), + ).thenAnswer( + (_) async => IpaBuildResult(kernelFile: kernelFile), + ); + when(() => artifactManager.getMacOSAppDirectory()).thenReturn( + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'App.xcframework', + ), + )..createSync(recursive: true), + ); + when( + () => artifactBuilder.buildElfAotSnapshot( + appDillPath: any(named: 'appDillPath'), + outFilePath: any(named: 'outFilePath'), + genSnapshotArtifact: any(named: 'genSnapshotArtifact'), + additionalArgs: any(named: 'additionalArgs'), + ), + ).thenAnswer( + (invocation) async => + File(invocation.namedArguments[#outFilePath] as String) + ..createSync(recursive: true), + ); + }); + + group('when releaseVersion is provided', () { + test('forwards --build-name and --build-number to builder', + () async { + await runWithOverrides( + () => patcher.buildPatchArtifact(releaseVersion: '1.2.3+4'), + ); + verify( + () => artifactBuilder.buildMacos( + flavor: any(named: 'flavor'), + codesign: any(named: 'codesign'), + target: any(named: 'target'), + args: any( + named: 'args', + that: containsAll( + ['--build-name=1.2.3', '--build-number=4'], + ), + ), + buildProgress: any(named: 'buildProgress'), + ), + ).called(1); + }); + }); + + group('when platform was specified via arg results rest', () { + setUp(() { + when(() => argResults.rest).thenReturn(['ios', '--verbose']); + }); + + test('returns app zip', () async { + final artifact = await runWithOverrides( + patcher.buildPatchArtifact, + ); + expect(p.basename(artifact.path), endsWith('.zip')); + verify( + () => artifactBuilder.buildMacos( + codesign: any(named: 'codesign'), + args: ['--verbose'], + buildProgress: any(named: 'buildProgress'), + ), + ).called(1); + }); + }); + + test('returns app zip', () async { + final artifact = await runWithOverrides(patcher.buildPatchArtifact); + expect(p.basename(artifact.path), endsWith('.zip')); + }); + + test('copies app.dill to build directory', () async { + final copiedKernelFile = File( + p.join( + projectRoot.path, + 'build', + 'app.dill', + ), + ); + expect(copiedKernelFile.existsSync(), isFalse); + await runWithOverrides(patcher.buildPatchArtifact); + expect(copiedKernelFile.existsSync(), isTrue); + }); + }); + }); + + group('createPatchArtifacts', () { + const postLinkerFlutterRevision = // cspell: disable-next-line + '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.macos, + hash: '#', + size: 42, + url: 'https://example.com', + podfileLockHash: 'podfile-lock-hash', + canSideload: true, + ); + late File releaseArtifactFile; + late File supplementArtifactFile; + + void setUpProjectRootArtifacts() { + File( + p.join(projectRoot.path, 'build', elfAotSnapshotFileName), + ).createSync(recursive: true); + File( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + ), + ).createSync(recursive: true); + File( + p.join( + projectRoot.path, + 'build', + 'macos', + 'shorebird', + 'App.ct.link', + ), + ).createSync(recursive: true); + File( + p.join( + projectRoot.path, + 'build', + 'macos', + 'shorebird', + 'App.class_table.json', + ), + ).createSync(recursive: true); + File( + p.join(projectRoot.path, 'build', linkFileName), + ).createSync(recursive: true); + } + + setUp(() { + releaseArtifactFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'release.xcarchive', + ), + )..createSync(recursive: true); + supplementArtifactFile = File( + p.join( + Directory.systemTemp.createTempSync().path, + 'macos_supplement.zip', + ), + )..createSync(recursive: true); + + 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.getMacOSAppDirectory()).thenReturn( + Directory( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + ), + ), + ); + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn(projectRoot); + when(() => engineConfig.localEngine).thenReturn(null); + }); + + group('when patch .app does not exist', () { + setUp(() { + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn(null); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + ), + ), + 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', + 'darwin-x64-release', + 'gen_snapshot', + ), + ); + analyzeSnapshotFile = File( + p.join( + flutterDirectory.path, + 'bin', + 'cache', + 'artifacts', + 'engine', + 'darwin-x64-release', + 'analyze_snapshot', + ), + )..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'), + additionalArgs: any(named: 'additionalArgs'), + ), + ).thenAnswer((_) async => linkPercentage); + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn( + Directory( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + ), + ), + ); + when( + () => shorebirdEnv.flutterRevision, + ).thenReturn(postLinkerFlutterRevision); + when( + () => shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.analyzeSnapshotMacOS, + ), + ).thenReturn(analyzeSnapshotFile.path); + when( + () => shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.genSnapshotMacOS, + ), + ).thenReturn(genSnapshotFile.path); + }); + + group('when linking fails', () { + group('when .app does not exist', () { + setUp(() { + when( + () => artifactManager.getMacOSAppDirectory(), + ).thenReturn(null); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: startsWith( + 'Unable to find .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, + releaseArtifact: releaseArtifactFile, + ), + ), + 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.analyzeSnapshotMacOS, + ), + ).thenReturn(''); + setUpProjectRootArtifacts(); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + ), + ), + 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'), + additionalArgs: any(named: 'additionalArgs'), + ), + ).thenThrow(Exception('oops')); + + setUpProjectRootArtifacts(); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + ), + ), + 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, + releaseArtifact: releaseArtifactFile, + ), + ), + 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, + releaseArtifact: releaseArtifactFile, + ), + ); + + expect(patchBundle, hasLength(1)); + expect( + patchBundle[Arch.arm64], + isA().having( + (b) => b.path, + 'path', + endsWith(diffPath), + ), + ); + }); + + group('when class table link info is not present', () { + setUp(() { + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async {}); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + 'Unable to find class table link info file', + ), + ).called(1); + }); + }); + + group('when debug info is missing', () { + setUp(() { + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outDir = invocation.namedArguments[#outputDirectory] + as Directory; + File( + p.join(outDir.path, 'App.ct.link'), + ).createSync(recursive: true); + }); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + 'Unable to find class table link debug info file', + ), + ).called(1); + }); + }); + + group('when class table link info & debug info are present', () { + setUp(() { + when( + () => artifactManager.extractZip( + zipFile: supplementArtifactFile, + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outDir = invocation.namedArguments[#outputDirectory] + as Directory; + File( + p.join(outDir.path, 'App.ct.link'), + ).createSync(recursive: true); + File( + p.join(outDir.path, 'App.class_table.json'), + ).createSync(recursive: true); + }); + }); + + test('returns linked patch artifact in patch bundle', () async { + final patchBundle = await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + supplementArtifact: supplementArtifactFile, + ), + ); + + expect(patchBundle, hasLength(1)); + expect( + patchBundle[Arch.arm64], + isA().having( + (b) => b.path, + 'path', + endsWith(diffPath), + ), + ); + }); + }); + + group('when isLinkDebugInfoSupported is true', () { + setUp(() { + when( + aotTools.isLinkDebugInfoSupported, + ).thenAnswer((_) async => true); + }); + + test('dumps debug info', () async { + await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + ), + ); + 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', + that: isNotNull, + ), + ), + ).called(1); + verify( + () => logger.detail( + any( + that: contains( + 'Link debug info saved to', + ), + ), + ), + ).called(1); + }); + + group('when aot_tools 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', + that: isNotNull, + ), + ), + ).thenThrow(Exception('oops')); + }); + + test('dumps debug info and logs', () async { + await expectLater( + () => runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + ), + ), + exitsWithCode(ExitCode.software), + ); + verify( + () => logger.detail( + any( + that: contains( + 'Link debug info saved to', + ), + ), + ), + ).called(1); + }); + }); + }); + + group('when isLinkDebugInfoSupported is false', () { + setUp(() { + when(aotTools.isLinkDebugInfoSupported) + .thenAnswer((_) async => false); + }); + + test('does not pass dumpDebugInfoPath to aotTools.link', + () async { + await runWithOverrides( + () => patcher.createPatchArtifacts( + appId: appId, + releaseId: releaseId, + releaseArtifact: releaseArtifactFile, + ), + ); + 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'), + // ignore: avoid_redundant_argument_values + dumpDebugInfoPath: null, + ), + ).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, + releaseArtifact: releaseArtifactFile, + ), + ); + + 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, + releaseArtifact: releaseArtifactFile, + ), + ); + + 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.getMacOSAppDirectory()).thenReturn( + Directory( + p.join( + projectRoot.path, + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + ), + ), + ); + }); + + 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', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + 'Contents', + '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', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + 'Contents', + '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', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + 'Contents', + '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('updatedCreatePatchMetadata', () { + const allowAssetDiffs = false; + const allowNativeDiffs = true; + const flutterRevision = '853d13d954df3b6e9c2f07b72062f33c52a9a64b'; + const operatingSystem = 'Mac OS X'; + const operatingSystemVersion = '10.15.7'; + const xcodeVersion = '11'; + + setUp(() { + when( + () => xcodeBuild.version(), + ).thenAnswer((_) async => xcodeVersion); + }); + + const linkPercentage = 100.0; + + setUp(() { + patcher.lastBuildLinkPercentage = linkPercentage; + }); + + test('returns correct metadata', () async { + const metadata = CreatePatchMetadata( + releasePlatform: ReleasePlatform.macos, + usedIgnoreAssetChangesFlag: allowAssetDiffs, + hasAssetChanges: true, + usedIgnoreNativeChangesFlag: allowNativeDiffs, + hasNativeChanges: false, + environment: BuildEnvironmentMetadata( + flutterRevision: flutterRevision, + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + shorebirdYaml: ShorebirdYaml(appId: 'app-id'), + ), + ); + + expect( + runWithOverrides( + () => patcher.updatedCreatePatchMetadata(metadata), + ), + completion( + const CreatePatchMetadata( + releasePlatform: ReleasePlatform.macos, + usedIgnoreAssetChangesFlag: allowAssetDiffs, + hasAssetChanges: true, + usedIgnoreNativeChangesFlag: allowNativeDiffs, + hasNativeChanges: false, + linkPercentage: linkPercentage, + environment: BuildEnvironmentMetadata( + flutterRevision: flutterRevision, + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + xcodeVersion: xcodeVersion, + shorebirdYaml: ShorebirdYaml(appId: 'app-id'), + ), + ), + ), + ); + }); + }); + }, + testOn: 'mac-os', + ); }