diff --git a/packages/shorebird_cli/lib/src/commands/release_new/aar_releaser.dart b/packages/shorebird_cli/lib/src/commands/release_new/aar_releaser.dart index 4fa62ae5..c333f149 100644 --- a/packages/shorebird_cli/lib/src/commands/release_new/aar_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release_new/aar_releaser.dart @@ -140,7 +140,8 @@ class AarReleaser extends Releaser { } @override - UpdateReleaseMetadata get releaseMetadata => UpdateReleaseMetadata( + Future releaseMetadata() async => + UpdateReleaseMetadata( releasePlatform: releaseType.releasePlatform, flutterVersionOverride: argResults['flutter-version'] as String?, generatedApks: false, diff --git a/packages/shorebird_cli/lib/src/commands/release_new/android_releaser.dart b/packages/shorebird_cli/lib/src/commands/release_new/android_releaser.dart index 1a18867b..f78f2964 100644 --- a/packages/shorebird_cli/lib/src/commands/release_new/android_releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release_new/android_releaser.dart @@ -164,7 +164,8 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec } @override - UpdateReleaseMetadata get releaseMetadata => UpdateReleaseMetadata( + Future releaseMetadata() async => + UpdateReleaseMetadata( releasePlatform: releaseType.releasePlatform, flutterVersionOverride: argResults['flutter-version'] as String?, generatedApks: generateApk, diff --git a/packages/shorebird_cli/lib/src/commands/release_new/ios_releaser.dart b/packages/shorebird_cli/lib/src/commands/release_new/ios_releaser.dart new file mode 100644 index 00000000..b3925a38 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/release_new/ios_releaser.dart @@ -0,0 +1,206 @@ +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; +import 'package:shorebird_cli/src/archive_analysis/plist.dart'; +import 'package:shorebird_cli/src/artifact_builder.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/release_new/release_type.dart'; +import 'package:shorebird_cli/src/commands/release_new/releaser.dart'; +import 'package:shorebird_cli/src/doctor.dart'; +import 'package:shorebird_cli/src/executables/xcodebuild.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_cli/src/platform.dart'; +import 'package:shorebird_cli/src/platform/ios.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; +import 'package:shorebird_cli/src/version.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template ios_releaser} +/// Functions to build and publish an iOS release. +/// {@endtemplate} +class IosReleaser extends Releaser { + /// {@macro ios_releaser} + IosReleaser({ + required super.argResults, + required super.flavor, + required super.target, + }); + + /// Whether to codesign the release. + bool get codesign => argResults['codesign'] == true; + + @override + ReleaseType get releaseType => ReleaseType.ios; + + @override + Future assertArgsAreValid() async { + if (argResults.rest.contains('--obfuscate')) { + // Obfuscated releases break patching, so we don't support them. + // See https://github.com/shorebirdtech/shorebird/issues/1619 + logger + ..err('Shorebird does not currently support obfuscation on iOS.') + ..info( + '''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''', + ); + exit(ExitCode.unavailable.code); + } + } + + @override + Future assertPreconditions() async { + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: doctor.iosCommandValidators, + supportedOperatingSystems: {Platform.macOS}, + ); + } on PreconditionFailedException catch (e) { + exit(e.exitCode.code); + } + } + + @override + Future buildReleaseArtifacts() async { + if (!codesign) { + logger + ..info( + '''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''', + ) + ..warn( + '''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''', + ); + } + + final File exportOptionsPlist; + try { + exportOptionsPlist = ios.exportOptionsPlistFromArgs(argResults); + } catch (error) { + logger.err('$error'); + exit(ExitCode.usage.code); + } + + final flutterVersionString = await shorebirdFlutter.getVersionAndRevision(); + final buildProgress = + logger.progress('Building ipa with Flutter $flutterVersionString'); + try { + await artifactBuilder.buildIpa( + codesign: codesign, + exportOptionsPlist: exportOptionsPlist, + flavor: flavor, + target: target, + ); + buildProgress.complete(); + } on ArtifactBuildException catch (error) { + buildProgress.fail(error.message); + exit(ExitCode.software.code); + } + + final xcarchiveDirectory = artifactManager.getXcarchiveDirectory(); + if (xcarchiveDirectory == null) { + logger.err('Unable to find .xcarchive directory'); + exit(ExitCode.software.code); + } + + final appDirectory = artifactManager.getIosAppDirectory( + xcarchiveDirectory: xcarchiveDirectory, + ); + + if (appDirectory == null) { + logger.err('Unable to find .app directory'); + exit(ExitCode.software.code); + } + + return xcarchiveDirectory; + } + + @override + Future getReleaseVersion({ + required FileSystemEntity releaseArtifactRoot, + }) async { + final plistFile = File(p.join(releaseArtifactRoot.path, 'Info.plist')); + if (!plistFile.existsSync()) { + logger.err('No Info.plist file found at ${plistFile.path}'); + exit(ExitCode.software.code); + } + + try { + return Plist(file: plistFile).versionNumber; + } catch (error) { + logger.err( + '''Failed to determine release version from ${plistFile.path}: $error''', + ); + exit(ExitCode.software.code); + } + } + + @override + Future uploadReleaseArtifacts({ + required Release release, + required String appId, + }) async { + final xcarchiveDirectory = artifactManager.getXcarchiveDirectory()!; + await codePushClientWrapper.createIosReleaseArtifacts( + appId: appId, + releaseId: release.id, + xcarchivePath: xcarchiveDirectory.path, + runnerPath: artifactManager + .getIosAppDirectory(xcarchiveDirectory: xcarchiveDirectory)! + .path, + isCodesigned: codesign, + ); + } + + @override + String get postReleaseInstructions { + final relativeArchivePath = p.relative( + artifactManager.getXcarchiveDirectory()!.path, + ); + if (codesign) { + final ipa = artifactManager.getIpa(); + if (ipa == null) { + logger.err('Could not find ipa file'); + exit(ExitCode.software.code); + } + + final relativeIpaPath = p.relative(ipa.path); + return ''' + +Your next step is to upload your app to App Store Connect. + +To upload to the App Store, do one of the following: + 1. Open ${lightCyan.wrap(relativeArchivePath)} in Xcode and use the "Distribute App" flow. + 2. Drag and drop the ${lightCyan.wrap(relativeIpaPath)} bundle into the Apple Transporter macOS app (https://apps.apple.com/us/app/transporter/id1450874784). + 3. Run ${lightCyan.wrap('xcrun altool --upload-app --type ios -f $relativeIpaPath --apiKey your_api_key --apiIssuer your_issuer_id')}. + See "man altool" for details about how to authenticate with the App Store Connect API key. +'''; + } else { + return ''' + +Your next step is to submit the archive at ${lightCyan.wrap(relativeArchivePath)} to the App Store using Xcode. + +You can open the archive in Xcode by running: + ${lightCyan.wrap('open $relativeArchivePath')} + +${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.')} +'''; + } + } + + @override + Future releaseMetadata() async => + UpdateReleaseMetadata( + releasePlatform: releaseType.releasePlatform, + flutterVersionOverride: argResults['flutter-version'] as String?, + generatedApks: false, + environment: BuildEnvironmentMetadata( + operatingSystem: platform.operatingSystem, + operatingSystemVersion: platform.operatingSystemVersion, + shorebirdVersion: packageVersion, + xcodeVersion: await xcodeBuild.version(), + ), + ); +} diff --git a/packages/shorebird_cli/lib/src/commands/release_new/release_new.dart b/packages/shorebird_cli/lib/src/commands/release_new/release_new.dart index 2d68b5d2..711e6bae 100644 --- a/packages/shorebird_cli/lib/src/commands/release_new/release_new.dart +++ b/packages/shorebird_cli/lib/src/commands/release_new/release_new.dart @@ -1,4 +1,5 @@ export 'aar_releaser.dart'; export 'android_releaser.dart'; +export 'ios_releaser.dart'; export 'release_new_command.dart'; export 'releaser.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/release_new/release_new_command.dart b/packages/shorebird_cli/lib/src/commands/release_new/release_new_command.dart index 50358ff6..68129d2f 100644 --- a/packages/shorebird_cli/lib/src/commands/release_new/release_new_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release_new/release_new_command.dart @@ -7,6 +7,7 @@ import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/command.dart'; import 'package:shorebird_cli/src/commands/release_new/aar_releaser.dart'; import 'package:shorebird_cli/src/commands/release_new/android_releaser.dart'; +import 'package:shorebird_cli/src/commands/release_new/ios_releaser.dart'; import 'package:shorebird_cli/src/commands/release_new/release_type.dart'; import 'package:shorebird_cli/src/commands/release_new/releaser.dart'; import 'package:shorebird_cli/src/config/config.dart'; @@ -48,6 +49,16 @@ On Android it is used as "versionCode". On Xcode builds it is used as "CFBundleVersion".''', defaultsTo: '1.0', ) + ..addFlag( + 'codesign', + help: 'Codesign the application bundle.', + defaultsTo: true, + ) + ..addOption( + exportOptionsPlistArgName, + help: + '''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.''', + ) ..addOption( 'flutter-version', help: 'The Flutter version to use when building the app (e.g: 3.16.3).', @@ -125,7 +136,7 @@ of the iOS app that is using this module.''', target: target, ); case ReleaseType.ios: - throw UnimplementedError(); + return IosReleaser(argResults: results, flavor: flavor, target: target); case ReleaseType.iosFramework: throw UnimplementedError(); case ReleaseType.aar: @@ -201,12 +212,14 @@ of the iOS app that is using this module.''', version: releaseVersion, releasePlatform: releaser.releaseType.releasePlatform, ); - await prepareRelease(release: release, pipeline: releaser); + await prepareRelease(release: release, releaser: releaser); await releaser.uploadReleaseArtifacts(release: release, appId: appId); - await finalizeRelease(release: release, pipeline: releaser); + await finalizeRelease(release: release, releaser: releaser); logger - ..success('✅ Published Release ${release.version}!') + ..success(''' + +✅ Published Release ${release.version}!''') ..info(releaser.postReleaseInstructions); printPatchInstructions( @@ -369,12 +382,12 @@ ${summary.join('\n')} /// Prepares the release by updating the release status to draft. Future prepareRelease({ required Release release, - required Releaser pipeline, + required Releaser releaser, }) async { await codePushClientWrapper.updateReleaseStatus( appId: appId, releaseId: release.id, - platform: pipeline.releaseType.releasePlatform, + platform: releaser.releaseType.releasePlatform, status: ReleaseStatus.draft, ); } @@ -382,18 +395,18 @@ ${summary.join('\n')} /// Finalizes the release by updating the status to active. Future finalizeRelease({ required Release release, - required Releaser pipeline, + required Releaser releaser, }) async { await codePushClientWrapper.updateReleaseStatus( appId: appId, releaseId: release.id, - platform: pipeline.releaseType.releasePlatform, + platform: releaser.releaseType.releasePlatform, status: ReleaseStatus.active, - metadata: pipeline.releaseMetadata, + metadata: await releaser.releaseMetadata(), ); } - /// Instructions explaining how to patch the release that was just created. + /// Instructions explaining how to patch the release that was just creatd. void printPatchInstructions({ required Releaser releaser, required String releaseVersion, diff --git a/packages/shorebird_cli/lib/src/commands/release_new/releaser.dart b/packages/shorebird_cli/lib/src/commands/release_new/releaser.dart index 751f8dbb..e2a7bd9b 100644 --- a/packages/shorebird_cli/lib/src/commands/release_new/releaser.dart +++ b/packages/shorebird_cli/lib/src/commands/release_new/releaser.dart @@ -56,7 +56,7 @@ abstract class Releaser { /// Metadata to attach to the release when creating it, used for debugging /// and support. - UpdateReleaseMetadata get releaseMetadata; + Future releaseMetadata(); /// Instructions explaining next steps after running `shorebird release`. This /// could include how to upload the generated artifact to a store and how to diff --git a/packages/shorebird_cli/lib/src/platform/ios.dart b/packages/shorebird_cli/lib/src/platform/ios.dart index bb97bc4f..9b85d283 100644 --- a/packages/shorebird_cli/lib/src/platform/ios.dart +++ b/packages/shorebird_cli/lib/src/platform/ios.dart @@ -46,7 +46,11 @@ class InvalidExportOptionsPlistException implements Exception { /// {@macro invalid_export_options_plist_exception} InvalidExportOptionsPlistException(this.message); + /// An explanation of this exception. final String message; + + @override + String toString() => message; } /// The minimum allowed Flutter version for creating iOS releases. diff --git a/packages/shorebird_cli/test/src/commands/release_new/aar_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release_new/aar_releaser_test.dart index 4bee7359..4b21a68c 100644 --- a/packages/shorebird_cli/test/src/commands/release_new/aar_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release_new/aar_releaser_test.dart @@ -408,18 +408,20 @@ void main() { .thenReturn(operatingSystemVersion); }); - test('returns expected metadata', () { + test('returns expected metadata', () async { expect( - runWithOverrides(() => aarReleaser.releaseMetadata), - const UpdateReleaseMetadata( - releasePlatform: ReleasePlatform.android, - flutterVersionOverride: null, - generatedApks: false, - environment: BuildEnvironmentMetadata( - operatingSystem: operatingSystem, - operatingSystemVersion: operatingSystemVersion, - shorebirdVersion: packageVersion, - xcodeVersion: null, + await runWithOverrides(aarReleaser.releaseMetadata), + equals( + const UpdateReleaseMetadata( + releasePlatform: ReleasePlatform.android, + flutterVersionOverride: null, + generatedApks: false, + environment: BuildEnvironmentMetadata( + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + xcodeVersion: null, + ), ), ), ); diff --git a/packages/shorebird_cli/test/src/commands/release_new/android_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release_new/android_releaser_test.dart index e22bfb1c..ac7afaf9 100644 --- a/packages/shorebird_cli/test/src/commands/release_new/android_releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release_new/android_releaser_test.dart @@ -471,9 +471,9 @@ void main() { when(() => argResults['android-artifact']).thenReturn('apk'); }); - test('returns expected metadata', () { + test('returns expected metadata', () async { expect( - runWithOverrides(() => androidReleaser.releaseMetadata), + await runWithOverrides(() => androidReleaser.releaseMetadata()), const UpdateReleaseMetadata( releasePlatform: ReleasePlatform.android, flutterVersionOverride: null, @@ -494,9 +494,9 @@ void main() { when(() => argResults['android-artifact']).thenReturn('aab'); }); - test('returns expected metadata', () { + test('returns expected metadata', () async { expect( - runWithOverrides(() => androidReleaser.releaseMetadata), + await runWithOverrides(() => androidReleaser.releaseMetadata()), const UpdateReleaseMetadata( releasePlatform: ReleasePlatform.android, flutterVersionOverride: null, diff --git a/packages/shorebird_cli/test/src/commands/release_new/ios_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release_new/ios_releaser_test.dart new file mode 100644 index 00000000..519919d6 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/release_new/ios_releaser_test.dart @@ -0,0 +1,670 @@ +import 'package:args/args.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; +import 'package:scoped/scoped.dart'; +import 'package:shorebird_cli/src/artifact_builder.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/commands/release_new/ios_releaser.dart'; +import 'package:shorebird_cli/src/commands/release_new/release_type.dart'; +import 'package:shorebird_cli/src/doctor.dart'; +import 'package:shorebird_cli/src/executables/xcodebuild.dart'; +import 'package:shorebird_cli/src/logger.dart'; +import 'package:shorebird_cli/src/os/operating_system_interface.dart'; +import 'package:shorebird_cli/src/platform.dart'; +import 'package:shorebird_cli/src/platform/ios.dart'; +import 'package:shorebird_cli/src/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.dart'; +import 'package:shorebird_cli/src/shorebird_process.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; +import 'package:shorebird_cli/src/validators/validators.dart'; +import 'package:shorebird_cli/src/version.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +import '../../matchers.dart'; +import '../../mocks.dart'; + +void main() { + group( + IosReleaser, + () { + late ArgResults argResults; + late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; + late CodePushClientWrapper codePushClientWrapper; + late Directory projectRoot; + late Doctor doctor; + late Platform platform; + late Progress progress; + late Logger logger; + late Ios ios; + late OperatingSystemInterface operatingSystemInterface; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdValidator shorebirdValidator; + late XcodeBuild xcodeBuild; + late IosReleaser iosReleaser; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), + 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), + }, + ); + } + + setUpAll(() { + registerFallbackValue(Directory('')); + registerFallbackValue(ReleasePlatform.android); + setExitFunctionForTests(); + }); + + tearDownAll(restoreExitFunction); + + setUp(() { + argResults = MockArgResults(); + artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); + codePushClientWrapper = MockCodePushClientWrapper(); + doctor = MockDoctor(); + platform = MockPlatform(); + projectRoot = Directory.systemTemp.createTempSync(); + operatingSystemInterface = MockOperatingSystemInterface(); + progress = MockProgress(); + logger = MockLogger(); + ios = MockIos(); + flutterValidator = MockShorebirdFlutterValidator(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdEnv = MockShorebirdEnv(); + shorebirdFlutter = MockShorebirdFlutter(); + shorebirdValidator = MockShorebirdValidator(); + xcodeBuild = MockXcodeBuild(); + + when(() => argResults.rest).thenReturn([]); + + when(() => logger.progress(any())).thenReturn(progress); + + iosReleaser = IosReleaser( + argResults: argResults, + flavor: null, + target: null, + ); + }); + + group('releaseType', () { + test('is ios', () { + expect(iosReleaser.releaseType, ReleaseType.ios); + }); + }); + + group('assertPreconditions', () { + setUp(() { + when(() => doctor.iosCommandValidators) + .thenReturn([flutterValidator]); + when(flutterValidator.validate).thenAnswer((_) async => []); + }); + + group('when validation succeeds', () { + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: + any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: + any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + supportedOperatingSystems: + any(named: 'supportedOperatingSystems'), + ), + ).thenAnswer((_) async {}); + }); + + test('returns normally', () async { + await expectLater( + () => runWithOverrides(iosReleaser.assertPreconditions), + returnsNormally, + ); + }); + }); + + group('when validation fails', () { + final exception = ValidationFailedException(); + + setUp(() { + when( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: + any(named: 'checkUserIsAuthenticated'), + checkShorebirdInitialized: + any(named: 'checkShorebirdInitialized'), + validators: any(named: 'validators'), + supportedOperatingSystems: + any(named: 'supportedOperatingSystems'), + ), + ).thenThrow(exception); + }); + + test('exits with code 70', () async { + await expectLater( + () => runWithOverrides(iosReleaser.assertPreconditions), + exitsWithCode(exception.exitCode), + ); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: [flutterValidator], + supportedOperatingSystems: {Platform.macOS}, + ), + ).called(1); + }); + }); + }); + + group('assertArgsAreValid', () { + group('when --obfuscate is passed', () { + setUp(() { + when(() => argResults.rest).thenReturn(['--obfuscate']); + }); + + test('logs error and exits', () async { + await expectLater( + runWithOverrides(iosReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.unavailable), + ); + + verify( + () => logger.err( + 'Shorebird does not currently support obfuscation on iOS.', + ), + ).called(1); + verify( + () => logger.info( + '''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''', + ), + ).called(1); + }); + }); + + group('when --obfuscate is not passed', () { + test('returns normally', () async { + await expectLater( + runWithOverrides(iosReleaser.assertArgsAreValid), + completes, + ); + }); + }); + }); + + group('buildReleaseArtifacts', () { + const flutterVersionAndRevision = '3.10.6 (83305b5088)'; + + late Directory xcarchiveDirectory; + late Directory iosAppDirectory; + + setUp(() { + xcarchiveDirectory = Directory.systemTemp.createTempSync(); + iosAppDirectory = Directory.systemTemp.createTempSync(); + when(() => argResults['codesign']).thenReturn(true); + when( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + exportOptionsPlist: any(named: 'exportOptionsPlist'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + ), + ).thenAnswer((_) async => {}); + + when( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: any(named: 'xcarchiveDirectory'), + ), + ).thenReturn(iosAppDirectory); + when(() => artifactManager.getXcarchiveDirectory()) + .thenReturn(xcarchiveDirectory); + when( + () => ios.exportOptionsPlistFromArgs(argResults), + ).thenReturn(File('')); + when(() => shorebirdEnv.getShorebirdProjectRoot()) + .thenReturn(projectRoot); + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterVersionAndRevision); + }); + + group('when not codesigning', () { + setUp(() { + when(() => argResults['codesign']).thenReturn(false); + }); + + test('logs warning about patching', () async { + await runWithOverrides(iosReleaser.buildReleaseArtifacts); + + verify( + () => logger.info( + '''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''', + ), + ).called(1); + verify( + () => logger.warn( + '''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''', + ), + ).called(1); + }); + }); + + group('when export options plist fails to generate', () { + const error = 'error'; + setUp(() { + when( + () => ios.exportOptionsPlistFromArgs(argResults), + ).thenThrow(error); + }); + + test('logs error and exits with code 64', () async { + await expectLater( + () => runWithOverrides(iosReleaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.usage), + ); + + verify( + () => logger.err(error), + ).called(1); + }); + }); + + group('when build fails', () { + setUp(() { + when( + () => artifactBuilder.buildIpa( + codesign: any(named: 'codesign'), + exportOptionsPlist: any(named: 'exportOptionsPlist'), + flavor: any(named: 'flavor'), + target: any(named: 'target'), + ), + ).thenThrow(ArtifactBuildException('Failed to build')); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides(iosReleaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => progress.fail('Failed to build'), + ).called(1); + }); + }); + + group('when build succeeds', () { + test('verifies artifacts exist and returns xcarchive path', () async { + expect( + await runWithOverrides(iosReleaser.buildReleaseArtifacts), + equals(xcarchiveDirectory), + ); + + verify(() => artifactManager.getXcarchiveDirectory()).called(1); + verify( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: xcarchiveDirectory, + ), + ).called(1); + }); + }); + + group('when xcarchive not found after build', () { + setUp(() { + when(() => artifactManager.getXcarchiveDirectory()) + .thenReturn(null); + }); + + test('logs message and exits with code 70', () async { + await expectLater( + () => runWithOverrides(iosReleaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err('Unable to find .xcarchive directory'), + ).called(1); + }); + }); + + group('when app not found after build', () { + setUp(() { + when( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: any(named: 'xcarchiveDirectory'), + ), + ).thenReturn(null); + }); + + test('logs message and exits with code 70', () async { + await expectLater( + () => runWithOverrides(iosReleaser.buildReleaseArtifacts), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err('Unable to find .app directory'), + ).called(1); + }); + }); + }); + + group('getReleaseVersion', () { + late Directory xcarchiveDirectory; + + setUp(() { + xcarchiveDirectory = Directory.systemTemp.createTempSync(); + }); + + group('when plist does not exist', () { + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides( + () => iosReleaser.getReleaseVersion( + releaseArtifactRoot: xcarchiveDirectory, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + '''No Info.plist file found at ${p.join(xcarchiveDirectory.path, 'Info.plist')}''', + ), + ).called(1); + }); + }); + + group('when plist does not contain version number', () { + late File plist; + setUp(() { + plist = File(p.join(xcarchiveDirectory.path, 'Info.plist')) + ..createSync() + ..writeAsStringSync( + ''' + + + + + ApplicationProperties + + + +' +''', + ); + }); + + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides( + () => iosReleaser.getReleaseVersion( + releaseArtifactRoot: xcarchiveDirectory, + ), + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err( + any( + that: startsWith( + 'Failed to determine release version from ${plist.path}', + ), + ), + ), + ).called(1); + }); + }); + + group('when plist contains version number', () { + setUp(() { + File(p.join(xcarchiveDirectory.path, 'Info.plist')) + ..createSync() + ..writeAsStringSync( + ''' + + + + + ApplicationProperties + + ApplicationPath + Applications/Runner.app + Architectures + + arm64 + + CFBundleIdentifier + com.shorebird.timeShift + CFBundleShortVersionString + 1.2.3 + CFBundleVersion + 1 + + ArchiveVersion + 2 + Name + Runner + SchemeName + Runner + +''', + ); + }); + + test('returns version number from plist', () async { + expect( + await runWithOverrides( + () => iosReleaser.getReleaseVersion( + releaseArtifactRoot: xcarchiveDirectory, + ), + ), + equals('1.2.3+1'), + ); + }); + }); + }); + + group('uploadReleaseArtifacts', () { + const appId = 'appId'; + const releaseVersion = '1.0.0'; + const flutterRevision = 'deadbeef'; + const codesign = true; + + final release = Release( + id: 42, + appId: appId, + version: releaseVersion, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + platformStatuses: {}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ); + + late Directory xcarchiveDirectory; + late Directory iosAppDirectory; + + setUp(() { + when(() => argResults['codesign']).thenReturn(codesign); + + xcarchiveDirectory = Directory.systemTemp.createTempSync(); + iosAppDirectory = Directory.systemTemp.createTempSync(); + when(artifactManager.getXcarchiveDirectory) + .thenReturn(xcarchiveDirectory); + when( + () => artifactManager.getIosAppDirectory( + xcarchiveDirectory: any(named: 'xcarchiveDirectory'), + ), + ).thenReturn(iosAppDirectory); + 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 => {}); + }); + + test('forwards call to codePushClientWrapper', () async { + await runWithOverrides( + () => iosReleaser.uploadReleaseArtifacts( + release: release, + appId: appId, + ), + ); + + verify( + () => codePushClientWrapper.createIosReleaseArtifacts( + appId: appId, + releaseId: release.id, + xcarchivePath: xcarchiveDirectory.path, + runnerPath: iosAppDirectory.path, + isCodesigned: codesign, + ), + ).called(1); + }); + }); + + group('releaseMetadata', () { + const operatingSystem = 'macOS'; + const operatingSystemVersion = '11.0.0'; + const xcodeVersion = '123'; + const flutterVersionOverride = '1.2.3'; + + setUp(() { + when(() => platform.operatingSystem).thenReturn(operatingSystem); + when(() => platform.operatingSystemVersion) + .thenReturn(operatingSystemVersion); + when(() => xcodeBuild.version()) + .thenAnswer((_) async => xcodeVersion); + when(() => argResults['flutter-version']) + .thenReturn(flutterVersionOverride); + }); + + test('returns expected metadata', () async { + expect( + await runWithOverrides(iosReleaser.releaseMetadata), + const UpdateReleaseMetadata( + releasePlatform: ReleasePlatform.ios, + flutterVersionOverride: flutterVersionOverride, + generatedApks: false, + environment: BuildEnvironmentMetadata( + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + xcodeVersion: xcodeVersion, + ), + ), + ); + }); + }); + + group('postReleaseInstructions', () { + late Directory xcarchiveDirectory; + + setUp(() { + xcarchiveDirectory = Directory.systemTemp.createTempSync(); + when(() => artifactManager.getXcarchiveDirectory()) + .thenReturn(xcarchiveDirectory); + }); + + group('when codesigning', () { + setUp(() { + when(() => argResults['codesign']).thenReturn(true); + }); + + group('when no ipa found', () { + test('logs error and exits', () async { + await expectLater( + () => runWithOverrides( + () => iosReleaser.postReleaseInstructions, + ), + exitsWithCode(ExitCode.software), + ); + + verify( + () => logger.err('Could not find ipa file'), + ).called(1); + }); + }); + + group('when ipa found', () { + late File ipa; + setUp(() { + final tempDir = Directory.systemTemp.createTempSync(); + ipa = File(p.join(tempDir.path, 'ipa.ipa'))..createSync(); + when(() => artifactManager.getIpa()).thenReturn(ipa); + }); + + test('prints ipa upload steps', () { + expect( + runWithOverrides(() => iosReleaser.postReleaseInstructions), + equals(''' + +Your next step is to upload your app to App Store Connect. + +To upload to the App Store, do one of the following: + 1. Open ${lightCyan.wrap(p.relative(xcarchiveDirectory.path))} in Xcode and use the "Distribute App" flow. + 2. Drag and drop the ${lightCyan.wrap(p.relative(ipa.path))} bundle into the Apple Transporter macOS app (https://apps.apple.com/us/app/transporter/id1450874784). + 3. Run ${lightCyan.wrap('xcrun altool --upload-app --type ios -f ${p.relative(ipa.path)} --apiKey your_api_key --apiIssuer your_issuer_id')}. + See "man altool" for details about how to authenticate with the App Store Connect API key. +'''), + ); + }); + }); + }); + + group('when not codesigning', () { + setUp(() { + when(() => argResults['codesign']).thenReturn(false); + }); + + test('prints xcarchive upload steps', () { + expect( + runWithOverrides(() => iosReleaser.postReleaseInstructions), + equals( + ''' + +Your next step is to submit the archive at ${lightCyan.wrap(p.relative(xcarchiveDirectory.path))} to the App Store using Xcode. + +You can open the archive in Xcode by running: + ${lightCyan.wrap('open ${p.relative(xcarchiveDirectory.path)}')} + +${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.')} +''', + ), + ); + }); + }); + }); + }, + testOn: 'mac-os', + ); +} diff --git a/packages/shorebird_cli/test/src/commands/release_new/release_new_command_test.dart b/packages/shorebird_cli/test/src/commands/release_new/release_new_command_test.dart index 67475901..a26eac3a 100644 --- a/packages/shorebird_cli/test/src/commands/release_new/release_new_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/release_new/release_new_command_test.dart @@ -3,11 +3,8 @@ import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; -import 'package:shorebird_cli/src/commands/release_new/aar_releaser.dart'; -import 'package:shorebird_cli/src/commands/release_new/android_releaser.dart'; -import 'package:shorebird_cli/src/commands/release_new/release_new_command.dart'; +import 'package:shorebird_cli/src/commands/release_new/release_new.dart'; import 'package:shorebird_cli/src/commands/release_new/release_type.dart'; -import 'package:shorebird_cli/src/commands/release_new/releaser.dart'; import 'package:shorebird_cli/src/config/config.dart'; import 'package:shorebird_cli/src/logger.dart'; import 'package:shorebird_cli/src/shorebird_env.dart'; @@ -139,8 +136,8 @@ void main() { when(() => releaser.postReleaseInstructions) .thenReturn(postReleaseInstructions); when(() => releaser.releaseType).thenReturn(ReleaseType.android); - when(() => releaser.releaseMetadata) - .thenReturn(UpdateReleaseMetadata.forTest()); + when(() => releaser.releaseMetadata()) + .thenAnswer((_) async => UpdateReleaseMetadata.forTest()); when(() => releaser.requiresReleaseVersionArg).thenReturn(false); when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml); @@ -189,8 +186,8 @@ void main() { isA(), ); expect( - () => command.getReleaser(ReleaseType.ios), - throwsA(isA()), + command.getReleaser(ReleaseType.ios), + isA(), ); expect( () => command.getReleaser(ReleaseType.iosFramework), @@ -215,7 +212,9 @@ void main() { release: release, appId: appId, ), - () => logger.success('✅ Published Release ${release.version}!'), + () => logger.success(''' + +✅ Published Release ${release.version}!'''), () => logger.info(postReleaseInstructions), () => logger.info( '''To create a patch for this release, run ${lightCyan.wrap('shorebird patch --platform=android --release-version=${release.version}')}''', @@ -275,7 +274,11 @@ Note: ${lightCyan.wrap('shorebird patch --platform=android')} without the --rele release: release, appId: appId, ), - () => logger.success('✅ Published Release ${release.version}!'), + () => logger.success( + ''' + +✅ Published Release ${release.version}!''', + ), () => logger.info(postReleaseInstructions), () => logger.info( '''To create a patch for this release, run ${lightCyan.wrap('shorebird patch --platform=android --flavor=$flavor --target=$target --release-version=${release.version}')}''', diff --git a/packages/shorebird_cli/test/src/commands/release_new/releaser_test.dart b/packages/shorebird_cli/test/src/commands/release_new/releaser_test.dart index 0398f231..6d16fc31 100644 --- a/packages/shorebird_cli/test/src/commands/release_new/releaser_test.dart +++ b/packages/shorebird_cli/test/src/commands/release_new/releaser_test.dart @@ -46,7 +46,7 @@ class FakeReleaser extends Releaser { String get postReleaseInstructions => throw UnimplementedError(); @override - UpdateReleaseMetadata get releaseMetadata => throw UnimplementedError(); + Future releaseMetadata() => throw UnimplementedError(); @override ReleaseType get releaseType => throw UnimplementedError(); diff --git a/packages/shorebird_cli/test/src/platform/ios_test.dart b/packages/shorebird_cli/test/src/platform/ios_test.dart index c4e23755..4ba58db4 100644 --- a/packages/shorebird_cli/test/src/platform/ios_test.dart +++ b/packages/shorebird_cli/test/src/platform/ios_test.dart @@ -11,6 +11,13 @@ import 'package:test/test.dart'; import '../mocks.dart'; void main() { + group(InvalidExportOptionsPlistException, () { + test('toString', () { + final exception = InvalidExportOptionsPlistException('message'); + expect(exception.toString(), 'message'); + }); + }); + group(Ios, () { late Ios ios;