From 36df2192ac46fa191e8f0dd990c835942c9a9ea2 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Mon, 8 Apr 2024 15:54:10 -0400 Subject: [PATCH] chore(shorebird_cli): run CI on Windows and Linux (#1873) --- .github/actions/dart_package/action.yaml | 6 - .github/workflows/main.yaml | 38 +- codecov.yml | 9 + .../archive_analysis/ios_archive_differ.dart | 2 - .../patch/patch_ios_command_test.dart | 2693 +++++++++-------- .../patch_ios_framework_command_test.dart | 2163 ++++++------- .../release/release_ios_command_test.dart | 1764 +++++------ .../release_ios_framework_command_test.dart | 1012 ++++--- .../test/src/executables/aot_tools_test.dart | 14 +- .../test/src/executables/java_test.dart | 64 +- .../test/src/shorebird_process_test.dart | 4 +- ...id_internet_permission_validator_test.dart | 12 +- 12 files changed, 3935 insertions(+), 3846 deletions(-) diff --git a/.github/actions/dart_package/action.yaml b/.github/actions/dart_package/action.yaml index b9c37560..bcdc5f4a 100644 --- a/.github/actions/dart_package/action.yaml +++ b/.github/actions/dart_package/action.yaml @@ -80,9 +80,3 @@ runs: with: flags: ${{ steps.split.outputs.package_name }} token: ${{ inputs.codecov_token }} - - - uses: VeryGoodOpenSource/very_good_coverage@v2 - with: - path: ${{inputs.working_directory}}/coverage/lcov.info - exclude: ${{inputs.coverage_excludes}} - min_coverage: ${{inputs.min_coverage}} diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index e2441b50..d5d8917e 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -19,6 +19,7 @@ jobs: runs-on: ubuntu-latest outputs: + needs_cross_platform_dart_build: ${{ steps.needs_cross_platform_dart_build.outputs.changes }} needs_dart_build: ${{ steps.needs_dart_build.outputs.changes }} needs_redis_build: ${{ steps.needs_redis_build.outputs.changes }} needs_verify: ${{ steps.needs_verify.outputs.changes }} @@ -44,6 +45,12 @@ jobs: - ./.github/workflows/main.yaml - ./.github/actions/dart_package/action.yaml - packages/discord_gcp_alerts/** + + - uses: dorny/paths-filter@v3 + name: Build Detection + id: needs_cross_platform_dart_build + with: + filters: | shorebird_cli: - ./.github/codecov.yml - ./.github/workflows/main.yaml @@ -122,6 +129,29 @@ jobs: codecov_token: ${{ secrets.CODECOV_TOKEN }} working_directory: packages/${{ matrix.package }} + build_cross_platform_dart_packages: + needs: changes + if: ${{ needs.changes.outputs.needs_cross_platform_dart_build != '[]' }} + + strategy: + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + package: ${{ fromJSON(needs.changes.outputs.needs_cross_platform_dart_build) }} + + runs-on: ${{ matrix.os }} + + name: 🎯 Build ${{ matrix.package }} (${{ matrix.os }}) + + steps: + - name: 📚 Git Checkout + uses: actions/checkout@v4 + + - name: 🎯 Build ${{ matrix.package }} + uses: ./.github/actions/dart_package + with: + codecov_token: ${{ secrets.CODECOV_TOKEN }} + working_directory: packages/${{ matrix.package }} + build_redis: needs: changes if: ${{ needs.changes.outputs.needs_redis_build != '[]' }} @@ -174,7 +204,13 @@ jobs: ci: needs: - [semantic_pull_request, build_dart_packages, build_redis, verify_packages] + [ + semantic_pull_request, + build_cross_platform_dart_packages, + build_dart_packages, + build_redis, + verify_packages, + ] if: ${{ always() }} runs-on: ubuntu-latest diff --git a/codecov.yml b/codecov.yml index 79d1071e..3d396f21 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,6 +2,15 @@ # curl -X POST --data-binary @codecov.yml https://codecov.io/validate # See https://docs.codecov.com/docs/flags +coverage: + status: + project: + default: + target: 100% + patch: + default: + target: 100% + # Packages in third_party don't have 100% coverage, which shouldn't prevent # us from landing. Ideally, this would be configured to allow a non-100% base # and enforce that all changes have 100% coverage of affected lines. diff --git a/packages/shorebird_cli/lib/src/archive_analysis/ios_archive_differ.dart b/packages/shorebird_cli/lib/src/archive_analysis/ios_archive_differ.dart index d7162910..27834834 100644 --- a/packages/shorebird_cli/lib/src/archive_analysis/ios_archive_differ.dart +++ b/packages/shorebird_cli/lib/src/archive_analysis/ios_archive_differ.dart @@ -137,9 +137,7 @@ class IosArchiveDiffer extends ArchiveDiffer { final assetInfoPath = '$outPath.json'; if (Platform.isMacOS) { - // coverage:ignore-start Process.runSync('assetutil', ['--info', outPath, '-o', assetInfoPath]); - // coverage:ignore-end } else { // This is just for testing File(assetInfoPath).createSync(recursive: true); diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart index 2309904e..ce2db7a1 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart @@ -144,448 +144,450 @@ flutter: updatedAt: DateTime(2023), ); - group(PatchIosCommand, () { - late AotTools aotTools; - late ArgResults argResults; - late ArtifactManager artifactManager; - late Auth auth; - late CodePushClientWrapper codePushClientWrapper; - late Directory flutterDirectory; - late Directory shorebirdRoot; - late Directory projectRoot; - late EngineConfig engineConfig; - late File genSnapshotFile; - late File analyzeSnapshotFile; - late File releaseArtifactFile; - late ShorebirdArtifacts shorebirdArtifacts; - late Doctor doctor; - late Ios ios; - late IosArchiveDiffer archiveDiffer; - late Progress progress; - late Logger logger; - late OperatingSystemInterface operatingSystemInterface; - late PatchDiffChecker patchDiffChecker; - late Platform platform; - late ShorebirdProcessResult aotBuildProcessResult; - late ShorebirdProcessResult flutterBuildProcessResult; - late ShorebirdProcessResult flutterPubGetProcessResult; - late http.Client httpClient; - late ShorebirdEnv shorebirdEnv; - late ShorebirdFlutter shorebirdFlutter; - late ShorebirdFlutterValidator flutterValidator; - late ShorebirdProcess shorebirdProcess; - late ShorebirdValidator shorebirdValidator; - late XcodeBuild xcodeBuild; - late PatchIosCommand command; + group( + PatchIosCommand, + () { + late AotTools aotTools; + late ArgResults argResults; + late ArtifactManager artifactManager; + late Auth auth; + late CodePushClientWrapper codePushClientWrapper; + late Directory flutterDirectory; + late Directory shorebirdRoot; + late Directory projectRoot; + late EngineConfig engineConfig; + late File genSnapshotFile; + late File analyzeSnapshotFile; + late File releaseArtifactFile; + late ShorebirdArtifacts shorebirdArtifacts; + late Doctor doctor; + late Ios ios; + late IosArchiveDiffer archiveDiffer; + late Progress progress; + late Logger logger; + late OperatingSystemInterface operatingSystemInterface; + late PatchDiffChecker patchDiffChecker; + late Platform platform; + late ShorebirdProcessResult aotBuildProcessResult; + late ShorebirdProcessResult flutterBuildProcessResult; + late ShorebirdProcessResult flutterPubGetProcessResult; + late http.Client httpClient; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + late ShorebirdValidator shorebirdValidator; + late XcodeBuild xcodeBuild; + late PatchIosCommand command; - R runWithOverrides(R Function() body) { - return runScoped( - body, - values: { - aotToolsRef.overrideWith(() => aotTools), - artifactManagerRef.overrideWith(() => artifactManager), - authRef.overrideWith(() => auth), - codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), - doctorRef.overrideWith(() => doctor), - engineConfigRef.overrideWith(() => engineConfig), - iosRef.overrideWith(() => ios), - shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), - loggerRef.overrideWith(() => logger), - osInterfaceRef.overrideWith(() => operatingSystemInterface), - patchDiffCheckerRef.overrideWith(() => patchDiffChecker), - platformRef.overrideWith(() => platform), - processRef.overrideWith(() => shorebirdProcess), - shorebirdEnvRef.overrideWith(() => shorebirdEnv), - shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), - shorebirdValidatorRef.overrideWith(() => shorebirdValidator), - xcodeBuildRef.overrideWith(() => xcodeBuild), - }, - ); - } + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + aotToolsRef.overrideWith(() => aotTools), + artifactManagerRef.overrideWith(() => artifactManager), + authRef.overrideWith(() => auth), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + doctorRef.overrideWith(() => doctor), + engineConfigRef.overrideWith(() => engineConfig), + iosRef.overrideWith(() => ios), + shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), + loggerRef.overrideWith(() => logger), + osInterfaceRef.overrideWith(() => operatingSystemInterface), + patchDiffCheckerRef.overrideWith(() => patchDiffChecker), + platformRef.overrideWith(() => platform), + processRef.overrideWith(() => shorebirdProcess), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + xcodeBuildRef.overrideWith(() => xcodeBuild), + }, + ); + } - void setUpProjectRoot() { - File( - p.join(projectRoot.path, 'pubspec.yaml'), - ).writeAsStringSync(pubspecYamlContent); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync('app_id: $appId'); - File( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Info.plist', - ), - ) - ..createSync(recursive: true) - ..writeAsStringSync(infoPlistContent); - File( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Products', - 'Applications', - 'Runner.app', - 'Frameworks', - 'App.framework', - 'App', - ), - ).createSync(recursive: true); - File(p.join(projectRoot.path, ipaPath)).createSync(recursive: true); - } - - 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); - File( - p.join(projectRoot.path, 'build', linkFileName), - ).createSync(recursive: true); - } - - setUpAll(() { - registerFallbackValue(CreatePatchMetadata.forTest()); - registerFallbackValue(Directory('')); - registerFallbackValue(File('')); - registerFallbackValue(FileSetDiff.empty()); - registerFallbackValue(ReleasePlatform.ios); - registerFallbackValue(Uri.parse('https://example.com')); - registerFallbackValue(FakeBaseRequest()); - registerFallbackValue(FakeShorebirdProcess()); - registerFallbackValue(DeploymentTrack.production); - }); - - setUp(() { - argResults = MockArgResults(); - artifactManager = MockArtifactManager(); - aotTools = MockAotTools(); - auth = MockAuth(); - codePushClientWrapper = MockCodePushClientWrapper(); - doctor = MockDoctor(); - engineConfig = MockEngineConfig(); - ios = MockIos(); - shorebirdArtifacts = MockShorebirdArtifacts(); - shorebirdRoot = Directory.systemTemp.createTempSync(); - projectRoot = 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', - 'android-arm-release', - 'darwin-x64', - 'analyze_snapshot', - ), - )..createSync(recursive: true); - releaseArtifactFile = - File(p.join(projectRoot.path, releaseArtifactFilePath)) - ..createSync(recursive: true); - archiveDiffer = MockIosArchiveDiffer(); - progress = MockProgress(); - logger = MockLogger(); - platform = MockPlatform(); - aotBuildProcessResult = MockProcessResult(); - flutterBuildProcessResult = MockProcessResult(); - flutterPubGetProcessResult = MockProcessResult(); - httpClient = MockHttpClient(); - operatingSystemInterface = MockOperatingSystemInterface(); - patchDiffChecker = MockPatchDiffChecker(); - shorebirdEnv = MockShorebirdEnv(); - shorebirdFlutter = MockShorebirdFlutter(); - flutterValidator = MockShorebirdFlutterValidator(); - shorebirdProcess = MockShorebirdProcess(); - shorebirdValidator = MockShorebirdValidator(); - xcodeBuild = MockXcodeBuild(); - - when(() => argResults['allow-asset-diffs']).thenReturn(false); - when(() => argResults['allow-native-diffs']).thenReturn(false); - when(() => argResults['arch']).thenReturn(arch); - when(() => argResults['dry-run']).thenReturn(false); - when(() => argResults['codesign']).thenReturn(true); - when(() => argResults['staging']).thenReturn(false); - when(() => argResults.rest).thenReturn([]); - when(() => argResults.wasParsed(any())).thenReturn(true); - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenAnswer((_) async => null); - when(() => aotTools.isGeneratePatchDiffBaseSupported()) - .thenAnswer((_) async => false); - when( - () => aotTools.generatePatchDiffBase( - releaseSnapshot: any(named: 'releaseSnapshot'), - analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), - ), - ).thenAnswer((_) async => File('')); - when(() => artifactManager.downloadFile(any())) - .thenAnswer((_) async => releaseArtifactFile); - when( - () => artifactManager.extractZip( - zipFile: any(named: 'zipFile'), - outputDirectory: any(named: 'outputDirectory'), - ), - ).thenAnswer((invocation) async { - final outputDirectory = - invocation.namedArguments[#outputDirectory] as Directory; + void setUpProjectRoot() { + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File( + p.join(projectRoot.path, 'shorebird.yaml'), + ).writeAsStringSync('app_id: $appId'); File( p.join( - outputDirectory.path, + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + 'Info.plist', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync(infoPlistContent); + File( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', 'Products', 'Applications', - 'App.app', + 'Runner.app', 'Frameworks', 'App.framework', 'App', ), ).createSync(recursive: true); + File(p.join(projectRoot.path, ipaPath)).createSync(recursive: true); + } + + 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); + File( + p.join(projectRoot.path, 'build', linkFileName), + ).createSync(recursive: true); + } + + setUpAll(() { + registerFallbackValue(CreatePatchMetadata.forTest()); + registerFallbackValue(Directory('')); + registerFallbackValue(File('')); + registerFallbackValue(FileSetDiff.empty()); + registerFallbackValue(ReleasePlatform.ios); + registerFallbackValue(Uri.parse('https://example.com')); + registerFallbackValue(FakeBaseRequest()); + registerFallbackValue(FakeShorebirdProcess()); + registerFallbackValue(DeploymentTrack.production); }); - when( - () => artifactManager.createDiff( - releaseArtifactPath: any(named: 'releaseArtifactPath'), - patchArtifactPath: any(named: 'patchArtifactPath'), - ), - ).thenAnswer((_) async => ''); - when(() => auth.isAuthenticated).thenReturn(true); - when(() => auth.client).thenReturn(httpClient); - when( - () => codePushClientWrapper.getApp(appId: any(named: 'appId')), - ).thenAnswer((_) async => appMetadata); - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => preLinkerRelease); - when( - () => codePushClientWrapper.getReleaseArtifact( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - arch: any(named: 'arch'), - platform: any(named: 'platform'), - ), - ).thenAnswer((_) async => ipaArtifact); - when( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).thenAnswer((_) async {}); - when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]); - when(() => engineConfig.localEngine).thenReturn(null); - when( - () => ios.exportOptionsPlistFromArgs(argResults), - ).thenReturn(File('.')); - when(flutterValidator.validate).thenAnswer((_) async => []); - when(() => logger.confirm(any())).thenReturn(true); - when(() => logger.progress(any())).thenReturn(progress); - when( - () => operatingSystemInterface.which('flutter'), - ).thenReturn('/path/to/flutter'); - when(() => platform.operatingSystem).thenReturn(operatingSystem); - when(() => platform.operatingSystemVersion) - .thenReturn(operatingSystemVersion); - when(() => platform.environment).thenReturn({}); - when(() => platform.script).thenReturn(shorebirdRoot.uri); - when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); - when( - () => shorebirdEnv.copyWith( - flutterRevisionOverride: any(named: 'flutterRevisionOverride'), - ), - ).thenAnswer((invocation) { - when(() => shorebirdEnv.flutterRevision).thenReturn( - invocation.namedArguments[#flutterRevisionOverride] as String, - ); - return shorebirdEnv; - }); - when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); - when( - () => shorebirdEnv.getShorebirdProjectRoot(), - ).thenReturn(projectRoot); - when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory); - when( - () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, - ), - ).thenReturn(genSnapshotFile.path); - when( - () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, - ), - ).thenReturn(analyzeSnapshotFile.path); - when(() => shorebirdEnv.flutterRevision) - .thenReturn(preLinkerFlutterRevision); - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenAnswer((_) async {}); - when( - () => aotBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when( - () => flutterBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when(() => flutterPubGetProcessResult.exitCode) - .thenReturn(ExitCode.success.code); - when( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).thenAnswer((_) async => flutterPubGetProcessResult); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - when( - () => shorebirdProcess.run( - any(that: endsWith('gen_snapshot_arm64')), - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => aotBuildProcessResult); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - validators: any(named: 'validators'), - supportedOperatingSystems: any(named: 'supportedOperatingSystems'), - ), - ).thenAnswer((_) async {}); - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: false, - hasNativeChanges: false, - ), - ); - when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion); - command = runWithOverrides( - () => PatchIosCommand(archiveDiffer: archiveDiffer), - )..testArgResults = argResults; - }); - - test('supports alpha alias', () { - expect(command.aliases, contains('ios-alpha')); - }); - - test('has a description', () { - expect(command.description, isNotEmpty); - }); - - test('exits when validation fails', () 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(command.run), - completion(equals(exception.exitCode.code)), - ); - verify( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - validators: [flutterValidator], - supportedOperatingSystems: {Platform.macOS}, - ), - ).called(1); - }); - - group('when exportOptionsPlistFromArgs throws exception', () { setUp(() { - when(() => ios.exportOptionsPlistFromArgs(argResults)) - .thenThrow(ArgumentError('bad args')); + argResults = MockArgResults(); + artifactManager = MockArtifactManager(); + aotTools = MockAotTools(); + auth = MockAuth(); + codePushClientWrapper = MockCodePushClientWrapper(); + doctor = MockDoctor(); + engineConfig = MockEngineConfig(); + ios = MockIos(); + shorebirdArtifacts = MockShorebirdArtifacts(); + shorebirdRoot = Directory.systemTemp.createTempSync(); + projectRoot = 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', + 'android-arm-release', + 'darwin-x64', + 'analyze_snapshot', + ), + )..createSync(recursive: true); + releaseArtifactFile = + File(p.join(projectRoot.path, releaseArtifactFilePath)) + ..createSync(recursive: true); + archiveDiffer = MockIosArchiveDiffer(); + progress = MockProgress(); + logger = MockLogger(); + platform = MockPlatform(); + aotBuildProcessResult = MockProcessResult(); + flutterBuildProcessResult = MockProcessResult(); + flutterPubGetProcessResult = MockProcessResult(); + httpClient = MockHttpClient(); + operatingSystemInterface = MockOperatingSystemInterface(); + patchDiffChecker = MockPatchDiffChecker(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); + flutterValidator = MockShorebirdFlutterValidator(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdValidator = MockShorebirdValidator(); + xcodeBuild = MockXcodeBuild(); + + when(() => argResults['allow-asset-diffs']).thenReturn(false); + when(() => argResults['allow-native-diffs']).thenReturn(false); + when(() => argResults['arch']).thenReturn(arch); + when(() => argResults['dry-run']).thenReturn(false); + when(() => argResults['codesign']).thenReturn(true); + when(() => argResults['staging']).thenReturn(false); + when(() => argResults.rest).thenReturn([]); + when(() => argResults.wasParsed(any())).thenReturn(true); + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + workingDirectory: any(named: 'workingDirectory'), + outputPath: any(named: 'outputPath'), + ), + ).thenAnswer((_) async => null); + when(() => aotTools.isGeneratePatchDiffBaseSupported()) + .thenAnswer((_) async => false); + when( + () => aotTools.generatePatchDiffBase( + releaseSnapshot: any(named: 'releaseSnapshot'), + analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), + ), + ).thenAnswer((_) async => File('')); + when(() => artifactManager.downloadFile(any())) + .thenAnswer((_) async => releaseArtifactFile); + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async { + final outputDirectory = + invocation.namedArguments[#outputDirectory] as Directory; + File( + p.join( + outputDirectory.path, + 'Products', + 'Applications', + 'App.app', + 'Frameworks', + 'App.framework', + 'App', + ), + ).createSync(recursive: true); + }); + when( + () => artifactManager.createDiff( + releaseArtifactPath: any(named: 'releaseArtifactPath'), + patchArtifactPath: any(named: 'patchArtifactPath'), + ), + ).thenAnswer((_) async => ''); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => auth.client).thenReturn(httpClient); + when( + () => codePushClientWrapper.getApp(appId: any(named: 'appId')), + ).thenAnswer((_) async => appMetadata); + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => preLinkerRelease); + when( + () => codePushClientWrapper.getReleaseArtifact( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async => ipaArtifact); + when( + () => codePushClientWrapper.publishPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + platform: any(named: 'platform'), + track: any(named: 'track'), + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: any(named: 'metadata'), + ), + ).thenAnswer((_) async {}); + when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]); + when(() => engineConfig.localEngine).thenReturn(null); + when( + () => ios.exportOptionsPlistFromArgs(argResults), + ).thenReturn(File('.')); + when(flutterValidator.validate).thenAnswer((_) async => []); + when(() => logger.confirm(any())).thenReturn(true); + when(() => logger.progress(any())).thenReturn(progress); + when( + () => operatingSystemInterface.which('flutter'), + ).thenReturn('/path/to/flutter'); + when(() => platform.operatingSystem).thenReturn(operatingSystem); + when(() => platform.operatingSystemVersion) + .thenReturn(operatingSystemVersion); + when(() => platform.environment).thenReturn({}); + when(() => platform.script).thenReturn(shorebirdRoot.uri); + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); + when( + () => shorebirdEnv.copyWith( + flutterRevisionOverride: any(named: 'flutterRevisionOverride'), + ), + ).thenAnswer((invocation) { + when(() => shorebirdEnv.flutterRevision).thenReturn( + invocation.namedArguments[#flutterRevisionOverride] as String, + ); + return shorebirdEnv; + }); + when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory); + when( + () => shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.genSnapshot, + ), + ).thenReturn(genSnapshotFile.path); + when( + () => shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.analyzeSnapshot, + ), + ).thenReturn(analyzeSnapshotFile.path); + when(() => shorebirdEnv.flutterRevision) + .thenReturn(preLinkerFlutterRevision); + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); + when( + () => shorebirdFlutter.installRevision( + revision: any(named: 'revision'), + ), + ).thenAnswer((_) async {}); + when( + () => aotBuildProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => flutterBuildProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when(() => flutterPubGetProcessResult.exitCode) + .thenReturn(ExitCode.success.code); + when( + () => shorebirdProcess.run( + 'flutter', + ['--no-version-check', 'pub', 'get', '--offline'], + runInShell: any(named: 'runInShell'), + useVendedFlutter: false, + ), + ).thenAnswer((_) async => flutterPubGetProcessResult); + when( + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => flutterBuildProcessResult); + when( + () => shorebirdProcess.run( + any(that: endsWith('gen_snapshot_arm64')), + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => aotBuildProcessResult); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + supportedOperatingSystems: any(named: 'supportedOperatingSystems'), + ), + ).thenAnswer((_) async {}); + when( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), + ), + ).thenAnswer( + (_) async => DiffStatus( + hasAssetChanges: false, + hasNativeChanges: false, + ), + ); + when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion); + + command = runWithOverrides( + () => PatchIosCommand(archiveDiffer: archiveDiffer), + )..testArgResults = argResults; }); - test('logs error and exits with usage code', () async { + test('supports alpha alias', () { + expect(command.aliases, contains('ios-alpha')); + }); + + test('has a description', () { + expect(command.description, isNotEmpty); + }); + + test('exits when validation fails', () 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(command.run), + completion(equals(exception.exitCode.code)), + ); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: [flutterValidator], + supportedOperatingSystems: {Platform.macOS}, + ), + ).called(1); + }); + + group('when exportOptionsPlistFromArgs throws exception', () { + setUp(() { + when(() => ios.exportOptionsPlistFromArgs(argResults)) + .thenThrow(ArgumentError('bad args')); + }); + + test('logs error and exits with usage code', () async { + setUpProjectRoot(); + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.usage.code)); + verify(() => logger.err('Invalid argument(s): bad args')).called(1); + }); + }); + + test('exits with code 70 when building fails', () async { + when(() => flutterBuildProcessResult.exitCode).thenReturn(1); + when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); + setUpProjectRoot(); final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.usage.code)); - verify(() => logger.err('Invalid argument(s): bad args')).called(1); + expect(exitCode, equals(ExitCode.software.code)); }); - }); - test('exits with code 70 when building fails', () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); - - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - }); - - test('exits with code 70 when building fails (due to BuildException)', - () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(0); - when(() => flutterBuildProcessResult.stderr).thenReturn(''' + test('exits with code 70 when building fails (due to BuildException)', + () async { + when(() => flutterBuildProcessResult.exitCode).thenReturn(0); + when(() => flutterBuildProcessResult.stderr).thenReturn(''' Encountered error while creating the IPA: error: exportArchive: Communication with Apple failed error: exportArchive: No signing certificate "iOS Distribution" found @@ -601,578 +603,598 @@ error: exportArchive: Communication with Apple failed error: exportArchive: No signing certificate "iOS Distribution" found '''); - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); + setUpProjectRoot(); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail(any(that: contains('Failed to build'))), - ).called(1); - verify( - () => logger.err(''' + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => progress.fail(any(that: contains('Failed to build'))), + ).called(1); + verify( + () => logger.err(''' Communication with Apple failed No signing certificate "iOS Distribution" found Team "My Team" does not have permission to create "iOS App Store" provisioning profiles. No profiles for 'com.example.co' were found'''), - ).called(1); - }); + ).called(1); + }); + + group('when build directory has non-default structure', () { + test('exits with code 70 if xcarchive is not found', () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + Directory( + p.join(projectRoot.path, 'build'), + ).deleteSync(recursive: true); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err( + any(that: contains('Unable to find .xcarchive directory')), + ), + ).called(1); + }); + + test('prints error and exits with code 70 if Info.plist does not exist', + () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final plistPath = p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + 'Info.plist', + ); + File(plistPath).deleteSync(); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err('No Info.plist file found at $plistPath.'), + ).called(1); + }); + + test('finds xcarchive that has been renamed from Runner', () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + ), + ).renameSync( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'شوربيرد | Shorebird.xcarchive', + ), + ); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.success.code)); + }); + }); + + test( + '''exits with code 70 if release does not exist for the ios platform''', + () async { + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer( + (_) async => Release( + id: 0, + appId: appId, + version: version, + flutterRevision: preLinkerFlutterRevision, + displayName: '1.2.3+1', + platformStatuses: {ReleasePlatform.android: ReleaseStatus.active}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ), + ); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.software.code); + verify(() => logger.err('No iOS release found for 1.2.3+1.')).called(1); + }); + + test( + '''exits with code 70 if release is in draft state for the ios platform''', + () async { + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer( + (_) async => Release( + id: 0, + appId: appId, + version: version, + flutterRevision: preLinkerFlutterRevision, + displayName: '1.2.3+1', + platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ), + ); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.software.code); + verify( + () => logger.err(''' +Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete. +Please re-run the release command for this version or create a new release.'''), + ).called(1); + }); + + test('proceeds if release is in draft state for a non-ios platform', + () async { + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer( + (_) async => Release( + id: 0, + appId: appId, + version: version, + flutterRevision: preLinkerFlutterRevision, + displayName: '1.2.3+1', + platformStatuses: { + ReleasePlatform.android: ReleaseStatus.draft, + ReleasePlatform.ios: ReleaseStatus.active, + }, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ), + ); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.success.code); + }); + + test( + '''switches to release flutter revision when shorebird flutter revision does not match''', + () async { + const otherRevision = 'other-revision'; + when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); + + // If a release version is not specified as a command argument, we + // build the app with the default Flutter revision to determine the + // release version. We then build the app with the release Flutter + // revision to determine the release version. + var hasBuiltToDetermineReleaseVersion = false; + var hasRunGenSnapshotToDetermineReleaseVersion = false; + when( + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async { + if (!hasBuiltToDetermineReleaseVersion) { + hasBuiltToDetermineReleaseVersion = true; + return flutterBuildProcessResult; + } + + // Ensure we're using the correct flutter revision. + expect( + shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); + return flutterBuildProcessResult; + }); + when( + () => shorebirdProcess.run( + any(that: endsWith('gen_snapshot_arm64')), + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async { + if (!hasRunGenSnapshotToDetermineReleaseVersion) { + hasRunGenSnapshotToDetermineReleaseVersion = true; + return aotBuildProcessResult; + } + + // Ensure we're using the correct flutter revision. + expect( + shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); + return aotBuildProcessResult; + }); + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + workingDirectory: any(named: 'workingDirectory'), + outputPath: any(named: 'outputPath'), + ), + ).thenAnswer((_) async { + // Ensure we're using the correct flutter revision. + expect( + shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); + return null; + }); - group('when build directory has non-default structure', () { - test('exits with code 70 if xcarchive is not found', () async { setUpProjectRoot(); setUpProjectRootArtifacts(); - Directory( - p.join(projectRoot.path, 'build'), - ).deleteSync(recursive: true); final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); + expect(exitCode, ExitCode.success.code); + + when( + () => shorebirdFlutter.installRevision( + revision: any(named: 'revision'), + ), + ).thenAnswer((_) async {}); verify( - () => logger.err( - any(that: contains('Unable to find .xcarchive directory')), + () => logger.info( + any( + that: stringContainsInOrder([ + '''The release you are trying to patch was built with a different version of Flutter.''', + 'Release Flutter Revision: ${preLinkerRelease.flutterRevision}', + 'Current Flutter Revision: $otherRevision', + ]), + ), ), ).called(1); }); - test('prints error and exits with code 70 if Info.plist does not exist', + group('when release-version option is provided', () { + const customReleaseVersion = 'custom-release-version'; + + setUp(() { + when( + () => argResults['release-version'], + ).thenReturn(customReleaseVersion); + }); + + test('does not extract release version from archive', () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + await runWithOverrides(command.run); + + verify( + () => codePushClientWrapper.getRelease( + appId: appId, + releaseVersion: customReleaseVersion, + ), + ).called(1); + }); + + test('exits with code 70 if xcarchive is not found', () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + Directory( + p.join(projectRoot.path, 'build'), + ).deleteSync(recursive: true); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err( + any(that: contains('Unable to find .xcarchive directory')), + ), + ).called(1); + }); + + test('exits with code 70 if build fails', () async { + when(() => flutterBuildProcessResult.exitCode).thenReturn(1); + when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); + + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.software.code); + }); + + test('only builds once if release uses different flutter revision', + () async { + const otherRevision = 'other-revision'; + when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); + + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.success.code); + + verify( + () => shorebirdProcess.run( + 'flutter', + any( + that: containsAll([ + 'build', + 'ipa', + '--release', + ]), + ), + runInShell: any(named: 'runInShell'), + ), + ).called(1); + }); + }); + + test('exits with code 70 when release version cannot be determined', () async { setUpProjectRoot(); setUpProjectRootArtifacts(); - final plistPath = p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Info.plist', - ); - File(plistPath).deleteSync(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err('No Info.plist file found at $plistPath.'), - ).called(1); - }); - - test('finds xcarchive that has been renamed from Runner', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - Directory( + final file = File( p.join( projectRoot.path, 'build', 'ios', 'archive', 'Runner.xcarchive', + 'Info.plist', ), - ).renameSync( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'شوربيرد | Shorebird.xcarchive', - ), - ); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - }); - }); - - test( - '''exits with code 70 if release does not exist for the ios platform''', - () async { - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer( - (_) async => Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.android: ReleaseStatus.active}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify(() => logger.err('No iOS release found for 1.2.3+1.')).called(1); - }); - - test( - '''exits with code 70 if release is in draft state for the ios platform''', - () async { - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer( - (_) async => Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify( - () => logger.err(''' -Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''), - ).called(1); - }); - - test('proceeds if release is in draft state for a non-ios platform', - () async { - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer( - (_) async => Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: { - ReleasePlatform.android: ReleaseStatus.draft, - ReleasePlatform.ios: ReleaseStatus.active, - }, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - }); - - test( - '''switches to release flutter revision when shorebird flutter revision does not match''', - () async { - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - - // If a release version is not specified as a command argument, we - // build the app with the default Flutter revision to determine the - // release version. We then build the app with the release Flutter - // revision to determine the release version. - var hasBuiltToDetermineReleaseVersion = false; - var hasRunGenSnapshotToDetermineReleaseVersion = false; - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - if (!hasBuiltToDetermineReleaseVersion) { - hasBuiltToDetermineReleaseVersion = true; - return flutterBuildProcessResult; - } - - // Ensure we're using the correct flutter revision. - expect(shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); - return flutterBuildProcessResult; - }); - when( - () => shorebirdProcess.run( - any(that: endsWith('gen_snapshot_arm64')), - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - if (!hasRunGenSnapshotToDetermineReleaseVersion) { - hasRunGenSnapshotToDetermineReleaseVersion = true; - return aotBuildProcessResult; - } - - // Ensure we're using the correct flutter revision. - expect(shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); - return aotBuildProcessResult; - }); - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenAnswer((_) async { - // Ensure we're using the correct flutter revision. - expect(shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); - return null; - }); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, ExitCode.success.code); - - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenAnswer((_) async {}); - verify( - () => logger.info( - any( - that: stringContainsInOrder([ - '''The release you are trying to patch was built with a different version of Flutter.''', - 'Release Flutter Revision: ${preLinkerRelease.flutterRevision}', - 'Current Flutter Revision: $otherRevision', - ]), - ), - ), - ).called(1); - }); - - group('when release-version option is provided', () { - const customReleaseVersion = 'custom-release-version'; - - setUp(() { - when( - () => argResults['release-version'], - ).thenReturn(customReleaseVersion); - }); - - test('does not extract release version from archive', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - await runWithOverrides(command.run); - - verify( - () => codePushClientWrapper.getRelease( - appId: appId, - releaseVersion: customReleaseVersion, - ), - ).called(1); - }); - - test('exits with code 70 if xcarchive is not found', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - Directory( - p.join(projectRoot.path, 'build'), - ).deleteSync(recursive: true); + ) + ..createSync(recursive: true) + ..writeAsStringSync(emptyPlistContent); final exitCode = await runWithOverrides(command.run); expect(exitCode, equals(ExitCode.software.code)); verify( () => logger.err( - any(that: contains('Unable to find .xcarchive directory')), + 'Failed to determine release version from ${file.path}: ' + 'Exception: Could not determine release version', ), ).called(1); }); - test('exits with code 70 if build fails', () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); - + test('prints release version when detected', () async { setUpProjectRoot(); setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); + + expect(exitCode, equals(ExitCode.success.code)); + verify(() => logger.info('Detected release version 1.2.3+1')).called(1); }); - test('only builds once if release uses different flutter revision', - () async { - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - + test('aborts when user opts out', () async { + when(() => logger.confirm(any())).thenReturn(false); setUpProjectRoot(); setUpProjectRootArtifacts(); final exitCode = await runWithOverrides(command.run); expect(exitCode, ExitCode.success.code); + verify(() => logger.info('Aborting.')).called(1); + }); - verify( - () => shorebirdProcess.run( - 'flutter', - any( - that: containsAll([ - 'build', - 'ipa', - '--release', - ]), + group('when flutter version install fails', () { + setUp(() { + when( + () => shorebirdFlutter.installRevision( + revision: any(named: 'revision'), ), - runInShell: any(named: 'runInShell'), - ), - ).called(1); - }); - }); + ).thenThrow(Exception('oops')); + }); - test('exits with code 70 when release version cannot be determined', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final file = File( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Info.plist', - ), - ) - ..createSync(recursive: true) - ..writeAsStringSync(emptyPlistContent); + test('exits with code 70', () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); + final result = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - 'Failed to determine release version from ${file.path}: ' - 'Exception: Could not determine release version', - ), - ).called(1); - }); - - test('prints release version when detected', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify(() => logger.info('Detected release version 1.2.3+1')).called(1); - }); - - test('aborts when user opts out', () async { - when(() => logger.confirm(any())).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify(() => logger.info('Aborting.')).called(1); - }); - - group('when flutter version install fails', () { - setUp(() { - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenThrow(Exception('oops')); + expect(result, equals(ExitCode.software.code)); + verify( + () => shorebirdFlutter.installRevision( + revision: preLinkerFlutterRevision, + ), + ).called(1); + }); }); - test('exits with code 70', () async { + test('throws error when creating aot snapshot fails', () async { + const error = 'oops something went wrong'; + when(() => aotBuildProcessResult.exitCode).thenReturn(1); + when(() => aotBuildProcessResult.stderr).thenReturn(error); setUpProjectRoot(); setUpProjectRootArtifacts(); - - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); + final exitCode = await runWithOverrides(command.run); verify( - () => shorebirdFlutter.installRevision( - revision: preLinkerFlutterRevision, - ), + () => progress.fail('Exception: Failed to create snapshot: $error'), ).called(1); + expect(exitCode, ExitCode.software.code); }); - }); - test('throws error when creating aot snapshot fails', () async { - const error = 'oops something went wrong'; - when(() => aotBuildProcessResult.exitCode).thenReturn(1); - when(() => aotBuildProcessResult.stderr).thenReturn(error); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => progress.fail('Exception: Failed to create snapshot: $error'), - ).called(1); - expect(exitCode, ExitCode.software.code); - }); - - test( - '''exits with code 0 if zipAndConfirmUnpatchableDiffsIfNecessary throws UserCancelledException''', - () async { - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UserCancelledException()); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - test('exits with code 70 if release artifact fails to download', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - releaseArtifactFile.deleteSync(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail('Exception: Failed to download release artifact'), - ).called(1); - }); - - group('when release artifact fails to extract', () { - setUp(() { + test( + '''exits with code 0 if zipAndConfirmUnpatchableDiffsIfNecessary throws UserCancelledException''', + () async { + when( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), + ), + ).thenThrow(UserCancelledException()); setUpProjectRoot(); setUpProjectRootArtifacts(); - when( - () => artifactManager.extractZip( - zipFile: any(named: 'zipFile'), - outputDirectory: any(named: 'outputDirectory'), + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.success.code)); + verify( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: releaseArtifactFile, + archiveDiffer: archiveDiffer, + allowAssetChanges: false, + allowNativeChanges: false, ), - ).thenAnswer((invocation) async {}); + ).called(1); + verifyNever( + () => codePushClientWrapper.publishPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + platform: any(named: 'platform'), + track: any(named: 'track'), + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: any(named: 'metadata'), + ), + ); }); - test('prints error message and exits with code 70', () async { + test('exits with code 70 if release artifact fails to download', + () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + releaseArtifactFile.deleteSync(); + final exitCode = await runWithOverrides(command.run); expect(exitCode, equals(ExitCode.software.code)); verify( - () => logger.err('Unable to find release artifact .app directory'), + () => progress.fail('Exception: Failed to download release artifact'), ).called(1); }); - }); - test( - '''exits with code 70 if zipAndConfirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''', - () async { - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UnpatchableChangeException()); - setUpProjectRoot(); - setUpProjectRootArtifacts(); + group('when release artifact fails to extract', () { + setUp(() { + setUpProjectRoot(); + setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((invocation) async {}); + }); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); + test('prints error message and exits with code 70', () async { + final exitCode = await runWithOverrides(command.run); - group('when the engine revision is pre-linker', () { - setUp(() { - setUpProjectRoot(); - setUpProjectRootArtifacts(); + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err('Unable to find release artifact .app directory'), + ).called(1); + }); }); - test('we do not attempt to link the AOT file', () async { - await runWithOverrides(command.run); + test( + '''exits with code 70 if zipAndConfirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''', + () async { + when( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), + ), + ).thenThrow(UnpatchableChangeException()); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: releaseArtifactFile, + archiveDiffer: archiveDiffer, + allowAssetChanges: false, + allowNativeChanges: false, + ), + ).called(1); verifyNever( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), + () => codePushClientWrapper.publishPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + platform: any(named: 'platform'), + track: any(named: 'track'), + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: any(named: 'metadata'), ), ); - verifyNever(() => aotTools.isGeneratePatchDiffBaseSupported()); - }); - }); - - group('when the engine revision supports the linker', () { - setUp(() { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => postLinkerRelease); }); - group('when using a local engine build', () { + group('when the engine revision is pre-linker', () { setUp(() { - when(() => engineConfig.localEngine).thenReturn('engine'); + setUpProjectRoot(); + setUpProjectRootArtifacts(); }); - test('attempts to link', () async { + test('we do not attempt to link the AOT file', () async { await runWithOverrides(command.run); + verifyNever( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + workingDirectory: any(named: 'workingDirectory'), + outputPath: any(named: 'outputPath'), + ), + ); + verifyNever(() => aotTools.isGeneratePatchDiffBaseSupported()); + }); + }); + + group('when the engine revision supports the linker', () { + setUp(() { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => postLinkerRelease); + }); + + group('when using a local engine build', () { + setUp(() { + when(() => engineConfig.localEngine).thenReturn('engine'); + }); + + test('attempts to link', () async { + await runWithOverrides(command.run); + + verify( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + workingDirectory: any(named: 'workingDirectory'), + outputPath: any(named: 'outputPath'), + ), + ).called(1); + }); + }); + + test('we attempt to link the AOT file', () async { + await runWithOverrides(command.run); verify( () => aotTools.link( base: any(named: 'base'), @@ -1185,58 +1207,138 @@ Please re-run the release command for this version or create a new release.'''), ), ).called(1); }); - }); - test('we attempt to link the AOT file', () async { - await runWithOverrides(command.run); - verify( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).called(1); - }); + group('when patch AOT file is not found', () { + test('exits with code 70', () async { + final patch = File( + p.join(projectRoot.path, 'build', elfAotSnapshotFileName), + )..deleteSync(recursive: true); - group('when patch AOT file is not found', () { - test('exits with code 70', () async { - final patch = File( - p.join(projectRoot.path, 'build', elfAotSnapshotFileName), - )..deleteSync(recursive: true); + final exitCode = await runWithOverrides(command.run); - final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => + logger.err('Unable to find patch AOT file at ${patch.path}'), + ).called(1); + }); + }); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err('Unable to find patch AOT file at ${patch.path}'), - ).called(1); + group('when analyze snapshot is not found', () { + setUp(() { + analyzeSnapshotFile.deleteSync(recursive: true); + }); + + test('exits with code 70', () async { + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err( + 'Unable to find analyze_snapshot at ${analyzeSnapshotFile.path}', + ), + ).called(1); + }); + }); + + group('when linking fails', () { + final exception = Exception('failed to link'); + setUp(() { + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + workingDirectory: any(named: 'workingDirectory'), + outputPath: any(named: 'outputPath'), + ), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => progress.fail('Failed to link AOT files: $exception'), + ).called(1); + }); + }); + + group('when aot_tools returns a low link percentage', () { + setUp(() { + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + workingDirectory: any(named: 'workingDirectory'), + outputPath: any(named: 'outputPath'), + ), + ).thenAnswer((_) async => PatchIosCommand.minLinkPercentage - 1); + }); + + test('logs a warning', () async { + await runWithOverrides(command.run); + verify( + () => logger.warn( + PatchIosCommand.lowLinkPercentageWarning( + PatchIosCommand.minLinkPercentage - 1, + ), + ), + ).called(1); + }); }); }); - group('when analyze snapshot is not found', () { + group('when aot-tools supports generating patch diff base', () { + const diffPath = 'path/to/diff'; setUp(() { - analyzeSnapshotFile.deleteSync(recursive: true); - }); + setUpProjectRoot(); + setUpProjectRootArtifacts(); - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - 'Unable to find analyze_snapshot at ${analyzeSnapshotFile.path}', + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), ), - ).called(1); + ).thenAnswer((_) async => postLinkerRelease); + when(() => aotTools.isGeneratePatchDiffBaseSupported()) + .thenAnswer((_) async => true); + when( + () => artifactManager.createDiff( + releaseArtifactPath: any(named: 'releaseArtifactPath'), + patchArtifactPath: any(named: 'patchArtifactPath'), + ), + ).thenAnswer((_) async => diffPath); }); - }); - group('when linking fails', () { - final exception = Exception('failed to link'); - setUp(() { + group('when generatePatchDiffBase fails', () { + const errorMessage = 'oops something went wrong'; + setUp(() { + when( + () => aotTools.generatePatchDiffBase( + analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), + releaseSnapshot: any(named: 'releaseSnapshot'), + ), + ).thenThrow(Exception(errorMessage)); + }); + + test('prints error and exits with code 70', () async { + final result = await runWithOverrides(command.run); + + expect(result, equals(ExitCode.software.code)); + verify(() => progress.fail('Exception: $errorMessage')).called(1); + }); + }); + + test('generates diff base and publishes the appropriate patch', + () async { + const linkPercentage = 99.9; when( () => aotTools.link( base: any(named: 'base'), @@ -1247,122 +1349,319 @@ Please re-run the release command for this version or create a new release.'''), workingDirectory: any(named: 'workingDirectory'), outputPath: any(named: 'outputPath'), ), - ).thenThrow(exception); - }); - - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail('Failed to link AOT files: $exception'), - ).called(1); - }); - }); - - group('when aot_tools returns a low link percentage', () { - setUp(() { - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenAnswer((_) async => PatchIosCommand.minLinkPercentage - 1); - }); - - test('logs a warning', () async { + ).thenAnswer( + (_) async => linkPercentage, + ); await runWithOverrides(command.run); verify( - () => logger.warn( - PatchIosCommand.lowLinkPercentageWarning( - PatchIosCommand.minLinkPercentage - 1, + () => codePushClientWrapper.publishPatch( + appId: appId, + releaseId: postLinkerRelease.id, + platform: releasePlatform, + track: track, + patchArtifactBundles: any( + named: 'patchArtifactBundles', + that: isA>() + .having((e) => e[Arch.arm64]!.path, 'patch path', diffPath), + ), + metadata: const CreatePatchMetadata( + releasePlatform: releasePlatform, + usedIgnoreAssetChangesFlag: false, + hasAssetChanges: false, + usedIgnoreNativeChangesFlag: false, + hasNativeChanges: false, + linkPercentage: linkPercentage, + environment: BuildEnvironmentMetadata( + shorebirdVersion: packageVersion, + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + xcodeVersion: xcodeVersion, + ), ), ), ).called(1); }); }); - }); - group('when aot-tools supports generating patch diff base', () { - const diffPath = 'path/to/diff'; - setUp(() { + test('does not create patch on --dry-run', () async { + when(() => argResults['dry-run']).thenReturn(true); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.success.code)); + verifyNever( + () => codePushClientWrapper.createPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + metadata: any(named: 'metadata'), + ), + ); + verify(() => logger.info('No issues detected.')).called(1); + }); + + test( + '''forwards allow-asset-diffs and allow-native-diffs to patch diff checker''', + () async { setUpProjectRoot(); setUpProjectRootArtifacts(); - when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), + when(() => argResults['allow-asset-diffs']).thenReturn(true); + when(() => argResults['allow-native-diffs']).thenReturn(true); + + await runWithOverrides(command.run); + + verify( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: true, + allowNativeChanges: true, ), - ).thenAnswer((_) async => postLinkerRelease); - when(() => aotTools.isGeneratePatchDiffBaseSupported()) - .thenAnswer((_) async => true); - when( - () => artifactManager.createDiff( - releaseArtifactPath: any(named: 'releaseArtifactPath'), - patchArtifactPath: any(named: 'patchArtifactPath'), + ).called(1); + + when(() => argResults['allow-asset-diffs']).thenReturn(false); + when(() => argResults['allow-native-diffs']).thenReturn(false); + + await runWithOverrides(command.run); + + verify( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: false, + allowNativeChanges: false, ), - ).thenAnswer((_) async => diffPath); + ).called(1); }); - group('when generatePatchDiffBase fails', () { - const errorMessage = 'oops something went wrong'; - setUp(() { - when( - () => aotTools.generatePatchDiffBase( - analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), - releaseSnapshot: any(named: 'releaseSnapshot'), - ), - ).thenThrow(Exception(errorMessage)); - }); - - test('prints error and exits with code 70', () async { - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); - verify(() => progress.fail('Exception: $errorMessage')).called(1); - }); - }); - - test('generates diff base and publishes the appropriate patch', () async { - const linkPercentage = 99.9; + test('reports when patch has asset and native changes', () async { + when(() => argResults['allow-asset-diffs']).thenReturn(true); + when(() => argResults['allow-native-diffs']).thenReturn(true); + when(() => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any())) + .thenReturn(true); + when(() => archiveDiffer.containsPotentiallyBreakingNativeDiffs(any())) + .thenReturn(true); + when(() => archiveDiffer.changedFiles(any(), any())) + .thenAnswer((_) async => FileSetDiff.empty()); when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), ), ).thenAnswer( - (_) async => linkPercentage, + (_) async => DiffStatus( + hasAssetChanges: true, + hasNativeChanges: true, + ), ); - await runWithOverrides(command.run); + + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.success.code)); verify( () => codePushClientWrapper.publishPatch( appId: appId, - releaseId: postLinkerRelease.id, + releaseId: preLinkerRelease.id, platform: releasePlatform, track: track, - patchArtifactBundles: any( - named: 'patchArtifactBundles', - that: isA>() - .having((e) => e[Arch.arm64]!.path, 'patch path', diffPath), + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: any( + named: 'metadata', + that: isA() + .having( + (m) => m.releasePlatform, + 'releasePlatform', + releasePlatform, + ) + .having( + (m) => m.hasAssetChanges, + 'hasAssetChanges', + true, + ) + .having( + (m) => m.hasNativeChanges, + 'hasNativeChanges', + true, + ), ), + ), + ).called(1); + }); + + test('succeeds when patch is successful (production)', () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + verify( + () => logger.info( + any( + that: contains( + ''' +🕹️ Platform: ${lightCyan.wrap(platformName)} ${lightCyan.wrap('[aarch64 (0 B)]')} +🟢 Track: ${lightCyan.wrap('Production')}''', + ), + ), + ), + ).called(1); + verify( + () => codePushClientWrapper.publishPatch( + appId: appId, + releaseId: preLinkerRelease.id, + platform: releasePlatform, + track: track, + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: any(named: 'metadata'), + ), + ).called(1); + + // Verify that an export options plist was provided to the build ipa + // command. + const exportOptionsPlistArgName = 'export-options-plist'; + final capturedArgs = verify( + () => shorebirdProcess.run( + 'flutter', + captureAny(), + runInShell: any(named: 'runInShell'), + ), + ).captured.first as List; + final exportOptionsPlistArg = capturedArgs + .whereType() + .firstWhereOrNull((arg) => arg.contains(exportOptionsPlistArgName)); + expect(exportOptionsPlistArg, isNotNull); + + expect(exitCode, ExitCode.success.code); + }); + + test('succeeds when patch is successful (staging)', () async { + when(() => argResults['staging']).thenReturn(true); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + verify( + () => logger.info( + any( + that: contains( + ''' +🕹️ Platform: ${lightCyan.wrap(platformName)} ${lightCyan.wrap('[aarch64 (0 B)]')} +🟠 Track: ${lightCyan.wrap('Staging')}''', + ), + ), + ), + ).called(1); + verify( + () => codePushClientWrapper.publishPatch( + appId: appId, + releaseId: preLinkerRelease.id, + platform: releasePlatform, + track: DeploymentTrack.staging, + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: any(named: 'metadata'), + ), + ).called(1); + expect(exitCode, ExitCode.success.code); + }); + + test('runs flutter pub get with system flutter after successful build', + () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + await runWithOverrides(command.run); + + verify( + () => shorebirdProcess.run( + 'flutter', + ['--no-version-check', 'pub', 'get', '--offline'], + runInShell: any(named: 'runInShell'), + useVendedFlutter: false, + ), + ).called(1); + }); + + test('forwards codesign to flutter build', () async { + when(() => argResults['codesign']).thenReturn(false); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + await runWithOverrides(command.run); + + verify( + () => shorebirdProcess.run( + 'flutter', + any( + that: containsAllInOrder( + [ + 'build', + 'ipa', + '--release', + '--no-codesign', + ], + ), + ), + runInShell: true, + ), + ).called(1); + }); + + test('does not provide export options when codesign is false', () async { + when(() => argResults['codesign']).thenReturn(false); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + await runWithOverrides(command.run); + + final capturedArgs = verify( + () => shorebirdProcess.run( + 'flutter', + captureAny(), + runInShell: any(named: 'runInShell'), + ), + ).captured.first as List; + expect( + capturedArgs + .whereType() + .firstWhereOrNull((arg) => arg.contains('export-options-plist')), + isNull, + ); + }); + + test('succeeds when patch is successful with flavors and target', + () async { + const flavor = 'development'; + const target = './lib/main_development.dart'; + when(() => argResults['flavor']).thenReturn(flavor); + when(() => argResults['target']).thenReturn(target); + setUpProjectRoot(); + File( + p.join(projectRoot.path, 'shorebird.yaml'), + ).writeAsStringSync(''' +app_id: productionAppId +flavors: + development: $appId'''); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.success.code); + verify( + () => codePushClientWrapper.publishPatch( + appId: appId, + releaseId: preLinkerRelease.id, + platform: releasePlatform, + track: track, + patchArtifactBundles: any(named: 'patchArtifactBundles'), metadata: const CreatePatchMetadata( releasePlatform: releasePlatform, usedIgnoreAssetChangesFlag: false, hasAssetChanges: false, usedIgnoreNativeChangesFlag: false, hasNativeChanges: false, - linkPercentage: linkPercentage, + linkPercentage: null, environment: BuildEnvironmentMetadata( shorebirdVersion: packageVersion, operatingSystem: operatingSystem, @@ -1373,320 +1672,32 @@ Please re-run the release command for this version or create a new release.'''), ), ).called(1); }); - }); - test('does not create patch on --dry-run', () async { - when(() => argResults['dry-run']).thenReturn(true); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.success.code)); - verifyNever( - () => codePushClientWrapper.createPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - metadata: any(named: 'metadata'), - ), - ); - verify(() => logger.info('No issues detected.')).called(1); - }); - - test( - '''forwards allow-asset-diffs and allow-native-diffs to patch diff checker''', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - when(() => argResults['allow-asset-diffs']).thenReturn(true); - when(() => argResults['allow-native-diffs']).thenReturn(true); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: true, - allowNativeChanges: true, - ), - ).called(1); - - when(() => argResults['allow-asset-diffs']).thenReturn(false); - when(() => argResults['allow-native-diffs']).thenReturn(false); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - }); - - test('reports when patch has asset and native changes', () async { - when(() => argResults['allow-asset-diffs']).thenReturn(true); - when(() => argResults['allow-native-diffs']).thenReturn(true); - when(() => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.containsPotentiallyBreakingNativeDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.changedFiles(any(), any())) - .thenAnswer((_) async => FileSetDiff.empty()); - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: true, - hasNativeChanges: true, - ), - ); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any( - named: 'metadata', - that: isA() - .having( - (m) => m.releasePlatform, - 'releasePlatform', - releasePlatform, - ) - .having( - (m) => m.hasAssetChanges, - 'hasAssetChanges', - true, - ) - .having( - (m) => m.hasNativeChanges, - 'hasNativeChanges', - true, - ), - ), - ), - ).called(1); - }); - - test('succeeds when patch is successful (production)', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => logger.info( - any( - that: contains( - ''' -🕹️ Platform: ${lightCyan.wrap(platformName)} ${lightCyan.wrap('[aarch64 (0 B)]')} -🟢 Track: ${lightCyan.wrap('Production')}''', - ), - ), - ), - ).called(1); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).called(1); - - // Verify that an export options plist was provided to the build ipa - // command. - const exportOptionsPlistArgName = 'export-options-plist'; - final capturedArgs = verify( - () => shorebirdProcess.run( - 'flutter', - captureAny(), - runInShell: any(named: 'runInShell'), - ), - ).captured.first as List; - final exportOptionsPlistArg = capturedArgs - .whereType() - .firstWhereOrNull((arg) => arg.contains(exportOptionsPlistArgName)); - expect(exportOptionsPlistArg, isNotNull); - - expect(exitCode, ExitCode.success.code); - }); - - test('succeeds when patch is successful (staging)', () async { - when(() => argResults['staging']).thenReturn(true); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => logger.info( - any( - that: contains( - ''' -🕹️ Platform: ${lightCyan.wrap(platformName)} ${lightCyan.wrap('[aarch64 (0 B)]')} -🟠 Track: ${lightCyan.wrap('Staging')}''', - ), - ), - ), - ).called(1); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: releasePlatform, - track: DeploymentTrack.staging, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).called(1); - expect(exitCode, ExitCode.success.code); - }); - - test('runs flutter pub get with system flutter after successful build', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - await runWithOverrides(command.run); - - verify( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).called(1); - }); - - test('forwards codesign to flutter build', () async { - when(() => argResults['codesign']).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - await runWithOverrides(command.run); - - verify( - () => shorebirdProcess.run( - 'flutter', - any( - that: containsAllInOrder( - [ - 'build', - 'ipa', - '--release', - '--no-codesign', - ], - ), - ), - runInShell: true, - ), - ).called(1); - }); - - test('does not provide export options when codesign is false', () async { - when(() => argResults['codesign']).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - await runWithOverrides(command.run); - - final capturedArgs = verify( - () => shorebirdProcess.run( - 'flutter', - captureAny(), - runInShell: any(named: 'runInShell'), - ), - ).captured.first as List; - expect( - capturedArgs - .whereType() - .firstWhereOrNull((arg) => arg.contains('export-options-plist')), - isNull, - ); - }); - - test('succeeds when patch is successful with flavors and target', () async { - const flavor = 'development'; - const target = './lib/main_development.dart'; - when(() => argResults['flavor']).thenReturn(flavor); - when(() => argResults['target']).thenReturn(target); - setUpProjectRoot(); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync(''' -app_id: productionAppId -flavors: - development: $appId'''); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: releasePlatform, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: const CreatePatchMetadata( - releasePlatform: releasePlatform, - usedIgnoreAssetChangesFlag: false, - hasAssetChanges: false, - usedIgnoreNativeChangesFlag: false, - hasNativeChanges: false, - linkPercentage: null, - environment: BuildEnvironmentMetadata( - shorebirdVersion: packageVersion, - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - xcodeVersion: xcodeVersion, - ), - ), - ), - ).called(1); - }); - - test('succeeds when patch is successful using custom base_url', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - const baseUrl = 'https://example.com'; - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync( - ''' + test('succeeds when patch is successful using custom base_url', () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + const baseUrl = 'https://example.com'; + File( + p.join(projectRoot.path, 'shorebird.yaml'), + ).writeAsStringSync( + ''' app_id: $appId base_url: $baseUrl''', - ); - await runWithOverrides(command.run); - }); + ); + await runWithOverrides(command.run); + }); - test('does not prompt if unable to accept user input', () async { - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); + test('does not prompt if unable to accept user input', () async { + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); + setUpProjectRoot(); + setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.success.code)); - verifyNever(() => logger.confirm(any())); - }); - }); + expect(exitCode, equals(ExitCode.success.code)); + verifyNever(() => logger.confirm(any())); + }); + }, + testOn: 'mac-os', + ); } diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_ios_framework_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_ios_framework_command_test.dart index ad5b0b06..04dc1ee0 100644 --- a/packages/shorebird_cli/test/src/commands/patch/patch_ios_framework_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/patch/patch_ios_framework_command_test.dart @@ -34,23 +34,26 @@ import 'package:test/test.dart'; import '../../mocks.dart'; void main() { - group(PatchIosFrameworkCommand, () { - const appDisplayName = 'Test App'; - const appId = 'test-app-id'; - const shorebirdYaml = ShorebirdYaml(appId: appId); - const versionName = '1.2.3'; - const versionCode = '1'; - const track = DeploymentTrack.production; - const version = '$versionName+$versionCode'; - const linkFileName = 'out.vmcode'; - const elfAotSnapshotFileName = 'out.aot'; - const operatingSystem = 'macOS'; - const operatingSystemVersion = '11.0.0'; - const xcodeVersion = '12.0'; - const postLinkerFlutterRevision = - 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; - const preLinkerFlutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; - const pubspecYamlContent = ''' + group( + PatchIosFrameworkCommand, + () { + const appDisplayName = 'Test App'; + const appId = 'test-app-id'; + const shorebirdYaml = ShorebirdYaml(appId: appId); + const versionName = '1.2.3'; + const versionCode = '1'; + const track = DeploymentTrack.production; + const version = '$versionName+$versionCode'; + const linkFileName = 'out.vmcode'; + const elfAotSnapshotFileName = 'out.aot'; + const operatingSystem = 'macOS'; + const operatingSystemVersion = '11.0.0'; + const xcodeVersion = '12.0'; + const postLinkerFlutterRevision = + 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + const preLinkerFlutterRevision = + '83305b5088e6fe327fb3334a73ff190828d85713'; + const pubspecYamlContent = ''' name: example version: $version environment: @@ -59,997 +62,236 @@ environment: flutter: assets: - shorebird.yaml'''; - final appMetadata = AppMetadata( - appId: appId, - displayName: appDisplayName, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - const arch = 'aarch64'; - const xcframeworkArtifact = ReleaseArtifact( - id: 0, - releaseId: 0, - arch: arch, - platform: ReleasePlatform.ios, - hash: '#', - size: 42, - url: 'https://example.com/release.xcframework', - ); - final preLinkerRelease = Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - final postLinkerRelease = Release( - id: 0, - appId: appId, - version: version, - flutterRevision: postLinkerFlutterRevision, - displayName: '1.2.4+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.active}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - - late File releaseArtifactFile; - - late AotTools aotTools; - late ArgResults argResults; - late ArtifactManager artifactManager; - late CodePushClientWrapper codePushClientWrapper; - late Directory shorebirdRoot; - late Directory projectRoot; - late Directory flutterDirectory; - late EngineConfig engineConfig; - late File analyzeSnapshotFile; - late File genSnapshotFile; - late ShorebirdArtifacts shorebirdArtifacts; - late Doctor doctor; - late IosArchiveDiffer archiveDiffer; - late PatchDiffChecker patchDiffChecker; - late Platform platform; - late Auth auth; - late OperatingSystemInterface operatingSystemInterface; - late Logger logger; - late Progress progress; - late ShorebirdProcessResult aotBuildProcessResult; - late ShorebirdProcessResult flutterBuildProcessResult; - late ShorebirdProcessResult flutterPubGetProcessResult; - late ShorebirdEnv shorebirdEnv; - late ShorebirdFlutter shorebirdFlutter; - late ShorebirdFlutterValidator flutterValidator; - late ShorebirdProcess shorebirdProcess; - late ShorebirdValidator shorebirdValidator; - late XcodeBuild xcodeBuild; - late PatchIosFrameworkCommand command; - - R runWithOverrides(R Function() body) { - return runScoped( - body, - values: { - aotToolsRef.overrideWith(() => aotTools), - artifactManagerRef.overrideWith(() => artifactManager), - authRef.overrideWith(() => auth), - codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), - doctorRef.overrideWith(() => doctor), - engineConfigRef.overrideWith(() => engineConfig), - shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), - loggerRef.overrideWith(() => logger), - osInterfaceRef.overrideWith(() => operatingSystemInterface), - patchDiffCheckerRef.overrideWith(() => patchDiffChecker), - platformRef.overrideWith(() => platform), - processRef.overrideWith(() => shorebirdProcess), - shorebirdEnvRef.overrideWith(() => shorebirdEnv), - shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), - shorebirdValidatorRef.overrideWith(() => shorebirdValidator), - xcodeBuildRef.overrideWith(() => xcodeBuild), - }, + final appMetadata = AppMetadata( + appId: appId, + displayName: appDisplayName, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), ); - } - - 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, + const arch = 'aarch64'; + const xcframeworkArtifact = ReleaseArtifact( + id: 0, + releaseId: 0, + arch: arch, + platform: ReleasePlatform.ios, + hash: '#', + size: 42, + url: 'https://example.com/release.xcframework', ); - Directory( - p.join( - projectRoot.path, - 'build', - 'ios', - 'framework', - 'Release', - 'App.xcframework', - ), - ).createSync( - recursive: true, + final preLinkerRelease = Release( + id: 0, + appId: appId, + version: version, + flutterRevision: preLinkerFlutterRevision, + displayName: '1.2.3+1', + platformStatuses: {}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), ); - File( - p.join(projectRoot.path, 'build', linkFileName), - ).createSync(recursive: true); - } - - void setUpProjectRoot() { - File( - p.join(projectRoot.path, 'pubspec.yaml'), - ).writeAsStringSync(pubspecYamlContent); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync('app_id: $appId'); - } - - setUpAll(() { - registerFallbackValue(CreatePatchMetadata.forTest()); - registerFallbackValue(Directory('')); - registerFallbackValue(File('')); - registerFallbackValue(FileSetDiff.empty()); - registerFallbackValue(ReleasePlatform.ios); - registerFallbackValue(Uri.parse('https://example.com')); - registerFallbackValue(DeploymentTrack.production); - }); - - setUp(() { - aotTools = MockAotTools(); - argResults = MockArgResults(); - archiveDiffer = MockIosArchiveDiffer(); - artifactManager = MockArtifactManager(); - codePushClientWrapper = MockCodePushClientWrapper(); - doctor = MockDoctor(); - engineConfig = MockEngineConfig(); - shorebirdArtifacts = MockShorebirdArtifacts(); - patchDiffChecker = MockPatchDiffChecker(); - platform = MockPlatform(); - xcodeBuild = MockXcodeBuild(); - shorebirdRoot = Directory.systemTemp.createTempSync(); - projectRoot = Directory.systemTemp.createTempSync(); - flutterDirectory = Directory( - p.join(shorebirdRoot.path, 'bin', 'cache', 'flutter'), + final postLinkerRelease = Release( + id: 0, + appId: appId, + version: version, + flutterRevision: postLinkerFlutterRevision, + displayName: '1.2.4+1', + platformStatuses: {ReleasePlatform.ios: ReleaseStatus.active}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), ); - 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); - auth = MockAuth(); - progress = MockProgress(); - logger = MockLogger(); - aotBuildProcessResult = MockProcessResult(); - flutterBuildProcessResult = MockProcessResult(); - flutterPubGetProcessResult = MockProcessResult(); - operatingSystemInterface = MockOperatingSystemInterface(); - shorebirdEnv = MockShorebirdEnv(); - shorebirdFlutter = MockShorebirdFlutter(); - flutterValidator = MockShorebirdFlutterValidator(); - shorebirdProcess = MockShorebirdProcess(); - shorebirdValidator = MockShorebirdValidator(); - when( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).thenAnswer((_) async => flutterPubGetProcessResult); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - when( - () => shorebirdProcess.run( - any(that: endsWith('gen_snapshot_arm64')), - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => aotBuildProcessResult); - when(() => aotTools.isGeneratePatchDiffBaseSupported()) - .thenAnswer((_) async => false); - when( - () => aotTools.generatePatchDiffBase( - releaseSnapshot: any(named: 'releaseSnapshot'), - analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), - ), - ).thenAnswer((_) async => File('')); - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenAnswer((_) async => null); - when(() => argResults['release-version']).thenReturn(version); - when(() => argResults.rest).thenReturn([]); - when(() => artifactManager.downloadFile(any())).thenAnswer((_) async { - final tmpDir = Directory.systemTemp.createTempSync(); - return releaseArtifactFile = - File(p.join(tmpDir.path, 'release.artifact')) - ..createSync(recursive: true); - }); - when( - () => artifactManager.extractZip( - zipFile: any(named: 'zipFile'), - outputDirectory: any(named: 'outputDirectory'), - ), - ).thenAnswer((_) async {}); - when(() => auth.isAuthenticated).thenReturn(true); - when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]); - when(() => engineConfig.localEngine).thenReturn(null); - when(flutterValidator.validate).thenAnswer((_) async => []); - when(() => logger.level).thenReturn(Level.info); - when(() => logger.progress(any())).thenReturn(progress); - when(() => logger.confirm(any())).thenReturn(true); - when( - () => operatingSystemInterface.which('flutter'), - ).thenReturn('/path/to/flutter'); - when(() => platform.operatingSystem).thenReturn(operatingSystem); - when(() => platform.operatingSystemVersion) - .thenReturn(operatingSystemVersion); - when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); - when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); - when( - () => shorebirdEnv.getShorebirdProjectRoot(), - ).thenReturn(projectRoot); - when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory); - when( - () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.analyzeSnapshot, - ), - ).thenReturn(analyzeSnapshotFile.path); - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenAnswer((_) async {}); - when( - () => shorebirdArtifacts.getArtifactPath( - artifact: ShorebirdArtifact.genSnapshot, - ), - ).thenReturn(genSnapshotFile.path); - when(() => shorebirdEnv.flutterRevision) - .thenReturn(preLinkerFlutterRevision); - when( - () => shorebirdEnv.copyWith( - flutterRevisionOverride: any(named: 'flutterRevisionOverride'), - ), - ).thenAnswer((invocation) { - when(() => shorebirdEnv.flutterRevision).thenReturn( - invocation.namedArguments[#flutterRevisionOverride] as String, - ); - return shorebirdEnv; - }); - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); - when( - () => aotBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when( - () => flutterBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when(() => flutterPubGetProcessResult.exitCode) - .thenReturn(ExitCode.success.code); - when( - () => codePushClientWrapper.getApp(appId: any(named: 'appId')), - ).thenAnswer((_) async => appMetadata); - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer((_) async => [preLinkerRelease]); - when( - () => codePushClientWrapper.getReleaseArtifact( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - arch: any(named: 'arch'), - platform: any(named: 'platform'), - ), - ).thenAnswer((_) async => xcframeworkArtifact); - when( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ).thenAnswer((_) async {}); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - validators: any(named: 'validators'), - supportedOperatingSystems: any(named: 'supportedOperatingSystems'), - ), - ).thenAnswer((_) async {}); - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: false, - hasNativeChanges: false, - ), - ); - when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion); + late File releaseArtifactFile; - command = runWithOverrides( - () => PatchIosFrameworkCommand(archiveDiffer: archiveDiffer), - )..testArgResults = argResults; - }); + late AotTools aotTools; + late ArgResults argResults; + late ArtifactManager artifactManager; + late CodePushClientWrapper codePushClientWrapper; + late Directory shorebirdRoot; + late Directory projectRoot; + late Directory flutterDirectory; + late EngineConfig engineConfig; + late File analyzeSnapshotFile; + late File genSnapshotFile; + late ShorebirdArtifacts shorebirdArtifacts; + late Doctor doctor; + late IosArchiveDiffer archiveDiffer; + late PatchDiffChecker patchDiffChecker; + late Platform platform; + late Auth auth; + late OperatingSystemInterface operatingSystemInterface; + late Logger logger; + late Progress progress; + late ShorebirdProcessResult aotBuildProcessResult; + late ShorebirdProcessResult flutterBuildProcessResult; + late ShorebirdProcessResult flutterPubGetProcessResult; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + late ShorebirdValidator shorebirdValidator; + late XcodeBuild xcodeBuild; + late PatchIosFrameworkCommand command; - test('supports alpha alias', () { - expect(command.aliases, contains('ios-framework-alpha')); - }); - - test('has a description', () { - expect(command.description, isNotEmpty); - }); - - test('exits when validation fails', () 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(command.run), - completion(equals(exception.exitCode.code)), - ); - verify( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - validators: [flutterValidator], - supportedOperatingSystems: {Platform.macOS}, - ), - ).called(1); - }); - - test('prompts for release when release-version is not specified', () async { - when(() => argResults['release-version']).thenReturn(null); - when( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: any(named: 'display'), - ), - ).thenReturn(preLinkerRelease); - try { - await runWithOverrides(command.run); - } catch (_) {} - await untilCalled( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: any(named: 'display'), - ), - ); - final display = verify( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: captureAny(named: 'display'), - ), - ).captured.single as String Function(Release); - expect(display(preLinkerRelease), equals(preLinkerRelease.version)); - }); - - test('exits early when no releases are found', () async { - when(() => argResults['release-version']).thenReturn(null); - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer((_) async => []); - try { - await runWithOverrides(command.run); - } catch (_) {} - verifyNever( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: captureAny(named: 'display'), - ), - ); - verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); - verify(() => logger.info('No releases found')).called(1); - }); - - test('exits early when specified release does not exist.', () async { - when(() => argResults['release-version']).thenReturn('0.0.0'); - try { - await runWithOverrides(command.run); - } catch (_) {} - verifyNever( - () => logger.chooseOne( - any(), - choices: any(named: 'choices'), - display: captureAny(named: 'display'), - ), - ); - verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); - verify( - () => logger.info(''' -No release found for version 0.0.0 - -Available release versions: -${preLinkerRelease.version}'''), - ).called(1); - }); - - test( - '''exits with code 70 if release is in draft state for the ios platform''', - () async { - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer( - (_) async => [ - Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ], - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify( - () => logger.err(''' -Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete. -Please re-run the release command for this version or create a new release.'''), - ).called(1); - }); - - test('proceeds if release is in draft state for a non-ios platform', - () async { - when( - () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), - ).thenAnswer( - (_) async => [ - Release( - id: 0, - appId: appId, - version: version, - flutterRevision: preLinkerFlutterRevision, - displayName: '1.2.3+1', - platformStatuses: {ReleasePlatform.android: ReleaseStatus.draft}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ), - ], - ); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - }); - - test( - '''uses release flutter revision if different than default flutter revision''', - () async { - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - when( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).thenAnswer((_) async { - expect(shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); - return null; - }); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - expect(shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); - return flutterBuildProcessResult; - }); - when( - () => shorebirdProcess.run( - any(that: endsWith('gen_snapshot_arm64')), - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - expect(shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); - return aotBuildProcessResult; - }); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => shorebirdFlutter.installRevision( - revision: preLinkerFlutterRevision, - ), - ).called(1); - }); - - test( - 'builds using correct flutter revision ' - 'when release flutter revision differs', () async { - when( - () => platform.script, - ).thenReturn( - Uri.file(p.join('bin', 'cache', 'shorebird.snapshot')), - ); - const otherRevision = 'other-revision'; - when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); - final processWrapper = MockProcessWrapper(); - when( - () => processWrapper.run( - any(), - any(), - runInShell: any(named: 'runInShell'), - workingDirectory: any(named: 'workingDirectory'), - environment: any(named: 'environment'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - final flutterFile = File( - p.join( - '.', - 'bin', - 'cache', - 'flutter', - preLinkerRelease.flutterRevision, - 'bin', - 'flutter', - ), - ); - when(() => shorebirdEnv.flutterBinaryFile).thenReturn(flutterFile); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - await runWithOverrides( - () => runScoped( - () => command.run(), + R runWithOverrides(R Function() body) { + return runScoped( + body, values: { - processRef.overrideWith( - () => ShorebirdProcess(processWrapper: processWrapper), - ), + aotToolsRef.overrideWith(() => aotTools), + artifactManagerRef.overrideWith(() => artifactManager), + authRef.overrideWith(() => auth), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + doctorRef.overrideWith(() => doctor), + engineConfigRef.overrideWith(() => engineConfig), + shorebirdArtifactsRef.overrideWith(() => shorebirdArtifacts), + loggerRef.overrideWith(() => logger), + osInterfaceRef.overrideWith(() => operatingSystemInterface), + patchDiffCheckerRef.overrideWith(() => patchDiffChecker), + platformRef.overrideWith(() => platform), + processRef.overrideWith(() => shorebirdProcess), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + xcodeBuildRef.overrideWith(() => xcodeBuild), }, - ), - ); + ); + } - verify( - () => shorebirdFlutter.installRevision( - revision: preLinkerFlutterRevision, - ), - ).called(1); - verify( - () => processWrapper.run( - flutterFile.path, - any(), - runInShell: true, - workingDirectory: any(named: 'workingDirectory'), - environment: any(named: 'environment'), - ), - ).called(1); - }); + 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); + } + + void setUpProjectRoot() { + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File( + p.join(projectRoot.path, 'shorebird.yaml'), + ).writeAsStringSync('app_id: $appId'); + } + + setUpAll(() { + registerFallbackValue(CreatePatchMetadata.forTest()); + registerFallbackValue(Directory('')); + registerFallbackValue(File('')); + registerFallbackValue(FileSetDiff.empty()); + registerFallbackValue(ReleasePlatform.ios); + registerFallbackValue(Uri.parse('https://example.com')); + registerFallbackValue(DeploymentTrack.production); + }); - group('when flutter version install fails', () { setUp(() { - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), + aotTools = MockAotTools(); + argResults = MockArgResults(); + archiveDiffer = MockIosArchiveDiffer(); + artifactManager = MockArtifactManager(); + codePushClientWrapper = MockCodePushClientWrapper(); + doctor = MockDoctor(); + engineConfig = MockEngineConfig(); + shorebirdArtifacts = MockShorebirdArtifacts(); + patchDiffChecker = MockPatchDiffChecker(); + platform = MockPlatform(); + xcodeBuild = MockXcodeBuild(); + shorebirdRoot = Directory.systemTemp.createTempSync(); + projectRoot = 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', ), - ).thenThrow(Exception('oops')); - }); - - test('exits with code 70', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); - verify( - () => shorebirdFlutter.installRevision( - revision: preLinkerFlutterRevision, + ); + analyzeSnapshotFile = File( + p.join( + flutterDirectory.path, + 'bin', + 'cache', + 'artifacts', + 'engine', + 'ios-release', + 'analyze_snapshot_arm64', ), - ).called(1); - }); - }); - - test('aborts when user opts out', () async { - when(() => logger.confirm(any())).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify(() => logger.info('Aborting.')).called(1); - }); - - test('exits with code 70 when build fails', () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oh no'); - - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.software.code); - verify(() => progress.fail('Failed to build: oh no')).called(1); - }); - - test('throws error when creating aot snapshot fails', () async { - const error = 'oops something went wrong'; - when(() => aotBuildProcessResult.exitCode).thenReturn(1); - when(() => aotBuildProcessResult.stderr).thenReturn(error); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => progress.fail('Exception: Failed to create snapshot: $error'), - ).called(1); - expect(exitCode, ExitCode.software.code); - }); - - test( - '''exits with code 0 if zipAndConfirmUnpatchableDiffsIfNecessary throws UserCancelledException''', - () async { - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UserCancelledException()); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - test( - '''exits with code 70 if zipAndConfirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''', - () async { - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenThrow(UnpatchableChangeException()); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: releaseArtifactFile, - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - verifyNever( - () => codePushClientWrapper.publishPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - track: any(named: 'track'), - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any(named: 'metadata'), - ), - ); - }); - - test('does not create patch on --dry-run', () async { - when(() => argResults['dry-run']).thenReturn(true); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.success.code)); - verifyNever( - () => codePushClientWrapper.createPatch( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - metadata: any(named: 'metadata'), - ), - ); - verify(() => logger.info('No issues detected.')).called(1); - }); - - test( - '''forwards allow-asset-diffs and allow-native-diffs to patch diff checker''', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - when(() => argResults['allow-asset-diffs']).thenReturn(true); - when(() => argResults['allow-native-diffs']).thenReturn(true); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: true, - allowNativeChanges: true, - ), - ).called(1); - - when(() => argResults['allow-asset-diffs']).thenReturn(false); - when(() => argResults['allow-native-diffs']).thenReturn(false); - - await runWithOverrides(command.run); - - verify( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: false, - allowNativeChanges: false, - ), - ).called(1); - }); - - test('reports when patch has asset and native changes', () async { - when(() => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.containsPotentiallyBreakingNativeDiffs(any())) - .thenReturn(true); - when(() => archiveDiffer.changedFiles(any(), any())) - .thenAnswer((_) async => FileSetDiff.empty()); - when( - () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( - localArtifactDirectory: any(named: 'localArtifactDirectory'), - releaseArtifact: any(named: 'releaseArtifact'), - archiveDiffer: archiveDiffer, - allowAssetChanges: any(named: 'allowAssetChanges'), - allowNativeChanges: any(named: 'allowNativeChanges'), - ), - ).thenAnswer( - (_) async => DiffStatus( - hasAssetChanges: true, - hasNativeChanges: true, - ), - ); - - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: postLinkerRelease.id, - platform: ReleasePlatform.ios, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: any( - named: 'metadata', - that: isA() - .having( - (m) => m.releasePlatform, - 'releasePlatform', - ReleasePlatform.ios, - ) - .having( - (m) => m.hasAssetChanges, - 'hasAssetChanges', - true, - ) - .having( - (m) => m.hasNativeChanges, - 'hasNativeChanges', - true, - ), - ), - ), - ).called(1); - }); - - test('succeeds when patch is successful', () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - final exitCode = await runWithOverrides(command.run); - verify( - () => logger.info( - any( - that: contains( - ''' -🕹️ Platform: ${lightCyan.wrap('ios')} ${lightCyan.wrap('[aarch64 (0 B)]')} -🟢 Track: ${lightCyan.wrap('Production')}''', - ), - ), - ), - ).called(1); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: ReleasePlatform.ios, - track: track, - patchArtifactBundles: any(named: 'patchArtifactBundles'), - metadata: const CreatePatchMetadata( - releasePlatform: ReleasePlatform.ios, - usedIgnoreAssetChangesFlag: false, - hasAssetChanges: false, - usedIgnoreNativeChangesFlag: false, - hasNativeChanges: false, - linkPercentage: null, - environment: BuildEnvironmentMetadata( - shorebirdVersion: packageVersion, - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - xcodeVersion: xcodeVersion, - ), - ), - ), - ).called(1); - expect(exitCode, ExitCode.success.code); - }); - - test('runs flutter pub get with system flutter after successful build', - () async { - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - await runWithOverrides(command.run); - - verify( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).called(1); - }); - - test('does not prompt if unable to accept user input', () async { - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); - setUpProjectRoot(); - setUpProjectRootArtifacts(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - verifyNever(() => logger.confirm(any())); - }); - - group('when the engine revision supports the linker', () { - setUp(() { - setUpProjectRoot(); - setUpProjectRootArtifacts(); + )..createSync(recursive: true); + auth = MockAuth(); + progress = MockProgress(); + logger = MockLogger(); + aotBuildProcessResult = MockProcessResult(); + flutterBuildProcessResult = MockProcessResult(); + flutterPubGetProcessResult = MockProcessResult(); + operatingSystemInterface = MockOperatingSystemInterface(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); + flutterValidator = MockShorebirdFlutterValidator(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdValidator = MockShorebirdValidator(); when( - () => codePushClientWrapper.getReleases( - appId: any(named: 'appId'), + () => shorebirdProcess.run( + 'flutter', + ['--no-version-check', 'pub', 'get', '--offline'], + runInShell: any(named: 'runInShell'), + useVendedFlutter: false, ), - ).thenAnswer((_) async => [postLinkerRelease]); + ).thenAnswer((_) async => flutterPubGetProcessResult); when( - () => codePushClientWrapper.getRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), ), - ).thenAnswer((_) async => postLinkerRelease); - }); - - group('when using a local engine build', () { - setUp(() { - when(() => engineConfig.localEngine).thenReturn('engine'); - }); - - test('attempts to link', () async { - await runWithOverrides(command.run); - - verify( - () => aotTools.link( - base: any(named: 'base'), - patch: any(named: 'patch'), - analyzeSnapshot: any(named: 'analyzeSnapshot'), - genSnapshot: any(named: 'genSnapshot'), - kernel: any(named: 'kernel'), - workingDirectory: any(named: 'workingDirectory'), - outputPath: any(named: 'outputPath'), - ), - ).called(1); - }); - }); - - test('attempts to link the AOT file', () async { - await runWithOverrides(command.run); - verify( + ).thenAnswer((_) async => flutterBuildProcessResult); + when( + () => shorebirdProcess.run( + any(that: endsWith('gen_snapshot_arm64')), + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => aotBuildProcessResult); + when(() => aotTools.isGeneratePatchDiffBaseSupported()) + .thenAnswer((_) async => false); + when( + () => aotTools.generatePatchDiffBase( + releaseSnapshot: any(named: 'releaseSnapshot'), + analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), + ), + ).thenAnswer((_) async => File('')); + when( () => aotTools.link( base: any(named: 'base'), patch: any(named: 'patch'), @@ -1059,45 +301,762 @@ Please re-run the release command for this version or create a new release.'''), workingDirectory: any(named: 'workingDirectory'), outputPath: any(named: 'outputPath'), ), + ).thenAnswer((_) async => null); + when(() => argResults['release-version']).thenReturn(version); + when(() => argResults.rest).thenReturn([]); + when(() => artifactManager.downloadFile(any())).thenAnswer((_) async { + final tmpDir = Directory.systemTemp.createTempSync(); + return releaseArtifactFile = + File(p.join(tmpDir.path, 'release.artifact')) + ..createSync(recursive: true); + }); + when( + () => artifactManager.extractZip( + zipFile: any(named: 'zipFile'), + outputDirectory: any(named: 'outputDirectory'), + ), + ).thenAnswer((_) async {}); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]); + when(() => engineConfig.localEngine).thenReturn(null); + when(flutterValidator.validate).thenAnswer((_) async => []); + when(() => logger.level).thenReturn(Level.info); + when(() => logger.progress(any())).thenReturn(progress); + when(() => logger.confirm(any())).thenReturn(true); + when( + () => operatingSystemInterface.which('flutter'), + ).thenReturn('/path/to/flutter'); + when(() => platform.operatingSystem).thenReturn(operatingSystem); + when(() => platform.operatingSystemVersion) + .thenReturn(operatingSystemVersion); + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); + when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + when(() => shorebirdEnv.flutterDirectory).thenReturn(flutterDirectory); + when( + () => shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.analyzeSnapshot, + ), + ).thenReturn(analyzeSnapshotFile.path); + when( + () => shorebirdFlutter.installRevision( + revision: any(named: 'revision'), + ), + ).thenAnswer((_) async {}); + when( + () => shorebirdArtifacts.getArtifactPath( + artifact: ShorebirdArtifact.genSnapshot, + ), + ).thenReturn(genSnapshotFile.path); + when(() => shorebirdEnv.flutterRevision) + .thenReturn(preLinkerFlutterRevision); + when( + () => shorebirdEnv.copyWith( + flutterRevisionOverride: any(named: 'flutterRevisionOverride'), + ), + ).thenAnswer((invocation) { + when(() => shorebirdEnv.flutterRevision).thenReturn( + invocation.namedArguments[#flutterRevisionOverride] as String, + ); + return shorebirdEnv; + }); + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); + when( + () => aotBuildProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => flutterBuildProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when(() => flutterPubGetProcessResult.exitCode) + .thenReturn(ExitCode.success.code); + when( + () => codePushClientWrapper.getApp(appId: any(named: 'appId')), + ).thenAnswer((_) async => appMetadata); + when( + () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => [preLinkerRelease]); + when( + () => codePushClientWrapper.getReleaseArtifact( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async => xcframeworkArtifact); + when( + () => codePushClientWrapper.publishPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + platform: any(named: 'platform'), + track: any(named: 'track'), + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: any(named: 'metadata'), + ), + ).thenAnswer((_) async {}); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + supportedOperatingSystems: any(named: 'supportedOperatingSystems'), + ), + ).thenAnswer((_) async {}); + when( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), + ), + ).thenAnswer( + (_) async => DiffStatus( + hasAssetChanges: false, + hasNativeChanges: false, + ), + ); + when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion); + + command = runWithOverrides( + () => PatchIosFrameworkCommand(archiveDiffer: archiveDiffer), + )..testArgResults = argResults; + }); + + test('supports alpha alias', () { + expect(command.aliases, contains('ios-framework-alpha')); + }); + + test('has a description', () { + expect(command.description, isNotEmpty); + }); + + test('exits when validation fails', () 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(command.run), + completion(equals(exception.exitCode.code)), + ); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: [flutterValidator], + supportedOperatingSystems: {Platform.macOS}, + ), ).called(1); }); - group('when patch AOT file is not found', () { - test('exits with code 70', () async { - final patch = File( - p.join(projectRoot.path, 'build', elfAotSnapshotFileName), - )..deleteSync(recursive: true); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err('Unable to find patch AOT file at ${patch.path}'), - ).called(1); - }); + test('prompts for release when release-version is not specified', + () async { + when(() => argResults['release-version']).thenReturn(null); + when( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ).thenReturn(preLinkerRelease); + try { + await runWithOverrides(command.run); + } catch (_) {} + await untilCalled( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: any(named: 'display'), + ), + ); + final display = verify( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: captureAny(named: 'display'), + ), + ).captured.single as String Function(Release); + expect(display(preLinkerRelease), equals(preLinkerRelease.version)); }); - group('when analyze snapshot is not found', () { + test('exits early when no releases are found', () async { + when(() => argResults['release-version']).thenReturn(null); + when( + () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + try { + await runWithOverrides(command.run); + } catch (_) {} + verifyNever( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: captureAny(named: 'display'), + ), + ); + verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); + verify(() => logger.info('No releases found')).called(1); + }); + + test('exits early when specified release does not exist.', () async { + when(() => argResults['release-version']).thenReturn('0.0.0'); + try { + await runWithOverrides(command.run); + } catch (_) {} + verifyNever( + () => logger.chooseOne( + any(), + choices: any(named: 'choices'), + display: captureAny(named: 'display'), + ), + ); + verify(() => codePushClientWrapper.getReleases(appId: appId)).called(1); + verify( + () => logger.info(''' +No release found for version 0.0.0 + +Available release versions: +${preLinkerRelease.version}'''), + ).called(1); + }); + + test( + '''exits with code 70 if release is in draft state for the ios platform''', + () async { + when( + () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), + ).thenAnswer( + (_) async => [ + Release( + id: 0, + appId: appId, + version: version, + flutterRevision: preLinkerFlutterRevision, + displayName: '1.2.3+1', + platformStatuses: {ReleasePlatform.ios: ReleaseStatus.draft}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ), + ], + ); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.software.code); + verify( + () => logger.err(''' +Release 1.2.3+1 is in an incomplete state. It's possible that the original release was terminated or failed to complete. +Please re-run the release command for this version or create a new release.'''), + ).called(1); + }); + + test('proceeds if release is in draft state for a non-ios platform', + () async { + when( + () => codePushClientWrapper.getReleases(appId: any(named: 'appId')), + ).thenAnswer( + (_) async => [ + Release( + id: 0, + appId: appId, + version: version, + flutterRevision: preLinkerFlutterRevision, + displayName: '1.2.3+1', + platformStatuses: {ReleasePlatform.android: ReleaseStatus.draft}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ), + ], + ); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.success.code); + }); + + test( + '''uses release flutter revision if different than default flutter revision''', + () async { + const otherRevision = 'other-revision'; + when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + workingDirectory: any(named: 'workingDirectory'), + outputPath: any(named: 'outputPath'), + ), + ).thenAnswer((_) async { + expect( + shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); + return null; + }); + when( + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async { + expect( + shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); + return flutterBuildProcessResult; + }); + when( + () => shorebirdProcess.run( + any(that: endsWith('gen_snapshot_arm64')), + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async { + expect( + shorebirdEnv.flutterRevision, equals(preLinkerFlutterRevision)); + return aotBuildProcessResult; + }); + + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.success.code)); + verify( + () => shorebirdFlutter.installRevision( + revision: preLinkerFlutterRevision, + ), + ).called(1); + }); + + test( + 'builds using correct flutter revision ' + 'when release flutter revision differs', () async { + when( + () => platform.script, + ).thenReturn( + Uri.file(p.join('bin', 'cache', 'shorebird.snapshot')), + ); + const otherRevision = 'other-revision'; + when(() => shorebirdEnv.flutterRevision).thenReturn(otherRevision); + final processWrapper = MockProcessWrapper(); + when( + () => processWrapper.run( + any(), + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + environment: any(named: 'environment'), + ), + ).thenAnswer((_) async => flutterBuildProcessResult); + final flutterFile = File( + p.join( + '.', + 'bin', + 'cache', + 'flutter', + preLinkerRelease.flutterRevision, + 'bin', + 'flutter', + ), + ); + when(() => shorebirdEnv.flutterBinaryFile).thenReturn(flutterFile); + + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + await runWithOverrides( + () => runScoped( + () => command.run(), + values: { + processRef.overrideWith( + () => ShorebirdProcess(processWrapper: processWrapper), + ), + }, + ), + ); + + verify( + () => shorebirdFlutter.installRevision( + revision: preLinkerFlutterRevision, + ), + ).called(1); + verify( + () => processWrapper.run( + flutterFile.path, + any(), + runInShell: true, + workingDirectory: any(named: 'workingDirectory'), + environment: any(named: 'environment'), + ), + ).called(1); + }); + + group('when flutter version install fails', () { setUp(() { - analyzeSnapshotFile.deleteSync(recursive: true); + when( + () => shorebirdFlutter.installRevision( + revision: any(named: 'revision'), + ), + ).thenThrow(Exception('oops')); }); test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); + setUpProjectRoot(); + setUpProjectRootArtifacts(); - expect(exitCode, equals(ExitCode.software.code)); + final result = await runWithOverrides(command.run); + + expect(result, equals(ExitCode.software.code)); verify( - () => logger.err( - 'Unable to find analyze_snapshot at ${analyzeSnapshotFile.path}', + () => shorebirdFlutter.installRevision( + revision: preLinkerFlutterRevision, ), ).called(1); }); }); - group('when linking fails', () { - final exception = Exception('failed to link'); + test('aborts when user opts out', () async { + when(() => logger.confirm(any())).thenReturn(false); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.success.code); + verify(() => logger.info('Aborting.')).called(1); + }); + + test('exits with code 70 when build fails', () async { + when(() => flutterBuildProcessResult.exitCode).thenReturn(1); + when(() => flutterBuildProcessResult.stderr).thenReturn('oh no'); + + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.software.code); + verify(() => progress.fail('Failed to build: oh no')).called(1); + }); + + test('throws error when creating aot snapshot fails', () async { + const error = 'oops something went wrong'; + when(() => aotBuildProcessResult.exitCode).thenReturn(1); + when(() => aotBuildProcessResult.stderr).thenReturn(error); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + verify( + () => progress.fail('Exception: Failed to create snapshot: $error'), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test( + '''exits with code 0 if zipAndConfirmUnpatchableDiffsIfNecessary throws UserCancelledException''', + () async { + when( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), + ), + ).thenThrow(UserCancelledException()); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.success.code)); + verify( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: releaseArtifactFile, + archiveDiffer: archiveDiffer, + allowAssetChanges: false, + allowNativeChanges: false, + ), + ).called(1); + verifyNever( + () => codePushClientWrapper.publishPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + platform: any(named: 'platform'), + track: any(named: 'track'), + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: any(named: 'metadata'), + ), + ); + }); + + test( + '''exits with code 70 if zipAndConfirmUnpatchableDiffsIfNecessary throws UnpatchableChangeException''', + () async { + when( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), + ), + ).thenThrow(UnpatchableChangeException()); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: releaseArtifactFile, + archiveDiffer: archiveDiffer, + allowAssetChanges: false, + allowNativeChanges: false, + ), + ).called(1); + verifyNever( + () => codePushClientWrapper.publishPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + platform: any(named: 'platform'), + track: any(named: 'track'), + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: any(named: 'metadata'), + ), + ); + }); + + test('does not create patch on --dry-run', () async { + when(() => argResults['dry-run']).thenReturn(true); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.success.code)); + verifyNever( + () => codePushClientWrapper.createPatch( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + metadata: any(named: 'metadata'), + ), + ); + verify(() => logger.info('No issues detected.')).called(1); + }); + + test( + '''forwards allow-asset-diffs and allow-native-diffs to patch diff checker''', + () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + when(() => argResults['allow-asset-diffs']).thenReturn(true); + when(() => argResults['allow-native-diffs']).thenReturn(true); + + await runWithOverrides(command.run); + + verify( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: true, + allowNativeChanges: true, + ), + ).called(1); + + when(() => argResults['allow-asset-diffs']).thenReturn(false); + when(() => argResults['allow-native-diffs']).thenReturn(false); + + await runWithOverrides(command.run); + + verify( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: false, + allowNativeChanges: false, + ), + ).called(1); + }); + + test('reports when patch has asset and native changes', () async { + when(() => archiveDiffer.containsPotentiallyBreakingAssetDiffs(any())) + .thenReturn(true); + when(() => archiveDiffer.containsPotentiallyBreakingNativeDiffs(any())) + .thenReturn(true); + when(() => archiveDiffer.changedFiles(any(), any())) + .thenAnswer((_) async => FileSetDiff.empty()); + when( + () => patchDiffChecker.zipAndConfirmUnpatchableDiffsIfNecessary( + localArtifactDirectory: any(named: 'localArtifactDirectory'), + releaseArtifact: any(named: 'releaseArtifact'), + archiveDiffer: archiveDiffer, + allowAssetChanges: any(named: 'allowAssetChanges'), + allowNativeChanges: any(named: 'allowNativeChanges'), + ), + ).thenAnswer( + (_) async => DiffStatus( + hasAssetChanges: true, + hasNativeChanges: true, + ), + ); + + setUpProjectRootArtifacts(); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.success.code)); + verify( + () => codePushClientWrapper.publishPatch( + appId: appId, + releaseId: postLinkerRelease.id, + platform: ReleasePlatform.ios, + track: track, + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: any( + named: 'metadata', + that: isA() + .having( + (m) => m.releasePlatform, + 'releasePlatform', + ReleasePlatform.ios, + ) + .having( + (m) => m.hasAssetChanges, + 'hasAssetChanges', + true, + ) + .having( + (m) => m.hasNativeChanges, + 'hasNativeChanges', + true, + ), + ), + ), + ).called(1); + }); + + test('succeeds when patch is successful', () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + final exitCode = await runWithOverrides(command.run); + verify( + () => logger.info( + any( + that: contains( + ''' +🕹️ Platform: ${lightCyan.wrap('ios')} ${lightCyan.wrap('[aarch64 (0 B)]')} +🟢 Track: ${lightCyan.wrap('Production')}''', + ), + ), + ), + ).called(1); + verify( + () => codePushClientWrapper.publishPatch( + appId: appId, + releaseId: preLinkerRelease.id, + platform: ReleasePlatform.ios, + track: track, + patchArtifactBundles: any(named: 'patchArtifactBundles'), + metadata: const CreatePatchMetadata( + releasePlatform: ReleasePlatform.ios, + usedIgnoreAssetChangesFlag: false, + hasAssetChanges: false, + usedIgnoreNativeChangesFlag: false, + hasNativeChanges: false, + linkPercentage: null, + environment: BuildEnvironmentMetadata( + shorebirdVersion: packageVersion, + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + xcodeVersion: xcodeVersion, + ), + ), + ), + ).called(1); + expect(exitCode, ExitCode.success.code); + }); + + test('runs flutter pub get with system flutter after successful build', + () async { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + await runWithOverrides(command.run); + + verify( + () => shorebirdProcess.run( + 'flutter', + ['--no-version-check', 'pub', 'get', '--offline'], + runInShell: any(named: 'runInShell'), + useVendedFlutter: false, + ), + ).called(1); + }); + + test('does not prompt if unable to accept user input', () async { + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.success.code)); + verifyNever(() => logger.confirm(any())); + }); + + group('when the engine revision supports the linker', () { setUp(() { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + when( + () => codePushClientWrapper.getReleases( + appId: any(named: 'appId'), + ), + ).thenAnswer((_) async => [postLinkerRelease]); + when( + () => codePushClientWrapper.getRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => postLinkerRelease); + }); + + group('when using a local engine build', () { + setUp(() { + when(() => engineConfig.localEngine).thenReturn('engine'); + }); + + test('attempts to link', () async { + await runWithOverrides(command.run); + + verify( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + workingDirectory: any(named: 'workingDirectory'), + outputPath: any(named: 'outputPath'), + ), + ).called(1); + }); + }); + + test('attempts to link the AOT file', () async { + await runWithOverrides(command.run); + verify( () => aotTools.link( base: any(named: 'base'), patch: any(named: 'patch'), @@ -1107,89 +1066,141 @@ Please re-run the release command for this version or create a new release.'''), workingDirectory: any(named: 'workingDirectory'), outputPath: any(named: 'outputPath'), ), - ).thenThrow(exception); - }); - - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail('Failed to link AOT files: $exception'), ).called(1); }); - }); - }); - group('when aot-tools supports generating patch diff base', () { - const diffPath = 'path/to/diff'; - setUp(() { - setUpProjectRoot(); - setUpProjectRootArtifacts(); + group('when patch AOT file is not found', () { + test('exits with code 70', () async { + final patch = File( + p.join(projectRoot.path, 'build', elfAotSnapshotFileName), + )..deleteSync(recursive: true); - when(() => aotTools.isGeneratePatchDiffBaseSupported()) - .thenAnswer((_) async => true); - when( - () => artifactManager.createDiff( - releaseArtifactPath: any(named: 'releaseArtifactPath'), - patchArtifactPath: any(named: 'patchArtifactPath'), - ), - ).thenAnswer((_) async => diffPath); - }); + final exitCode = await runWithOverrides(command.run); - group('when release artifact fails to download', () { - setUp(() { - when(() => artifactManager.downloadFile(any())) - .thenAnswer((_) async => File('')); + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => + logger.err('Unable to find patch AOT file at ${patch.path}'), + ).called(1); + }); }); - test('prints error and exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); + group('when analyze snapshot is not found', () { + setUp(() { + analyzeSnapshotFile.deleteSync(recursive: true); + }); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => - progress.fail('Exception: Failed to download release artifact'), - ).called(1); + test('exits with code 70', () async { + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err( + 'Unable to find analyze_snapshot at ${analyzeSnapshotFile.path}', + ), + ).called(1); + }); + }); + + group('when linking fails', () { + final exception = Exception('failed to link'); + setUp(() { + when( + () => aotTools.link( + base: any(named: 'base'), + patch: any(named: 'patch'), + analyzeSnapshot: any(named: 'analyzeSnapshot'), + genSnapshot: any(named: 'genSnapshot'), + kernel: any(named: 'kernel'), + workingDirectory: any(named: 'workingDirectory'), + outputPath: any(named: 'outputPath'), + ), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => progress.fail('Failed to link AOT files: $exception'), + ).called(1); + }); }); }); - group('when generatePatchDiffBase errors', () { - const errorMessage = 'oops something went wrong'; + group('when aot-tools supports generating patch diff base', () { + const diffPath = 'path/to/diff'; setUp(() { + setUpProjectRoot(); + setUpProjectRootArtifacts(); + + when(() => aotTools.isGeneratePatchDiffBaseSupported()) + .thenAnswer((_) async => true); when( - () => aotTools.generatePatchDiffBase( - releaseSnapshot: any(named: 'releaseSnapshot'), - analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), + () => artifactManager.createDiff( + releaseArtifactPath: any(named: 'releaseArtifactPath'), + patchArtifactPath: any(named: 'patchArtifactPath'), ), - ).thenThrow(Exception(errorMessage)); + ).thenAnswer((_) async => diffPath); }); - test('prints error and exits with code 70', () async { - final result = await runWithOverrides(command.run); + group('when release artifact fails to download', () { + setUp(() { + when(() => artifactManager.downloadFile(any())) + .thenAnswer((_) async => File('')); + }); - expect(result, equals(ExitCode.software.code)); - verify(() => progress.fail('Exception: $errorMessage')).called(1); + test('prints error and exits with code 70', () async { + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => progress + .fail('Exception: Failed to download release artifact'), + ).called(1); + }); + }); + + group('when generatePatchDiffBase errors', () { + const errorMessage = 'oops something went wrong'; + setUp(() { + when( + () => aotTools.generatePatchDiffBase( + releaseSnapshot: any(named: 'releaseSnapshot'), + analyzeSnapshotPath: any(named: 'analyzeSnapshotPath'), + ), + ).thenThrow(Exception(errorMessage)); + }); + + test('prints error and exits with code 70', () async { + final result = await runWithOverrides(command.run); + + expect(result, equals(ExitCode.software.code)); + verify(() => progress.fail('Exception: $errorMessage')).called(1); + }); + }); + + test('generates diff base and publishes the appropriate patch', + () async { + await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.publishPatch( + appId: appId, + releaseId: preLinkerRelease.id, + platform: ReleasePlatform.ios, + track: track, + patchArtifactBundles: any( + named: 'patchArtifactBundles', + that: isA>() + .having((e) => e[Arch.arm64]!.path, 'patch path', diffPath), + ), + metadata: any(named: 'metadata'), + ), + ).called(1); }); }); - - test('generates diff base and publishes the appropriate patch', () async { - await runWithOverrides(command.run); - verify( - () => codePushClientWrapper.publishPatch( - appId: appId, - releaseId: preLinkerRelease.id, - platform: ReleasePlatform.ios, - track: track, - patchArtifactBundles: any( - named: 'patchArtifactBundles', - that: isA>() - .having((e) => e[Arch.arm64]!.path, 'patch path', diffPath), - ), - metadata: any(named: 'metadata'), - ), - ).called(1); - }); - }); - }); + }, + testOn: 'mac-os', + ); } diff --git a/packages/shorebird_cli/test/src/commands/release/release_ios_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_ios_command_test.dart index d5ee4e97..5854174a 100644 --- a/packages/shorebird_cli/test/src/commands/release/release_ios_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/release_ios_command_test.dart @@ -30,38 +30,40 @@ import '../../fakes.dart'; import '../../mocks.dart'; void main() { - group(ReleaseIosCommand, () { - const appId = 'test-app-id'; - const shorebirdYaml = ShorebirdYaml(appId: appId); - const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; - const flutterVersionAndRevision = '3.10.6 (83305b5088)'; - const versionName = '1.2.3'; - const versionCode = '1'; - const version = '$versionName+$versionCode'; - const operatingSystem = 'macOS'; - const operatingSystemVersion = '11.0.0'; - const xcodeVersion = '12.0'; - const appDisplayName = 'Test App'; - const arch = 'armv7'; - const releasePlatform = ReleasePlatform.ios; - const ipaPath = 'build/ios/ipa/Runner.ipa'; - final appMetadata = AppMetadata( - appId: appId, - displayName: appDisplayName, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - final release = Release( - id: 0, - appId: appId, - version: version, - flutterRevision: flutterRevision, - displayName: '1.2.3+1', - platformStatuses: {}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - const infoPlistContent = ''' + group( + ReleaseIosCommand, + () { + const appId = 'test-app-id'; + const shorebirdYaml = ShorebirdYaml(appId: appId); + const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; + const flutterVersionAndRevision = '3.10.6 (83305b5088)'; + const versionName = '1.2.3'; + const versionCode = '1'; + const version = '$versionName+$versionCode'; + const operatingSystem = 'macOS'; + const operatingSystemVersion = '11.0.0'; + const xcodeVersion = '12.0'; + const appDisplayName = 'Test App'; + const arch = 'armv7'; + const releasePlatform = ReleasePlatform.ios; + const ipaPath = 'build/ios/ipa/Runner.ipa'; + final appMetadata = AppMetadata( + appId: appId, + displayName: appDisplayName, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ); + final release = Release( + id: 0, + appId: appId, + version: version, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + platformStatuses: {}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ); + const infoPlistContent = ''' @@ -89,7 +91,7 @@ void main() { Runner '''; - const emptyPlistContent = ''' + const emptyPlistContent = ''' @@ -100,7 +102,7 @@ void main() { ' '''; - const pubspecYamlContent = ''' + const pubspecYamlContent = ''' name: example version: $version environment: @@ -110,618 +112,620 @@ flutter: assets: - shorebird.yaml'''; - late ArgResults argResults; - late CodePushClientWrapper codePushClientWrapper; - late Directory shorebirdRoot; - late Directory projectRoot; - late Doctor doctor; - late Platform platform; - late Auth auth; - late Progress progress; - late Logger logger; - late Ios ios; - late OperatingSystemInterface operatingSystemInterface; - late ShorebirdProcessResult flutterBuildProcessResult; - late ShorebirdProcessResult flutterPubGetProcessResult; - late ShorebirdFlutterValidator flutterValidator; - late ShorebirdProcess shorebirdProcess; - late ShorebirdEnv shorebirdEnv; - late ShorebirdFlutter shorebirdFlutter; - late ShorebirdValidator shorebirdValidator; - late XcodeBuild xcodeBuild; - late ReleaseIosCommand command; + late ArgResults argResults; + late CodePushClientWrapper codePushClientWrapper; + late Directory shorebirdRoot; + late Directory projectRoot; + late Doctor doctor; + late Platform platform; + late Auth auth; + late Progress progress; + late Logger logger; + late Ios ios; + late OperatingSystemInterface operatingSystemInterface; + late ShorebirdProcessResult flutterBuildProcessResult; + late ShorebirdProcessResult flutterPubGetProcessResult; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdValidator shorebirdValidator; + late XcodeBuild xcodeBuild; + late ReleaseIosCommand command; - R runWithOverrides(R Function() body) { - return runScoped( - body, - values: { - authRef.overrideWith(() => auth), - codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), - doctorRef.overrideWith(() => doctor), - iosRef.overrideWith(() => ios), - loggerRef.overrideWith(() => logger), - osInterfaceRef.overrideWith(() => operatingSystemInterface), - platformRef.overrideWith(() => platform), - processRef.overrideWith(() => shorebirdProcess), - shorebirdEnvRef.overrideWith(() => shorebirdEnv), - shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), - shorebirdValidatorRef.overrideWith(() => shorebirdValidator), - xcodeBuildRef.overrideWith(() => xcodeBuild), - }, - ); - } - - void setUpProjectRoot() { - File( - p.join(projectRoot.path, 'pubspec.yaml'), - ).writeAsStringSync(pubspecYamlContent); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync('app_id: $appId'); - File( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Info.plist', - ), - ) - ..createSync(recursive: true) - ..writeAsStringSync(infoPlistContent); - Directory( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Products', - 'Applications', - 'Runner.app', - ), - ).createSync(recursive: true); - File(p.join(projectRoot.path, ipaPath)).createSync(recursive: true); - } - - setUpAll(() { - registerFallbackValue(File('')); - registerFallbackValue(ReleasePlatform.ios); - registerFallbackValue(ReleaseStatus.draft); - registerFallbackValue(FakeRelease()); - registerFallbackValue(FakeShorebirdProcess()); - }); - - setUp(() { - argResults = MockArgResults(); - codePushClientWrapper = MockCodePushClientWrapper(); - doctor = MockDoctor(); - platform = MockPlatform(); - shorebirdRoot = Directory.systemTemp.createTempSync(); - projectRoot = Directory.systemTemp.createTempSync(); - auth = MockAuth(); - operatingSystemInterface = MockOperatingSystemInterface(); - progress = MockProgress(); - logger = MockLogger(); - ios = MockIos(); - flutterBuildProcessResult = MockProcessResult(); - flutterPubGetProcessResult = MockProcessResult(); - flutterValidator = MockShorebirdFlutterValidator(); - shorebirdProcess = MockShorebirdProcess(); - shorebirdEnv = MockShorebirdEnv(); - shorebirdFlutter = MockShorebirdFlutter(); - shorebirdValidator = MockShorebirdValidator(); - xcodeBuild = MockXcodeBuild(); - - when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); - when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); - when( - () => shorebirdEnv.getShorebirdProjectRoot(), - ).thenReturn(projectRoot); - when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision); - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); - when( - () => shorebirdEnv.copyWith( - flutterRevisionOverride: any(named: 'flutterRevisionOverride'), - ), - ).thenAnswer((invocation) { - when(() => shorebirdEnv.flutterRevision).thenReturn( - invocation.namedArguments[#flutterRevisionOverride] as String, + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + authRef.overrideWith(() => auth), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + doctorRef.overrideWith(() => doctor), + iosRef.overrideWith(() => ios), + loggerRef.overrideWith(() => logger), + osInterfaceRef.overrideWith(() => operatingSystemInterface), + platformRef.overrideWith(() => platform), + processRef.overrideWith(() => shorebirdProcess), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + xcodeBuildRef.overrideWith(() => xcodeBuild), + }, ); - return shorebirdEnv; - }); - when( - () => shorebirdFlutter.getVersionAndRevision(), - ).thenAnswer((_) async => flutterVersionAndRevision); - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenAnswer((_) async => {}); - when( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).thenAnswer((_) async => flutterPubGetProcessResult); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - when(() => argResults['arch']).thenReturn(arch); - when(() => argResults['codesign']).thenReturn(true); - when(() => argResults['platform']).thenReturn(releasePlatform); - when(() => argResults.rest).thenReturn([]); - when(() => argResults.wasParsed(any())).thenReturn(true); - when(() => auth.isAuthenticated).thenReturn(true); - when(() => logger.progress(any())).thenReturn(progress); - when(() => logger.confirm(any())).thenReturn(true); - when( - () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), - ).thenReturn(version); - when(() => ios.exportOptionsPlistFromArgs(argResults)).thenReturn( - File('.'), - ); - when( - () => operatingSystemInterface.which('flutter'), - ).thenReturn('/path/to/flutter'); - when(() => platform.operatingSystem).thenReturn(operatingSystem); - when(() => platform.operatingSystemVersion) - .thenReturn(operatingSystemVersion); - when( - () => flutterBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when( - () => flutterPubGetProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when( - () => codePushClientWrapper.getApp(appId: any(named: 'appId')), - ).thenAnswer((_) async => appMetadata); - when( - () => codePushClientWrapper.maybeGetRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => null); - when( - () => codePushClientWrapper.ensureReleaseIsNotActive( - release: any(named: 'release'), - platform: any(named: 'platform'), - ), - ).thenAnswer((_) async => {}); - when( - () => codePushClientWrapper.createRelease( - appId: any(named: 'appId'), - version: any(named: 'version'), - flutterRevision: any(named: 'flutterRevision'), - platform: any(named: 'platform'), - ), - ).thenAnswer((_) async => release); - when( - () => codePushClientWrapper.createIosReleaseArtifacts( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - xcarchivePath: any(named: 'xcarchivePath'), - runnerPath: any(named: 'runnerPath'), - isCodesigned: any(named: 'isCodesigned'), - ), - ).thenAnswer((_) async => release); - when( - () => codePushClientWrapper.updateReleaseStatus( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - status: any(named: 'status'), - metadata: any(named: 'metadata'), - ), - ).thenAnswer((_) async => {}); + } - when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - validators: any(named: 'validators'), - supportedOperatingSystems: any(named: 'supportedOperatingSystems'), - ), - ).thenAnswer((_) async {}); - when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion); - - command = runWithOverrides(ReleaseIosCommand.new) - ..testArgResults = argResults; - }); - - test('supports alpha alias', () { - expect(command.aliases, contains('ios-alpha')); - }); - - test('has a description', () { - expect(command.description, isNotEmpty); - }); - - test('exits when validation fails', () 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(command.run), - completion(equals(exception.exitCode.code)), - ); - verify( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - validators: any(named: 'validators'), - supportedOperatingSystems: {Platform.macOS}, - ), - ).called(1); - }); - - group('when obfuscate flag is passed', () { - setUp(() { - when(() => argResults.rest).thenReturn(['--obfuscate']); - }); - - test('prints error and exits with usage code', () async { - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.usage.code)); - verify( - () => logger - .err('Shorebird does not currently support obfuscation on iOS.'), - ).called(1); - }); - }); - - group('when codesign is disabled', () { - setUp(() { - when(() => argResults['codesign']).thenReturn(false); - }); - - test('prints instructions to manually codesign', () async { - setUpProjectRoot(); - await runWithOverrides(command.run); - - verify( - () => logger.info( - '''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''', + void setUpProjectRoot() { + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File( + p.join(projectRoot.path, 'shorebird.yaml'), + ).writeAsStringSync('app_id: $appId'); + File( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + 'Info.plist', ), - ).called(1); + ) + ..createSync(recursive: true) + ..writeAsStringSync(infoPlistContent); + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + 'Products', + 'Applications', + 'Runner.app', + ), + ).createSync(recursive: true); + File(p.join(projectRoot.path, ipaPath)).createSync(recursive: true); + } + + setUpAll(() { + registerFallbackValue(File('')); + registerFallbackValue(ReleasePlatform.ios); + registerFallbackValue(ReleaseStatus.draft); + registerFallbackValue(FakeRelease()); + registerFallbackValue(FakeShorebirdProcess()); }); - test('builds without codesigning', () async { - setUpProjectRoot(); - await runWithOverrides(command.run); + setUp(() { + argResults = MockArgResults(); + codePushClientWrapper = MockCodePushClientWrapper(); + doctor = MockDoctor(); + platform = MockPlatform(); + shorebirdRoot = Directory.systemTemp.createTempSync(); + projectRoot = Directory.systemTemp.createTempSync(); + auth = MockAuth(); + operatingSystemInterface = MockOperatingSystemInterface(); + progress = MockProgress(); + logger = MockLogger(); + ios = MockIos(); + flutterBuildProcessResult = MockProcessResult(); + flutterPubGetProcessResult = MockProcessResult(); + flutterValidator = MockShorebirdFlutterValidator(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); + shorebirdValidator = MockShorebirdValidator(); + xcodeBuild = MockXcodeBuild(); - verify( + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); + when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision); + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); + when( + () => shorebirdEnv.copyWith( + flutterRevisionOverride: any(named: 'flutterRevisionOverride'), + ), + ).thenAnswer((invocation) { + when(() => shorebirdEnv.flutterRevision).thenReturn( + invocation.namedArguments[#flutterRevisionOverride] as String, + ); + return shorebirdEnv; + }); + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterVersionAndRevision); + when( + () => shorebirdFlutter.installRevision( + revision: any(named: 'revision'), + ), + ).thenAnswer((_) async => {}); + when( () => shorebirdProcess.run( 'flutter', - any( - that: containsAllInOrder( - [ - 'build', - 'ipa', - '--release', - '--no-codesign', - ], - ), - ), - runInShell: true, + ['--no-version-check', 'pub', 'get', '--offline'], + runInShell: any(named: 'runInShell'), + useVendedFlutter: false, + ), + ).thenAnswer((_) async => flutterPubGetProcessResult); + when( + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => flutterBuildProcessResult); + when(() => argResults['arch']).thenReturn(arch); + when(() => argResults['codesign']).thenReturn(true); + when(() => argResults['platform']).thenReturn(releasePlatform); + when(() => argResults.rest).thenReturn([]); + when(() => argResults.wasParsed(any())).thenReturn(true); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => logger.progress(any())).thenReturn(progress); + when(() => logger.confirm(any())).thenReturn(true); + when( + () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), + ).thenReturn(version); + when(() => ios.exportOptionsPlistFromArgs(argResults)).thenReturn( + File('.'), + ); + when( + () => operatingSystemInterface.which('flutter'), + ).thenReturn('/path/to/flutter'); + when(() => platform.operatingSystem).thenReturn(operatingSystem); + when(() => platform.operatingSystemVersion) + .thenReturn(operatingSystemVersion); + when( + () => flutterBuildProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => flutterPubGetProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => codePushClientWrapper.getApp(appId: any(named: 'appId')), + ).thenAnswer((_) async => appMetadata); + when( + () => codePushClientWrapper.maybeGetRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => null); + when( + () => codePushClientWrapper.ensureReleaseIsNotActive( + release: any(named: 'release'), + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async => {}); + when( + () => codePushClientWrapper.createRelease( + appId: any(named: 'appId'), + version: any(named: 'version'), + flutterRevision: any(named: 'flutterRevision'), + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClientWrapper.createIosReleaseArtifacts( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + xcarchivePath: any(named: 'xcarchivePath'), + runnerPath: any(named: 'runnerPath'), + isCodesigned: any(named: 'isCodesigned'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClientWrapper.updateReleaseStatus( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + platform: any(named: 'platform'), + status: any(named: 'status'), + metadata: any(named: 'metadata'), + ), + ).thenAnswer((_) async => {}); + + when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + supportedOperatingSystems: any(named: 'supportedOperatingSystems'), + ), + ).thenAnswer((_) async {}); + when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion); + + command = runWithOverrides(ReleaseIosCommand.new) + ..testArgResults = argResults; + }); + + test('supports alpha alias', () { + expect(command.aliases, contains('ios-alpha')); + }); + + test('has a description', () { + expect(command.description, isNotEmpty); + }); + + test('exits when validation fails', () 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(command.run), + completion(equals(exception.exitCode.code)), + ); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: any(named: 'validators'), + supportedOperatingSystems: {Platform.macOS}, ), ).called(1); }); - group('when build directory has non-default structure', () { - test('prints error and exits with code 70 if xcarchive does not exist', - () async { - setUpProjectRoot(); - Directory( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - ), - ).deleteSync(recursive: true); + group('when obfuscate flag is passed', () { + setUp(() { + when(() => argResults.rest).thenReturn(['--obfuscate']); + }); + test('prints error and exits with usage code', () async { final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); + expect(exitCode, equals(ExitCode.usage.code)); verify( - () => logger.err('Unable to find .xcarchive directory'), + () => logger.err( + 'Shorebird does not currently support obfuscation on iOS.'), + ).called(1); + }); + }); + + group('when codesign is disabled', () { + setUp(() { + when(() => argResults['codesign']).thenReturn(false); + }); + + test('prints instructions to manually codesign', () async { + setUpProjectRoot(); + await runWithOverrides(command.run); + + verify( + () => logger.info( + '''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''', + ), ).called(1); }); - test( - '''prints error and exits with code 70 if .app directory does not exist''', - () async { + test('builds without codesigning', () async { setUpProjectRoot(); - Directory( - p.join( + await runWithOverrides(command.run); + + verify( + () => shorebirdProcess.run( + 'flutter', + any( + that: containsAllInOrder( + [ + 'build', + 'ipa', + '--release', + '--no-codesign', + ], + ), + ), + runInShell: true, + ), + ).called(1); + }); + + group('when build directory has non-default structure', () { + test( + 'prints error and exits with code 70 if xcarchive does not exist', + () async { + setUpProjectRoot(); + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + ), + ).deleteSync(recursive: true); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err('Unable to find .xcarchive directory'), + ).called(1); + }); + + test( + '''prints error and exits with code 70 if .app directory does not exist''', + () async { + setUpProjectRoot(); + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + 'Products', + 'Applications', + ), + ).deleteSync(recursive: true); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify(() => logger.err('Unable to find .app directory')).called(1); + }); + + test( + '''finds .xcarchive and .app when they do not have the default "Runner" name''', + () async { + setUpProjectRoot(); + final archivePath = p.join( projectRoot.path, 'build', 'ios', 'archive', + ); + final applicationsPath = p.join( + archivePath, 'Runner.xcarchive', 'Products', 'Applications', - ), - ).deleteSync(recursive: true); + ); + Directory(p.join(applicationsPath, 'Runner.app')).renameSync( + p.join( + applicationsPath, + 'شوربيرد | Shorebird.app', + ), + ); + Directory(p.join(archivePath, 'Runner.xcarchive')).renameSync( + p.join( + archivePath, + 'شوربيرد | Shorebird.xcarchive', + ), + ); - final exitCode = await runWithOverrides(command.run); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify(() => logger.err('Unable to find .app directory')).called(1); + expect(exitCode, equals(ExitCode.success.code)); + }); }); - test( - '''finds .xcarchive and .app when they do not have the default "Runner" name''', - () async { + test('prints archive upload instructions on success', () async { setUpProjectRoot(); + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.success.code)); final archivePath = p.join( projectRoot.path, 'build', 'ios', 'archive', - ); - final applicationsPath = p.join( - archivePath, 'Runner.xcarchive', - 'Products', - 'Applications', ); - Directory(p.join(applicationsPath, 'Runner.app')).renameSync( - p.join( - applicationsPath, - 'شوربيرد | Shorebird.app', - ), - ); - Directory(p.join(archivePath, 'Runner.xcarchive')).renameSync( - p.join( - archivePath, - 'شوربيرد | Shorebird.xcarchive', - ), - ); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - }); - }); - - test('prints archive upload instructions on success', () async { - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - final archivePath = p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - ); - verify( - () => logger.info( - any( - that: stringContainsInOrder( - [ - 'Your next step is to submit the archive', - p.relative(archivePath), - 'to the App Store using Xcode.', - 'You can open the archive in Xcode by running', - 'open ${p.relative(archivePath)}', - '''Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.''', - ], - ), - ), - ), - ).called(1); - }); - - test('creates unsigned release artifacts', () async { - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.success.code)); - - verify( - () => codePushClientWrapper.createIosReleaseArtifacts( - appId: appId, - releaseId: release.id, - xcarchivePath: any( - named: 'xcarchivePath', - that: endsWith('.xcarchive'), - ), - runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), - isCodesigned: false, - ), - ).called(1); - }); - }); - - group('when exportOptionsPlistFromArgs throws exception', () { - setUp(() { - when(() => ios.exportOptionsPlistFromArgs(argResults)) - .thenThrow(ArgumentError('bad args')); - }); - - test('logs error and exits with usage code', () async { - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.usage.code)); - verify(() => logger.err('Invalid argument(s): bad args')).called(1); - }); - }); - - group('when flutter-version is provided', () { - const flutterVersion = '3.19.5'; - setUp(() { - when(() => argResults['flutter-version']).thenReturn(flutterVersion); - }); - - group('when unable to determine flutter revision', () { - final exception = Exception('oops'); - setUp(() { - when( - () => shorebirdFlutter.getRevisionForVersion(any()), - ).thenThrow(exception); - }); - - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); verify( - () => logger.err( - ''' -Unable to determine revision for Flutter version: $flutterVersion. -$exception''', - ), - ).called(1); - }); - }); - - group('when flutter version is too old', () { - setUp(() { - when(() => argResults['flutter-version']).thenReturn('3.16.3'); - }); - - test('prints error log and exits with code 64 (usage)', () async { - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.usage.code)); - verify( - () => logger.err( - '''iOS releases are not supported with Flutter versions older than 3.19.5.''', - ), - ).called(1); - }); - }); - - group('when flutter version is not supported', () { - setUp(() { - when( - () => shorebirdFlutter.getRevisionForVersion(any()), - ).thenAnswer((_) async => null); - }); - - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - any(that: contains('Version $flutterVersion not found.')), - ), - ).called(1); - }); - }); - - group('when flutter version is supported', () { - const revision = '771d07b2cf'; - setUp(() { - setUpProjectRoot(); - when( - () => shorebirdFlutter.getRevisionForVersion(any()), - ).thenAnswer((_) async => revision); - }); - - test('uses specified flutter version to build', () async { - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - // Ensure we're using the correct flutter version. - expect(shorebirdEnv.flutterRevision, equals(revision)); - return flutterBuildProcessResult; - }); - - await runWithOverrides(command.run); - - verify(() => shorebirdFlutter.installRevision(revision: revision)) - .called(1); - verify( - () => codePushClientWrapper.createRelease( - appId: appId, - version: version, - flutterRevision: revision, - platform: releasePlatform, - ), - ).called(1); - verify( - () => codePushClientWrapper.updateReleaseStatus( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - status: ReleaseStatus.active, - metadata: const UpdateReleaseMetadata( - releasePlatform: releasePlatform, - flutterVersionOverride: flutterVersion, - generatedApks: false, - environment: BuildEnvironmentMetadata( - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - shorebirdVersion: packageVersion, - xcodeVersion: xcodeVersion, + () => logger.info( + any( + that: stringContainsInOrder( + [ + 'Your next step is to submit the archive', + p.relative(archivePath), + 'to the App Store using Xcode.', + 'You can open the archive in Xcode by running', + 'open ${p.relative(archivePath)}', + '''Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.''', + ], ), ), ), ).called(1); }); - group('when flutter version install fails', () { + test('creates unsigned release artifacts', () async { + setUpProjectRoot(); + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.success.code)); + + verify( + () => codePushClientWrapper.createIosReleaseArtifacts( + appId: appId, + releaseId: release.id, + xcarchivePath: any( + named: 'xcarchivePath', + that: endsWith('.xcarchive'), + ), + runnerPath: + any(named: 'runnerPath', that: endsWith('Runner.app')), + isCodesigned: false, + ), + ).called(1); + }); + }); + + group('when exportOptionsPlistFromArgs throws exception', () { + setUp(() { + when(() => ios.exportOptionsPlistFromArgs(argResults)) + .thenThrow(ArgumentError('bad args')); + }); + + test('logs error and exits with usage code', () async { + setUpProjectRoot(); + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.usage.code)); + verify(() => logger.err('Invalid argument(s): bad args')).called(1); + }); + }); + + group('when flutter-version is provided', () { + const flutterVersion = '3.19.5'; + setUp(() { + when(() => argResults['flutter-version']).thenReturn(flutterVersion); + }); + + group('when unable to determine flutter revision', () { + final exception = Exception('oops'); setUp(() { when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenThrow(Exception('oops')); + () => shorebirdFlutter.getRevisionForVersion(any()), + ).thenThrow(exception); }); test('exits with code 70', () async { - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.software.code)); verify( - () => shorebirdFlutter.installRevision(revision: revision), + () => logger.err( + ''' +Unable to determine revision for Flutter version: $flutterVersion. +$exception''', + ), ).called(1); }); }); + + group('when flutter version is too old', () { + setUp(() { + when(() => argResults['flutter-version']).thenReturn('3.16.3'); + }); + + test('prints error log and exits with code 64 (usage)', () async { + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '''iOS releases are not supported with Flutter versions older than 3.19.5.''', + ), + ).called(1); + }); + }); + + group('when flutter version is not supported', () { + setUp(() { + when( + () => shorebirdFlutter.getRevisionForVersion(any()), + ).thenAnswer((_) async => null); + }); + + test('exits with code 70', () async { + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err( + any(that: contains('Version $flutterVersion not found.')), + ), + ).called(1); + }); + }); + + group('when flutter version is supported', () { + const revision = '771d07b2cf'; + setUp(() { + setUpProjectRoot(); + when( + () => shorebirdFlutter.getRevisionForVersion(any()), + ).thenAnswer((_) async => revision); + }); + + test('uses specified flutter version to build', () async { + when( + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async { + // Ensure we're using the correct flutter version. + expect(shorebirdEnv.flutterRevision, equals(revision)); + return flutterBuildProcessResult; + }); + + await runWithOverrides(command.run); + + verify(() => shorebirdFlutter.installRevision(revision: revision)) + .called(1); + verify( + () => codePushClientWrapper.createRelease( + appId: appId, + version: version, + flutterRevision: revision, + platform: releasePlatform, + ), + ).called(1); + verify( + () => codePushClientWrapper.updateReleaseStatus( + appId: appId, + releaseId: release.id, + platform: releasePlatform, + status: ReleaseStatus.active, + metadata: const UpdateReleaseMetadata( + releasePlatform: releasePlatform, + flutterVersionOverride: flutterVersion, + generatedApks: false, + environment: BuildEnvironmentMetadata( + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + xcodeVersion: xcodeVersion, + ), + ), + ), + ).called(1); + }); + + group('when flutter version install fails', () { + setUp(() { + when( + () => shorebirdFlutter.installRevision( + revision: any(named: 'revision'), + ), + ).thenThrow(Exception('oops')); + }); + + test('exits with code 70', () async { + final result = await runWithOverrides(command.run); + + expect(result, equals(ExitCode.software.code)); + verify( + () => shorebirdFlutter.installRevision(revision: revision), + ).called(1); + }); + }); + }); }); - }); - test('exits with code 70 when build fails with non-zero exit code', - () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); + test('exits with code 70 when build fails with non-zero exit code', + () async { + when(() => flutterBuildProcessResult.exitCode).thenReturn(1); + when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); + setUpProjectRoot(); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail(any(that: contains('Failed to build'))), - ).called(1); - }); + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => progress.fail(any(that: contains('Failed to build'))), + ).called(1); + }); - test('exits with code 70 when building fails with 0 exit code', () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(0); - when(() => flutterBuildProcessResult.stderr).thenReturn(''' + test('exits with code 70 when building fails with 0 exit code', () async { + when(() => flutterBuildProcessResult.exitCode).thenReturn(0); + when(() => flutterBuildProcessResult.stderr).thenReturn(''' Encountered error while creating the IPA: error: exportArchive: Communication with Apple failed error: exportArchive: No signing certificate "iOS Distribution" found @@ -737,344 +741,352 @@ error: exportArchive: Communication with Apple failed error: exportArchive: No signing certificate "iOS Distribution" found '''); - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); + setUpProjectRoot(); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail(any(that: contains('Failed to build'))), - ).called(1); - verify( - () => logger.err(''' + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => progress.fail(any(that: contains('Failed to build'))), + ).called(1); + verify( + () => logger.err(''' Communication with Apple failed No signing certificate "iOS Distribution" found Team "My Team" does not have permission to create "iOS App Store" provisioning profiles. No profiles for 'com.example.co' were found'''), - ).called(1); - }); + ).called(1); + }); - test('exits with code 70 when release version cannot be determined', - () async { - setUpProjectRoot(); - final file = File( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Info.plist', - ), - ) - ..createSync(recursive: true) - ..writeAsStringSync(emptyPlistContent); - final exitCode = await runWithOverrides(command.run); + test('exits with code 70 when release version cannot be determined', + () async { + setUpProjectRoot(); + final file = File( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + 'Info.plist', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync(emptyPlistContent); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - 'Failed to determine release version from ${file.path}: ' - 'Exception: Could not determine release version', - ), - ).called(1); - }); + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err( + 'Failed to determine release version from ${file.path}: ' + 'Exception: Could not determine release version', + ), + ).called(1); + }); - test('aborts when user opts out', () async { - when(() => logger.confirm(any())).thenReturn(false); - setUpProjectRoot(); + test('aborts when user opts out', () async { + when(() => logger.confirm(any())).thenReturn(false); + setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verify(() => logger.info('Aborting.')).called(1); - verifyNever( - () => codePushClientWrapper.createIosReleaseArtifacts( - appId: appId, - releaseId: release.id, - xcarchivePath: - any(named: 'xcarchivePath', that: endsWith('.xcarchive')), - runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), - isCodesigned: any(named: 'isCodesigned'), - ), - ); - }); + expect(exitCode, ExitCode.success.code); + verify(() => logger.info('Aborting.')).called(1); + verifyNever( + () => codePushClientWrapper.createIosReleaseArtifacts( + appId: appId, + releaseId: release.id, + xcarchivePath: + any(named: 'xcarchivePath', that: endsWith('.xcarchive')), + runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), + isCodesigned: any(named: 'isCodesigned'), + ), + ); + }); - test('exits with code 70 if Info.plist does not exist', () async { - setUpProjectRoot(); - final infoPlistFile = File( - p.join( - projectRoot.path, - 'build', - 'ios', - 'archive', - 'Runner.xcarchive', - 'Info.plist', - ), - )..deleteSync(recursive: true); + test('exits with code 70 if Info.plist does not exist', () async { + setUpProjectRoot(); + final infoPlistFile = File( + p.join( + projectRoot.path, + 'build', + 'ios', + 'archive', + 'Runner.xcarchive', + 'Info.plist', + ), + )..deleteSync(recursive: true); - final exitCode = await runWithOverrides(command.run); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err('No Info.plist file found at ${infoPlistFile.path}.'), - ).called(1); - }); + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => + logger.err('No Info.plist file found at ${infoPlistFile.path}.'), + ).called(1); + }); - test('exits with code 70 if build directory does not exist', () async { - setUpProjectRoot(); - Directory(p.join(projectRoot.path, 'build')).deleteSync(recursive: true); + test('exits with code 70 if build directory does not exist', () async { + setUpProjectRoot(); + Directory(p.join(projectRoot.path, 'build')) + .deleteSync(recursive: true); - final exitCode = await runWithOverrides(command.run); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify(() => logger.err('Unable to find .xcarchive directory')).called(1); - }); + expect(exitCode, equals(ExitCode.software.code)); + verify(() => logger.err('Unable to find .xcarchive directory')) + .called(1); + }); - test('exits with code 70 if ipa build directory does not exist', () async { - setUpProjectRoot(); - final ipaDirectory = Directory( - p.join(projectRoot.path, 'build', 'ios', 'ipa'), - )..deleteSync(recursive: true); + test('exits with code 70 if ipa build directory does not exist', + () async { + setUpProjectRoot(); + final ipaDirectory = Directory( + p.join(projectRoot.path, 'build', 'ios', 'ipa'), + )..deleteSync(recursive: true); - final exitCode = await runWithOverrides(command.run); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - any( - that: stringContainsInOrder( - [ + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err( + any( + that: stringContainsInOrder( + [ + 'Could not find ipa file', + 'No directory found at ${ipaDirectory.path}', + ], + ), + ), + ), + ).called(1); + }); + + test('exits with code 70 if ipa file does not exist', () async { + setUpProjectRoot(); + File(p.join(projectRoot.path, ipaPath)).deleteSync(recursive: true); + + final exitCode = await runWithOverrides(command.run); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err( + any( + that: stringContainsInOrder([ 'Could not find ipa file', - 'No directory found at ${ipaDirectory.path}', - ], + 'No .ipa files found in', + p.join('build', 'ios', 'ipa'), + ]), ), ), - ), - ).called(1); - }); + ).called(1); + }); - test('exits with code 70 if ipa file does not exist', () async { - setUpProjectRoot(); - File(p.join(projectRoot.path, ipaPath)).deleteSync(recursive: true); + test('exits with code 70 if more than one ipa file is found', () async { + setUpProjectRoot(); + File( + p.join(projectRoot.path, 'build/ios/ipa/Runner2.ipa'), + ).createSync(recursive: true); - final exitCode = await runWithOverrides(command.run); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - any( - that: stringContainsInOrder([ - 'Could not find ipa file', - 'No .ipa files found in', - p.join('build', 'ios', 'ipa'), - ]), - ), - ), - ).called(1); - }); - - test('exits with code 70 if more than one ipa file is found', () async { - setUpProjectRoot(); - File( - p.join(projectRoot.path, 'build/ios/ipa/Runner2.ipa'), - ).createSync(recursive: true); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - any( - that: stringContainsInOrder([ - 'Could not find ipa file', - 'More than one .ipa file found in', - p.join('build', 'ios', 'ipa'), - ]), - ), - ), - ).called(1); - }); - - test('succeeds when release is successful', () async { - setUpProjectRoot(); - - final exitCode = await runWithOverrides(command.run); - - verify(() => logger.success('\n✅ Published Release $version!')).called(1); - verify( - () => logger.info( - any( - that: stringContainsInOrder( - [ - 'Your next step is to upload your app to App Store Connect.', - p.join('build', 'ios', 'ipa', 'Runner.ipa'), - ], + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err( + any( + that: stringContainsInOrder([ + 'Could not find ipa file', + 'More than one .ipa file found in', + p.join('build', 'ios', 'ipa'), + ]), ), ), - ), - ).called(1); - verify( - () => codePushClientWrapper.createIosReleaseArtifacts( - appId: appId, - releaseId: release.id, - xcarchivePath: - any(named: 'xcarchivePath', that: endsWith('.xcarchive')), - runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), - isCodesigned: true, - ), - ).called(1); - verify( - () => codePushClientWrapper.updateReleaseStatus( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - status: ReleaseStatus.active, - metadata: const UpdateReleaseMetadata( - releasePlatform: releasePlatform, - flutterVersionOverride: null, - generatedApks: false, - environment: BuildEnvironmentMetadata( - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - shorebirdVersion: packageVersion, - xcodeVersion: xcodeVersion, + ).called(1); + }); + + test('succeeds when release is successful', () async { + setUpProjectRoot(); + + final exitCode = await runWithOverrides(command.run); + + verify(() => logger.success('\n✅ Published Release $version!')) + .called(1); + verify( + () => logger.info( + any( + that: stringContainsInOrder( + [ + 'Your next step is to upload your app to App Store Connect.', + p.join('build', 'ios', 'ipa', 'Runner.ipa'), + ], + ), ), ), - ), - ).called(1); - expect(exitCode, ExitCode.success.code); - }); + ).called(1); + verify( + () => codePushClientWrapper.createIosReleaseArtifacts( + appId: appId, + releaseId: release.id, + xcarchivePath: + any(named: 'xcarchivePath', that: endsWith('.xcarchive')), + runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), + isCodesigned: true, + ), + ).called(1); + verify( + () => codePushClientWrapper.updateReleaseStatus( + appId: appId, + releaseId: release.id, + platform: releasePlatform, + status: ReleaseStatus.active, + metadata: const UpdateReleaseMetadata( + releasePlatform: releasePlatform, + flutterVersionOverride: null, + generatedApks: false, + environment: BuildEnvironmentMetadata( + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + xcodeVersion: xcodeVersion, + ), + ), + ), + ).called(1); + expect(exitCode, ExitCode.success.code); + }); - test('runs flutter pub get with system flutter after successful build', - () async { - setUpProjectRoot(); + test('runs flutter pub get with system flutter after successful build', + () async { + setUpProjectRoot(); - await runWithOverrides(command.run); + await runWithOverrides(command.run); - verify( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).called(1); - }); + verify( + () => shorebirdProcess.run( + 'flutter', + ['--no-version-check', 'pub', 'get', '--offline'], + runInShell: any(named: 'runInShell'), + useVendedFlutter: false, + ), + ).called(1); + }); - test( - 'succeeds when release is successful ' - 'with flavors and target', () async { - const flavor = 'development'; - final target = p.join('lib', 'main_development.dart'); - when(() => argResults['flavor']).thenReturn(flavor); - when(() => argResults['target']).thenReturn(target); - setUpProjectRoot(); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync(''' + test( + 'succeeds when release is successful ' + 'with flavors and target', () async { + const flavor = 'development'; + final target = p.join('lib', 'main_development.dart'); + when(() => argResults['flavor']).thenReturn(flavor); + when(() => argResults['target']).thenReturn(target); + setUpProjectRoot(); + File( + p.join(projectRoot.path, 'shorebird.yaml'), + ).writeAsStringSync(''' app_id: productionAppId flavors: development: $appId'''); - final exitCode = await runWithOverrides(command.run); + final exitCode = await runWithOverrides(command.run); - verify(() => logger.success('\n✅ Published Release $version!')).called(1); - verify( - () => logger.info( - any( - that: stringContainsInOrder( - [ - 'Your next step is to upload your app to App Store Connect.', - p.join('build', 'ios', 'ipa', 'Runner.ipa'), - ], + verify(() => logger.success('\n✅ Published Release $version!')) + .called(1); + verify( + () => logger.info( + any( + that: stringContainsInOrder( + [ + 'Your next step is to upload your app to App Store Connect.', + p.join('build', 'ios', 'ipa', 'Runner.ipa'), + ], + ), ), ), - ), - ).called(1); - verify( - () => codePushClientWrapper.createIosReleaseArtifacts( - appId: appId, - releaseId: release.id, - xcarchivePath: - any(named: 'xcarchivePath', that: endsWith('.xcarchive')), - runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), - isCodesigned: true, - ), - ).called(1); - expect(exitCode, ExitCode.success.code); - }); + ).called(1); + verify( + () => codePushClientWrapper.createIosReleaseArtifacts( + appId: appId, + releaseId: release.id, + xcarchivePath: + any(named: 'xcarchivePath', that: endsWith('.xcarchive')), + runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), + isCodesigned: true, + ), + ).called(1); + expect(exitCode, ExitCode.success.code); + }); - test('does not create new release if existing release is present', - () async { - when( - () => codePushClientWrapper.maybeGetRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => release); - setUpProjectRoot(); + test('does not create new release if existing release is present', + () async { + when( + () => codePushClientWrapper.maybeGetRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => release); + setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, ExitCode.success.code); - verifyNever( - () => codePushClientWrapper.createRelease( - appId: any(named: 'appId'), - version: any(named: 'version'), - flutterRevision: any(named: 'flutterRevision'), - platform: any(named: 'platform'), - ), - ); - verify( - () => codePushClientWrapper.createIosReleaseArtifacts( - appId: appId, - releaseId: release.id, - xcarchivePath: - any(named: 'xcarchivePath', that: endsWith('.xcarchive')), - runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), - isCodesigned: true, - ), - ).called(1); - verify( - () => codePushClientWrapper.updateReleaseStatus( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - status: ReleaseStatus.active, - metadata: any(named: 'metadata'), - ), - ).called(1); - }); + expect(exitCode, ExitCode.success.code); + verifyNever( + () => codePushClientWrapper.createRelease( + appId: any(named: 'appId'), + version: any(named: 'version'), + flutterRevision: any(named: 'flutterRevision'), + platform: any(named: 'platform'), + ), + ); + verify( + () => codePushClientWrapper.createIosReleaseArtifacts( + appId: appId, + releaseId: release.id, + xcarchivePath: + any(named: 'xcarchivePath', that: endsWith('.xcarchive')), + runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), + isCodesigned: true, + ), + ).called(1); + verify( + () => codePushClientWrapper.updateReleaseStatus( + appId: appId, + releaseId: release.id, + platform: releasePlatform, + status: ReleaseStatus.active, + metadata: any(named: 'metadata'), + ), + ).called(1); + }); - test('does not provide export options when codesign is false', () async { - when(() => argResults['codesign']).thenReturn(false); - setUpProjectRoot(); + test('does not provide export options when codesign is false', () async { + when(() => argResults['codesign']).thenReturn(false); + setUpProjectRoot(); - await runWithOverrides(command.run); + await runWithOverrides(command.run); - final capturedArgs = verify( - () => shorebirdProcess.run( - 'flutter', - captureAny(), - runInShell: any(named: 'runInShell'), - ), - ).captured.first as List; - expect( - capturedArgs - .whereType() - .firstWhereOrNull((arg) => arg.contains('export-options-plist')), - isNull, - ); - }); + final capturedArgs = verify( + () => shorebirdProcess.run( + 'flutter', + captureAny(), + runInShell: any(named: 'runInShell'), + ), + ).captured.first as List; + expect( + capturedArgs + .whereType() + .firstWhereOrNull((arg) => arg.contains('export-options-plist')), + isNull, + ); + }); - test('does not prompt if unable to accept user input', () async { - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); - setUpProjectRoot(); + test('does not prompt if unable to accept user input', () async { + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); + setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.success.code)); - verifyNever(() => logger.confirm(any())); - }); - }); + expect(exitCode, equals(ExitCode.success.code)); + verifyNever(() => logger.confirm(any())); + }); + }, + testOn: 'mac-os', + ); } diff --git a/packages/shorebird_cli/test/src/commands/release/release_ios_framework_command_test.dart b/packages/shorebird_cli/test/src/commands/release/release_ios_framework_command_test.dart index ac285c05..f473496c 100644 --- a/packages/shorebird_cli/test/src/commands/release/release_ios_framework_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release/release_ios_framework_command_test.dart @@ -28,36 +28,38 @@ import '../../fakes.dart'; import '../../mocks.dart'; void main() { - group(ReleaseIosFrameworkCommand, () { - const appId = 'test-app-id'; - const shorebirdYaml = ShorebirdYaml(appId: appId); - const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; - const flutterVersionAndRevision = '3.10.6 (83305b5088)'; - const versionName = '1.2.3'; - const versionCode = '1'; - const version = '$versionName+$versionCode'; - const operatingSystem = 'macOS'; - const operatingSystemVersion = '11.0.0'; - const xcodeVersion = '12.0'; - const appDisplayName = 'Test App'; - const releasePlatform = ReleasePlatform.ios; - final appMetadata = AppMetadata( - appId: appId, - displayName: appDisplayName, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - final release = Release( - id: 0, - appId: appId, - version: version, - flutterRevision: flutterRevision, - displayName: '1.2.3+1', - platformStatuses: {}, - createdAt: DateTime(2023), - updatedAt: DateTime(2023), - ); - const pubspecYamlContent = ''' + group( + ReleaseIosFrameworkCommand, + () { + const appId = 'test-app-id'; + const shorebirdYaml = ShorebirdYaml(appId: appId); + const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; + const flutterVersionAndRevision = '3.10.6 (83305b5088)'; + const versionName = '1.2.3'; + const versionCode = '1'; + const version = '$versionName+$versionCode'; + const operatingSystem = 'macOS'; + const operatingSystemVersion = '11.0.0'; + const xcodeVersion = '12.0'; + const appDisplayName = 'Test App'; + const releasePlatform = ReleasePlatform.ios; + final appMetadata = AppMetadata( + appId: appId, + displayName: appDisplayName, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ); + final release = Release( + id: 0, + appId: appId, + version: version, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + platformStatuses: {}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ); + const pubspecYamlContent = ''' name: example version: $version environment: @@ -67,523 +69,527 @@ flutter: assets: - shorebird.yaml'''; - late ArgResults argResults; - late CodePushClientWrapper codePushClientWrapper; - late Directory shorebirdRoot; - late Directory projectRoot; - late Doctor doctor; - late Platform platform; - late Auth auth; - late Progress progress; - late Logger logger; - late OperatingSystemInterface operatingSystemInterface; - late ShorebirdProcessResult flutterBuildProcessResult; - late ShorebirdProcessResult flutterPubGetProcessResult; - late ShorebirdFlutterValidator flutterValidator; - late ShorebirdProcess shorebirdProcess; - late ShorebirdEnv shorebirdEnv; - late ShorebirdFlutter shorebirdFlutter; - late ShorebirdValidator shorebirdValidator; - late XcodeBuild xcodeBuild; - late ReleaseIosFrameworkCommand command; + late ArgResults argResults; + late CodePushClientWrapper codePushClientWrapper; + late Directory shorebirdRoot; + late Directory projectRoot; + late Doctor doctor; + late Platform platform; + late Auth auth; + late Progress progress; + late Logger logger; + late OperatingSystemInterface operatingSystemInterface; + late ShorebirdProcessResult flutterBuildProcessResult; + late ShorebirdProcessResult flutterPubGetProcessResult; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdValidator shorebirdValidator; + late XcodeBuild xcodeBuild; + late ReleaseIosFrameworkCommand command; - R runWithOverrides(R Function() body) { - return runScoped( - body, - values: { - authRef.overrideWith(() => auth), - codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), - doctorRef.overrideWith(() => doctor), - loggerRef.overrideWith(() => logger), - osInterfaceRef.overrideWith(() => operatingSystemInterface), - platformRef.overrideWith(() => platform), - processRef.overrideWith(() => shorebirdProcess), - shorebirdEnvRef.overrideWith(() => shorebirdEnv), - shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), - shorebirdValidatorRef.overrideWith(() => shorebirdValidator), - xcodeBuildRef.overrideWith(() => xcodeBuild), - }, - ); - } - - void setUpProjectRoot() { - File( - p.join(projectRoot.path, 'pubspec.yaml'), - ).writeAsStringSync(pubspecYamlContent); - File( - p.join(projectRoot.path, 'shorebird.yaml'), - ).writeAsStringSync('app_id: $appId'); - // Create an xcframework in the release directory to simulate running this - // command a subsequent time. - Directory(p.join(projectRoot.path, 'release', 'Flutter.xcframework')) - .createSync(recursive: true); - Directory( - p.join( - projectRoot.path, - 'build', - 'ios', - 'framework', - 'Release', - 'Flutter.xcframework', - ), - ).createSync(recursive: true); - } - - setUpAll(() { - registerFallbackValue(Directory('')); - registerFallbackValue(ReleasePlatform.ios); - registerFallbackValue(ReleaseStatus.draft); - registerFallbackValue(FakeRelease()); - registerFallbackValue(FakeShorebirdProcess()); - }); - - setUp(() { - argResults = MockArgResults(); - codePushClientWrapper = MockCodePushClientWrapper(); - doctor = MockDoctor(); - platform = MockPlatform(); - shorebirdRoot = Directory.systemTemp.createTempSync(); - projectRoot = Directory.systemTemp.createTempSync(); - auth = MockAuth(); - progress = MockProgress(); - logger = MockLogger(); - operatingSystemInterface = MockOperatingSystemInterface(); - flutterBuildProcessResult = MockProcessResult(); - flutterPubGetProcessResult = MockProcessResult(); - flutterValidator = MockShorebirdFlutterValidator(); - shorebirdProcess = MockShorebirdProcess(); - shorebirdEnv = MockShorebirdEnv(); - shorebirdFlutter = MockShorebirdFlutter(); - shorebirdValidator = MockShorebirdValidator(); - xcodeBuild = MockXcodeBuild(); - - when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); - when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); - when( - () => shorebirdEnv.copyWith( - flutterRevisionOverride: any(named: 'flutterRevisionOverride'), - ), - ).thenAnswer((invocation) { - when(() => shorebirdEnv.flutterRevision).thenReturn( - invocation.namedArguments[#flutterRevisionOverride] as String, + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + authRef.overrideWith(() => auth), + codePushClientWrapperRef.overrideWith(() => codePushClientWrapper), + doctorRef.overrideWith(() => doctor), + loggerRef.overrideWith(() => logger), + osInterfaceRef.overrideWith(() => operatingSystemInterface), + platformRef.overrideWith(() => platform), + processRef.overrideWith(() => shorebirdProcess), + shorebirdEnvRef.overrideWith(() => shorebirdEnv), + shorebirdFlutterRef.overrideWith(() => shorebirdFlutter), + shorebirdValidatorRef.overrideWith(() => shorebirdValidator), + xcodeBuildRef.overrideWith(() => xcodeBuild), + }, ); - return shorebirdEnv; + } + + void setUpProjectRoot() { + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File( + p.join(projectRoot.path, 'shorebird.yaml'), + ).writeAsStringSync('app_id: $appId'); + // Create an xcframework in the release directory to simulate running this + // command a subsequent time. + Directory(p.join(projectRoot.path, 'release', 'Flutter.xcframework')) + .createSync(recursive: true); + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'Flutter.xcframework', + ), + ).createSync(recursive: true); + } + + setUpAll(() { + registerFallbackValue(Directory('')); + registerFallbackValue(ReleasePlatform.ios); + registerFallbackValue(ReleaseStatus.draft); + registerFallbackValue(FakeRelease()); + registerFallbackValue(FakeShorebirdProcess()); }); - when( - () => shorebirdEnv.getShorebirdProjectRoot(), - ).thenReturn(projectRoot); - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); - when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision); - when( - () => shorebirdFlutter.getVersionAndRevision(), - ).thenAnswer((_) async => flutterVersionAndRevision); - when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenAnswer((_) async => {}); - when( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).thenAnswer((_) async => flutterPubGetProcessResult); - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async => flutterBuildProcessResult); - when(() => argResults['release-version']).thenReturn(version); - when(() => argResults.rest).thenReturn([]); - when(() => auth.isAuthenticated).thenReturn(true); - when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]); - when( - () => flutterBuildProcessResult.exitCode, - ).thenReturn(ExitCode.success.code); - when(() => flutterPubGetProcessResult.exitCode) - .thenReturn(ExitCode.success.code); - when(() => logger.progress(any())).thenReturn(progress); - when(() => logger.confirm(any())).thenReturn(true); - when(() => operatingSystemInterface.which('flutter')) - .thenReturn('/path/to/flutter'); - when(() => platform.operatingSystem).thenReturn(operatingSystem); - when(() => platform.operatingSystemVersion) - .thenReturn(operatingSystemVersion); - when( - () => codePushClientWrapper.getApp(appId: any(named: 'appId')), - ).thenAnswer((_) async => appMetadata); - when( - () => codePushClientWrapper.createIosFrameworkReleaseArtifacts( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - appFrameworkPath: any(named: 'appFrameworkPath'), - ), - ).thenAnswer((_) async => {}); - when( - () => codePushClientWrapper.createRelease( - appId: any(named: 'appId'), - version: any(named: 'version'), - flutterRevision: any(named: 'flutterRevision'), - platform: any(named: 'platform'), - ), - ).thenAnswer((_) async => release); - when( - () => codePushClientWrapper.maybeGetRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => null); - when( - () => codePushClientWrapper.ensureReleaseIsNotActive( - release: any(named: 'release'), - platform: any(named: 'platform'), - ), - ).thenAnswer((_) async => {}); - when( - () => codePushClientWrapper.updateReleaseStatus( - appId: any(named: 'appId'), - releaseId: any(named: 'releaseId'), - platform: any(named: 'platform'), - status: any(named: 'status'), - metadata: any(named: 'metadata'), - ), - ).thenAnswer((_) async => {}); - when( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), - checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), - validators: any(named: 'validators'), - supportedOperatingSystems: any(named: 'supportedOperatingSystems'), - ), - ).thenAnswer((_) async {}); - when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion); - command = runWithOverrides(ReleaseIosFrameworkCommand.new) - ..testArgResults = argResults; - }); - - test('supports alpha alias', () { - expect(command.aliases, contains('ios-framework-alpha')); - }); - - test('has a description', () { - expect(command.description, isNotEmpty); - }); - - test('exits when validation fails', () 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(command.run), - completion(equals(exception.exitCode.code)), - ); - verify( - () => shorebirdValidator.validatePreconditions( - checkUserIsAuthenticated: true, - checkShorebirdInitialized: true, - validators: any(named: 'validators'), - supportedOperatingSystems: {Platform.macOS}, - ), - ).called(1); - }); - - group('when flutter-version is provided', () { - const flutterVersion = '3.19.5'; setUp(() { - when(() => argResults['flutter-version']).thenReturn(flutterVersion); + argResults = MockArgResults(); + codePushClientWrapper = MockCodePushClientWrapper(); + doctor = MockDoctor(); + platform = MockPlatform(); + shorebirdRoot = Directory.systemTemp.createTempSync(); + projectRoot = Directory.systemTemp.createTempSync(); + auth = MockAuth(); + progress = MockProgress(); + logger = MockLogger(); + operatingSystemInterface = MockOperatingSystemInterface(); + flutterBuildProcessResult = MockProcessResult(); + flutterPubGetProcessResult = MockProcessResult(); + flutterValidator = MockShorebirdFlutterValidator(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); + shorebirdValidator = MockShorebirdValidator(); + xcodeBuild = MockXcodeBuild(); + + when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); + when(() => shorebirdEnv.shorebirdRoot).thenReturn(shorebirdRoot); + when( + () => shorebirdEnv.copyWith( + flutterRevisionOverride: any(named: 'flutterRevisionOverride'), + ), + ).thenAnswer((invocation) { + when(() => shorebirdEnv.flutterRevision).thenReturn( + invocation.namedArguments[#flutterRevisionOverride] as String, + ); + return shorebirdEnv; + }); + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(true); + when(() => shorebirdEnv.flutterRevision).thenReturn(flutterRevision); + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterVersionAndRevision); + when( + () => shorebirdFlutter.installRevision( + revision: any(named: 'revision'), + ), + ).thenAnswer((_) async => {}); + when( + () => shorebirdProcess.run( + 'flutter', + ['--no-version-check', 'pub', 'get', '--offline'], + runInShell: any(named: 'runInShell'), + useVendedFlutter: false, + ), + ).thenAnswer((_) async => flutterPubGetProcessResult); + when( + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => flutterBuildProcessResult); + when(() => argResults['release-version']).thenReturn(version); + when(() => argResults.rest).thenReturn([]); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => doctor.iosCommandValidators).thenReturn([flutterValidator]); + when( + () => flutterBuildProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when(() => flutterPubGetProcessResult.exitCode) + .thenReturn(ExitCode.success.code); + when(() => logger.progress(any())).thenReturn(progress); + when(() => logger.confirm(any())).thenReturn(true); + when(() => operatingSystemInterface.which('flutter')) + .thenReturn('/path/to/flutter'); + when(() => platform.operatingSystem).thenReturn(operatingSystem); + when(() => platform.operatingSystemVersion) + .thenReturn(operatingSystemVersion); + when( + () => codePushClientWrapper.getApp(appId: any(named: 'appId')), + ).thenAnswer((_) async => appMetadata); + when( + () => codePushClientWrapper.createIosFrameworkReleaseArtifacts( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + appFrameworkPath: any(named: 'appFrameworkPath'), + ), + ).thenAnswer((_) async => {}); + when( + () => codePushClientWrapper.createRelease( + appId: any(named: 'appId'), + version: any(named: 'version'), + flutterRevision: any(named: 'flutterRevision'), + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClientWrapper.maybeGetRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), + ), + ).thenAnswer((_) async => null); + when( + () => codePushClientWrapper.ensureReleaseIsNotActive( + release: any(named: 'release'), + platform: any(named: 'platform'), + ), + ).thenAnswer((_) async => {}); + when( + () => codePushClientWrapper.updateReleaseStatus( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + platform: any(named: 'platform'), + status: any(named: 'status'), + metadata: any(named: 'metadata'), + ), + ).thenAnswer((_) async => {}); + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + supportedOperatingSystems: any(named: 'supportedOperatingSystems'), + ), + ).thenAnswer((_) async {}); + when(() => xcodeBuild.version()).thenAnswer((_) async => xcodeVersion); + + command = runWithOverrides(ReleaseIosFrameworkCommand.new) + ..testArgResults = argResults; }); - group('when unable to determine flutter revision', () { - final exception = Exception('oops'); - setUp(() { - when( - () => shorebirdFlutter.getRevisionForVersion(any()), - ).thenThrow(exception); - }); - - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - ''' -Unable to determine revision for Flutter version: $flutterVersion. -$exception''', - ), - ).called(1); - }); + test('supports alpha alias', () { + expect(command.aliases, contains('ios-framework-alpha')); }); - group('when flutter version is too old', () { - setUp(() { - when(() => argResults['flutter-version']).thenReturn('3.16.3'); - }); - - test('prints error log and exits with code 64 (usage)', () async { - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.usage.code)); - verify( - () => logger.err( - '''iOS releases are not supported with Flutter versions older than 3.19.5.''', - ), - ).called(1); - }); + test('has a description', () { + expect(command.description, isNotEmpty); }); - group('when flutter version is not supported', () { - setUp(() { - when( - () => shorebirdFlutter.getRevisionForVersion(any()), - ).thenAnswer((_) async => null); - }); - - test('exits with code 70', () async { - final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => logger.err( - any(that: contains('Version $flutterVersion not found.')), - ), - ).called(1); - }); + test('exits when validation fails', () 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(command.run), + completion(equals(exception.exitCode.code)), + ); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: any(named: 'validators'), + supportedOperatingSystems: {Platform.macOS}, + ), + ).called(1); }); - group('when flutter version is supported', () { - const revision = '771d07b2cf'; + group('when flutter-version is provided', () { + const flutterVersion = '3.19.5'; setUp(() { - when( - () => shorebirdFlutter.getRevisionForVersion(any()), - ).thenAnswer((_) async => revision); + when(() => argResults['flutter-version']).thenReturn(flutterVersion); }); - test('uses specified flutter version build', () async { - when( - () => shorebirdProcess.run( - 'flutter', - any(), - runInShell: any(named: 'runInShell'), - ), - ).thenAnswer((_) async { - // Ensure we're using the correct flutter version. - expect(shorebirdEnv.flutterRevision, equals(revision)); - return flutterBuildProcessResult; - }); - - setUpProjectRoot(); - - await runWithOverrides(command.run); - - verify(() => shorebirdFlutter.installRevision(revision: revision)) - .called(1); - verify( - () => codePushClientWrapper.createRelease( - appId: appId, - version: version, - flutterRevision: revision, - platform: releasePlatform, - ), - ).called(1); - verify( - () => codePushClientWrapper.updateReleaseStatus( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - status: ReleaseStatus.active, - metadata: const UpdateReleaseMetadata( - releasePlatform: releasePlatform, - flutterVersionOverride: flutterVersion, - generatedApks: false, - environment: BuildEnvironmentMetadata( - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - shorebirdVersion: packageVersion, - xcodeVersion: xcodeVersion, - ), - ), - ), - ).called(1); - }); - - group('when flutter version install fails', () { + group('when unable to determine flutter revision', () { + final exception = Exception('oops'); setUp(() { when( - () => shorebirdFlutter.installRevision( - revision: any(named: 'revision'), - ), - ).thenThrow(Exception('oops')); + () => shorebirdFlutter.getRevisionForVersion(any()), + ).thenThrow(exception); }); test('exits with code 70', () async { - setUpProjectRoot(); - final result = await runWithOverrides(command.run); - - expect(result, equals(ExitCode.software.code)); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.software.code)); verify( - () => shorebirdFlutter.installRevision(revision: revision), + () => logger.err( + ''' +Unable to determine revision for Flutter version: $flutterVersion. +$exception''', + ), ).called(1); }); }); + + group('when flutter version is too old', () { + setUp(() { + when(() => argResults['flutter-version']).thenReturn('3.16.3'); + }); + + test('prints error log and exits with code 64 (usage)', () async { + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.usage.code)); + verify( + () => logger.err( + '''iOS releases are not supported with Flutter versions older than 3.19.5.''', + ), + ).called(1); + }); + }); + + group('when flutter version is not supported', () { + setUp(() { + when( + () => shorebirdFlutter.getRevisionForVersion(any()), + ).thenAnswer((_) async => null); + }); + + test('exits with code 70', () async { + final exitCode = await runWithOverrides(command.run); + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => logger.err( + any(that: contains('Version $flutterVersion not found.')), + ), + ).called(1); + }); + }); + + group('when flutter version is supported', () { + const revision = '771d07b2cf'; + setUp(() { + when( + () => shorebirdFlutter.getRevisionForVersion(any()), + ).thenAnswer((_) async => revision); + }); + + test('uses specified flutter version build', () async { + when( + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async { + // Ensure we're using the correct flutter version. + expect(shorebirdEnv.flutterRevision, equals(revision)); + return flutterBuildProcessResult; + }); + + setUpProjectRoot(); + + await runWithOverrides(command.run); + + verify(() => shorebirdFlutter.installRevision(revision: revision)) + .called(1); + verify( + () => codePushClientWrapper.createRelease( + appId: appId, + version: version, + flutterRevision: revision, + platform: releasePlatform, + ), + ).called(1); + verify( + () => codePushClientWrapper.updateReleaseStatus( + appId: appId, + releaseId: release.id, + platform: releasePlatform, + status: ReleaseStatus.active, + metadata: const UpdateReleaseMetadata( + releasePlatform: releasePlatform, + flutterVersionOverride: flutterVersion, + generatedApks: false, + environment: BuildEnvironmentMetadata( + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + xcodeVersion: xcodeVersion, + ), + ), + ), + ).called(1); + }); + + group('when flutter version install fails', () { + setUp(() { + when( + () => shorebirdFlutter.installRevision( + revision: any(named: 'revision'), + ), + ).thenThrow(Exception('oops')); + }); + + test('exits with code 70', () async { + setUpProjectRoot(); + final result = await runWithOverrides(command.run); + + expect(result, equals(ExitCode.software.code)); + verify( + () => shorebirdFlutter.installRevision(revision: revision), + ).called(1); + }); + }); + }); }); - }); - test('exits with code 70 when build fails with non-zero exit code', - () async { - when(() => flutterBuildProcessResult.exitCode).thenReturn(1); - when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); + test('exits with code 70 when build fails with non-zero exit code', + () async { + when(() => flutterBuildProcessResult.exitCode).thenReturn(1); + when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); - setUpProjectRoot(); - final exitCode = await runWithOverrides(command.run); + setUpProjectRoot(); + final exitCode = await runWithOverrides(command.run); - expect(exitCode, equals(ExitCode.software.code)); - verify( - () => progress.fail(any(that: contains('Failed to build'))), - ).called(1); - }); + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => progress.fail(any(that: contains('Failed to build'))), + ).called(1); + }); - test('checks that release is not active if release exists', () async { - when( - () => codePushClientWrapper.maybeGetRelease( - appId: any(named: 'appId'), - releaseVersion: any(named: 'releaseVersion'), - ), - ).thenAnswer((_) async => release); - setUpProjectRoot(); - - await runWithOverrides(command.run); - - verify( - () => codePushClientWrapper.ensureReleaseIsNotActive( - release: release, - platform: releasePlatform, - ), - ).called(1); - }); - - test('aborts when user opts out', () async { - when(() => logger.confirm(any())).thenReturn(false); - setUpProjectRoot(); - - final exitCode = await runWithOverrides(command.run); - - expect(exitCode, ExitCode.success.code); - verify(() => logger.info('Aborting.')).called(1); - verifyNever( - () => codePushClientWrapper.createIosReleaseArtifacts( - appId: appId, - releaseId: release.id, - xcarchivePath: any( - named: 'xcarchivePath', - that: endsWith('.xcarchive'), + test('checks that release is not active if release exists', () async { + when( + () => codePushClientWrapper.maybeGetRelease( + appId: any(named: 'appId'), + releaseVersion: any(named: 'releaseVersion'), ), - runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), - isCodesigned: any(named: 'isCodesigned'), - ), - ); - }); + ).thenAnswer((_) async => release); + setUpProjectRoot(); - test('does not prompt for confirmation if unable to accept user input', - () async { - when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); - when(() => argResults['release-version']).thenReturn(version); - setUpProjectRoot(); + await runWithOverrides(command.run); - final exitCode = await runWithOverrides(command.run); + verify( + () => codePushClientWrapper.ensureReleaseIsNotActive( + release: release, + platform: releasePlatform, + ), + ).called(1); + }); - verify(() => logger.success('\n✅ Published Release $version!')).called(1); - expect(exitCode, ExitCode.success.code); - verifyNever( - () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), - ); - verify( - () => codePushClientWrapper.updateReleaseStatus( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - status: ReleaseStatus.active, - metadata: any(named: 'metadata'), - ), - ).called(1); - }); + test('aborts when user opts out', () async { + when(() => logger.confirm(any())).thenReturn(false); + setUpProjectRoot(); - test('succeeds when release is successful', () async { - setUpProjectRoot(); + final exitCode = await runWithOverrides(command.run); - final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.success.code); + verify(() => logger.info('Aborting.')).called(1); + verifyNever( + () => codePushClientWrapper.createIosReleaseArtifacts( + appId: appId, + releaseId: release.id, + xcarchivePath: any( + named: 'xcarchivePath', + that: endsWith('.xcarchive'), + ), + runnerPath: any(named: 'runnerPath', that: endsWith('Runner.app')), + isCodesigned: any(named: 'isCodesigned'), + ), + ); + }); - verify(() => logger.success('\n✅ Published Release $version!')).called(1); - verify( - () => logger.info( - any( - that: stringContainsInOrder( - [ - 'Your next step is to add the .xcframework files found in', - 'release', - 'to your iOS app.', - '''Embed the App.xcframework and ShorebirdFlutter.framework in your Xcode project''', - ], + test('does not prompt for confirmation if unable to accept user input', + () async { + when(() => shorebirdEnv.canAcceptUserInput).thenReturn(false); + when(() => argResults['release-version']).thenReturn(version); + setUpProjectRoot(); + + final exitCode = await runWithOverrides(command.run); + + verify(() => logger.success('\n✅ Published Release $version!')) + .called(1); + expect(exitCode, ExitCode.success.code); + verifyNever( + () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), + ); + verify( + () => codePushClientWrapper.updateReleaseStatus( + appId: appId, + releaseId: release.id, + platform: releasePlatform, + status: ReleaseStatus.active, + metadata: any(named: 'metadata'), + ), + ).called(1); + }); + + test('succeeds when release is successful', () async { + setUpProjectRoot(); + + final exitCode = await runWithOverrides(command.run); + + verify(() => logger.success('\n✅ Published Release $version!')) + .called(1); + verify( + () => logger.info( + any( + that: stringContainsInOrder( + [ + 'Your next step is to add the .xcframework files found in', + 'release', + 'to your iOS app.', + '''Embed the App.xcframework and ShorebirdFlutter.framework in your Xcode project''', + ], + ), ), ), - ), - ).called(1); - verify( - () => codePushClientWrapper.createIosFrameworkReleaseArtifacts( - appId: appId, - releaseId: release.id, - appFrameworkPath: any( - named: 'appFrameworkPath', - that: endsWith( - p.join('release', 'App.xcframework'), + ).called(1); + verify( + () => codePushClientWrapper.createIosFrameworkReleaseArtifacts( + appId: appId, + releaseId: release.id, + appFrameworkPath: any( + named: 'appFrameworkPath', + that: endsWith( + p.join('release', 'App.xcframework'), + ), ), ), - ), - ).called(1); - verify( - () => codePushClientWrapper.updateReleaseStatus( - appId: appId, - releaseId: release.id, - platform: releasePlatform, - status: ReleaseStatus.active, - metadata: const UpdateReleaseMetadata( - releasePlatform: releasePlatform, - flutterVersionOverride: null, - generatedApks: false, - environment: BuildEnvironmentMetadata( - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - shorebirdVersion: packageVersion, - xcodeVersion: xcodeVersion, + ).called(1); + verify( + () => codePushClientWrapper.updateReleaseStatus( + appId: appId, + releaseId: release.id, + platform: releasePlatform, + status: ReleaseStatus.active, + metadata: const UpdateReleaseMetadata( + releasePlatform: releasePlatform, + flutterVersionOverride: null, + generatedApks: false, + environment: BuildEnvironmentMetadata( + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + xcodeVersion: xcodeVersion, + ), ), ), - ), - ).called(1); - expect(exitCode, ExitCode.success.code); - }); + ).called(1); + expect(exitCode, ExitCode.success.code); + }); - test('runs flutter pub get with system flutter after successful build', - () async { - setUpProjectRoot(); + test('runs flutter pub get with system flutter after successful build', + () async { + setUpProjectRoot(); - await runWithOverrides(command.run); + await runWithOverrides(command.run); - verify( - () => shorebirdProcess.run( - 'flutter', - ['--no-version-check', 'pub', 'get', '--offline'], - runInShell: any(named: 'runInShell'), - useVendedFlutter: false, - ), - ).called(1); - }); - }); + verify( + () => shorebirdProcess.run( + 'flutter', + ['--no-version-check', 'pub', 'get', '--offline'], + runInShell: any(named: 'runInShell'), + useVendedFlutter: false, + ), + ).called(1); + }); + }, + testOn: 'mac-os', + ); } diff --git a/packages/shorebird_cli/test/src/executables/aot_tools_test.dart b/packages/shorebird_cli/test/src/executables/aot_tools_test.dart index ca34313c..092ee265 100644 --- a/packages/shorebird_cli/test/src/executables/aot_tools_test.dart +++ b/packages/shorebird_cli/test/src/executables/aot_tools_test.dart @@ -54,13 +54,13 @@ void main() { }); group('link', () { - const base = './path/to/base.aot'; - const patch = './path/to/patch.aot'; - const analyzeSnapshot = './path/to/analyze_snapshot'; - const genSnapshot = './path/to/gen_snapshot'; - const kernel = './path/to/kernel.dill'; - const outputPath = './path/to/out.vmcode'; - const linkJsonPath = './path/to/link.jsonl'; + final base = p.join('.', 'path', 'to', 'base.aot'); + final patch = p.join('.', 'path', 'to', 'patch.aot'); + final analyzeSnapshot = p.join('.', 'path', 'to', 'analyze_snapshot'); + final genSnapshot = p.join('.', 'path', 'to', 'gen_snapshot'); + final kernel = p.join('.', 'path', 'to', 'kernel.dill'); + final outputPath = p.join('.', 'path', 'to', 'out.vmcode'); + final linkJsonPath = p.join('.', 'path', 'to', 'link.jsonl'); test('throws Exception when process exits with non-zero code', () async { when( diff --git a/packages/shorebird_cli/test/src/executables/java_test.dart b/packages/shorebird_cli/test/src/executables/java_test.dart index af1f5ba2..2bbd6c3a 100644 --- a/packages/shorebird_cli/test/src/executables/java_test.dart +++ b/packages/shorebird_cli/test/src/executables/java_test.dart @@ -51,35 +51,47 @@ void main() { }); group('executable', () { - group('when on Windows', () { - const javaHome = r'C:\Program Files\Java\jdk-11.0.1'; - setUp(() { - when(() => platform.isWindows).thenReturn(true); - when(() => platform.environment).thenReturn({'JAVA_HOME': javaHome}); - }); + group( + 'when on Windows', + () { + const javaHome = r'C:\Program Files\Java\jdk-11.0.1'; + setUp(() { + when(() => platform.isWindows).thenReturn(true); + when(() => platform.environment) + .thenReturn({'JAVA_HOME': javaHome}); + }); - test('returns correct executable on windows', () async { - expect( - runWithOverrides(() => java.executable), - equals(p.join(javaHome, 'bin', 'java.exe')), - ); - }); - }); + test('returns correct executable on windows', () async { + expect( + runWithOverrides(() => java.executable), + equals(p.join(javaHome, 'bin', 'java.exe')), + ); + }); + }, + testOn: 'windows', + ); - group('when on a non-Windows OS', () { - setUp(() { - const javaHome = '/path/to/jdk'; - when(() => platform.isWindows).thenReturn(false); - when(() => platform.environment).thenReturn({'JAVA_HOME': javaHome}); - }); + group( + 'when on a non-Windows OS', + () { + setUp(() { + const javaHome = '/path/to/jdk'; + when(() => platform.isWindows).thenReturn(false); + when(() => platform.environment) + .thenReturn({'JAVA_HOME': javaHome}); + }); - test('returns correct executable on non-windows', () async { - expect( - runWithOverrides(() => java.executable), - equals('/path/to/jdk/bin/java'), - ); - }); - }); + test('returns correct executable on non-windows', () async { + expect( + runWithOverrides(() => java.executable), + equals('/path/to/jdk/bin/java'), + ); + }); + }, + onPlatform: { + 'windows': const Skip(), + }, + ); group('when no jdk is found', () { setUp(() { diff --git a/packages/shorebird_cli/test/src/shorebird_process_test.dart b/packages/shorebird_cli/test/src/shorebird_process_test.dart index 4131458c..a988502c 100644 --- a/packages/shorebird_cli/test/src/shorebird_process_test.dart +++ b/packages/shorebird_cli/test/src/shorebird_process_test.dart @@ -199,8 +199,8 @@ void main() { ); test('adds local-engine arguments if set', () async { - engineConfig = const EngineConfig( - localEngineSrcPath: 'path/to/engine/src', + engineConfig = EngineConfig( + localEngineSrcPath: p.join('path', 'to', 'engine', 'src'), localEngine: 'android_release_arm64', localEngineHost: 'host_release', ); diff --git a/packages/shorebird_cli/test/src/validators/android_internet_permission_validator_test.dart b/packages/shorebird_cli/test/src/validators/android_internet_permission_validator_test.dart index 14359a06..f8f13436 100644 --- a/packages/shorebird_cli/test/src/validators/android_internet_permission_validator_test.dart +++ b/packages/shorebird_cli/test/src/validators/android_internet_permission_validator_test.dart @@ -149,10 +149,10 @@ void main() { expect( results.first, equals( - const ValidationIssue( + ValidationIssue( severity: ValidationIssueSeverity.error, message: - '''android/app/src/main/AndroidManifest.xml is missing the INTERNET permission.''', + '''${p.join('android', 'app', 'src', 'main', 'AndroidManifest.xml')} is missing the INTERNET permission.''', ), ), ); @@ -179,10 +179,10 @@ void main() { expect( results.first, equals( - const ValidationIssue( + ValidationIssue( severity: ValidationIssueSeverity.error, message: - '''android/app/src/main/AndroidManifest.xml is missing the INTERNET permission.''', + '''${p.join('android', 'app', 'src', 'main', 'AndroidManifest.xml')} is missing the INTERNET permission.''', ), ), ); @@ -212,10 +212,10 @@ void main() { expect( results.first, equals( - const ValidationIssue( + ValidationIssue( severity: ValidationIssueSeverity.error, message: - '''android/app/src/main/AndroidManifest.xml is missing the INTERNET permission.''', + '''${p.join('android', 'app', 'src', 'main', 'AndroidManifest.xml')} is missing the INTERNET permission.''', ), ), );