From 222308f1d9b3bf368bed8eab14b7823d5cf0ec99 Mon Sep 17 00:00:00 2001 From: Bryan Oltman Date: Tue, 7 May 2024 14:46:42 -0400 Subject: [PATCH] refactor(shorebird_cli): Add IosFrameworkReleaser for new release command (#2013) --- .../release_new/ios_framework_releaser.dart | 145 ++++++ .../src/commands/release_new/release_new.dart | 1 + .../release_new/release_new_command.dart | 17 +- .../ios_framework_releaser_test.dart | 418 ++++++++++++++++++ .../release_new/release_new_command_test.dart | 4 +- 5 files changed, 577 insertions(+), 8 deletions(-) create mode 100644 packages/shorebird_cli/lib/src/commands/release_new/ios_framework_releaser.dart create mode 100644 packages/shorebird_cli/test/src/commands/release_new/ios_framework_releaser_test.dart diff --git a/packages/shorebird_cli/lib/src/commands/release_new/ios_framework_releaser.dart b/packages/shorebird_cli/lib/src/commands/release_new/ios_framework_releaser.dart new file mode 100644 index 00000000..ddf0e315 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/release_new/ios_framework_releaser.dart @@ -0,0 +1,145 @@ +import 'package:io/io.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; +import 'package:platform/platform.dart'; +import 'package:shorebird_cli/src/artifact_builder.dart'; +import 'package:shorebird_cli/src/artifact_manager.dart'; +import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; +import 'package:shorebird_cli/src/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/shorebird_env.dart'; +import 'package:shorebird_cli/src/shorebird_flutter.dart'; +import 'package:shorebird_cli/src/shorebird_validator.dart'; +import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart'; +import 'package:shorebird_cli/src/version.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template ios_framework_releaser} +/// Functions to create an iOS framework release. +/// {@endtemplate} +class IosFrameworkReleaser extends Releaser { + /// {@macro ios_framework_releaser} + IosFrameworkReleaser({ + required super.argResults, + required super.flavor, + required super.target, + }); + + Directory get releaseDirectory => Directory( + p.join(shorebirdEnv.getShorebirdProjectRoot()!.path, 'release'), + ); + + @override + bool get requiresReleaseVersionArg => true; + + @override + ReleaseType get releaseType => ReleaseType.iosFramework; + + @override + Future assertArgsAreValid() async { + if (!argResults.wasParsed('release-version')) { + logger.err('Missing required argument: --release-version'); + exit(ExitCode.usage.code); + } + } + + @override + Future assertPreconditions() async { + try { + await shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + supportedOperatingSystems: {Platform.macOS}, + validators: doctor.iosCommandValidators, + ); + } on PreconditionFailedException catch (e) { + exit(e.exitCode.code); + } + } + + @override + Future buildReleaseArtifacts() async { + final flutterVersionString = await shorebirdFlutter.getVersionAndRevision(); + + final buildProgress = logger.progress( + 'Building iOS framework with Flutter $flutterVersionString', + ); + + try { + await artifactBuilder.buildIosFramework(); + } catch (error) { + buildProgress.fail('Failed to build iOS framework: $error'); + exit(ExitCode.software.code); + } + + buildProgress.complete(); + + // Copy release xcframework to a new directory to avoid overwriting with + // subsequent patch builds. + final sourceLibraryDirectory = artifactManager.getAppXcframeworkDirectory(); + final targetLibraryDirectory = Directory( + p.join(shorebirdEnv.getShorebirdProjectRoot()!.path, 'release'), + ); + if (targetLibraryDirectory.existsSync()) { + targetLibraryDirectory.deleteSync(recursive: true); + } + await copyPath( + sourceLibraryDirectory.path, + targetLibraryDirectory.path, + ); + + return targetLibraryDirectory; + } + + @override + Future getReleaseVersion({ + required FileSystemEntity releaseArtifactRoot, + }) async { + return argResults['release-version'] as String; + } + + @override + Future uploadReleaseArtifacts({ + required Release release, + required String appId, + }) { + return codePushClientWrapper.createIosFrameworkReleaseArtifacts( + appId: appId, + releaseId: release.id, + appFrameworkPath: p.join(releaseDirectory.path, 'App.xcframework'), + ); + } + + @override + String get postReleaseInstructions { + final relativeFrameworkDirectoryPath = p.relative(releaseDirectory.path); + return ''' + +Your next step is to add the .xcframework files found in the ${lightCyan.wrap(relativeFrameworkDirectoryPath)} directory to your iOS app. + +To do this: + 1. Add the relative path to the ${lightCyan.wrap(relativeFrameworkDirectoryPath)} directory to your app's Framework Search Paths in your Xcode build settings. + 2. Embed the App.xcframework and ShorebirdFlutter.framework in your Xcode project. + +Instructions for these steps can be found at https://docs.flutter.dev/add-to-app/ios/project-setup#option-b---embed-frameworks-in-xcode. +'''; + } + + @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 711e6bae..9871b356 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,5 +1,6 @@ export 'aar_releaser.dart'; export 'android_releaser.dart'; +export 'ios_framework_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 68129d2f..3865f051 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 @@ -5,11 +5,8 @@ import 'package:meta/meta.dart'; import 'package:scoped/scoped.dart'; import 'package:shorebird_cli/src/code_push_client_wrapper.dart'; import 'package:shorebird_cli/src/command.dart'; -import 'package:shorebird_cli/src/commands/release_new/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_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/extensions/arg_results.dart'; import 'package:shorebird_cli/src/logger.dart'; @@ -136,9 +133,17 @@ of the iOS app that is using this module.''', target: target, ); case ReleaseType.ios: - return IosReleaser(argResults: results, flavor: flavor, target: target); + return IosReleaser( + argResults: results, + flavor: flavor, + target: target, + ); case ReleaseType.iosFramework: - throw UnimplementedError(); + return IosFrameworkReleaser( + argResults: results, + flavor: flavor, + target: target, + ); case ReleaseType.aar: return AarReleaser( argResults: results, diff --git a/packages/shorebird_cli/test/src/commands/release_new/ios_framework_releaser_test.dart b/packages/shorebird_cli/test/src/commands/release_new/ios_framework_releaser_test.dart new file mode 100644 index 00000000..f2625bf4 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/release_new/ios_framework_releaser_test.dart @@ -0,0 +1,418 @@ +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_framework_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/executables.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/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( + IosFrameworkReleaser, + () { + late ArgResults argResults; + late ArtifactBuilder artifactBuilder; + late ArtifactManager artifactManager; + late CodePushClientWrapper codePushClientWrapper; + late Doctor doctor; + late Platform platform; + late Directory projectRoot; + late Logger logger; + late OperatingSystemInterface operatingSystemInterface; + late Progress progress; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + late ShorebirdEnv shorebirdEnv; + late ShorebirdFlutter shorebirdFlutter; + late ShorebirdValidator shorebirdValidator; + late XcodeBuild xcodeBuild; + late IosFrameworkReleaser iosFrameworkReleaser; + + R runWithOverrides(R Function() body) { + return runScoped( + body, + values: { + artifactBuilderRef.overrideWith(() => artifactBuilder), + artifactManagerRef.overrideWith(() => artifactManager), + 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), + }, + ); + } + + setUpAll(() { + registerFallbackValue(Directory('')); + registerFallbackValue(ReleasePlatform.ios); + setExitFunctionForTests(); + }); + + tearDownAll(restoreExitFunction); + + setUp(() { + argResults = MockArgResults(); + artifactBuilder = MockArtifactBuilder(); + artifactManager = MockArtifactManager(); + codePushClientWrapper = MockCodePushClientWrapper(); + doctor = MockDoctor(); + operatingSystemInterface = MockOperatingSystemInterface(); + platform = MockPlatform(); + progress = MockProgress(); + projectRoot = Directory.systemTemp.createTempSync(); + logger = MockLogger(); + shorebirdProcess = MockShorebirdProcess(); + shorebirdEnv = MockShorebirdEnv(); + flutterValidator = MockShorebirdFlutterValidator(); + shorebirdFlutter = MockShorebirdFlutter(); + shorebirdValidator = MockShorebirdValidator(); + xcodeBuild = MockXcodeBuild(); + + when(() => logger.progress(any())).thenReturn(progress); + + when( + () => shorebirdEnv.getShorebirdProjectRoot(), + ).thenReturn(projectRoot); + + iosFrameworkReleaser = IosFrameworkReleaser( + argResults: argResults, + flavor: null, + target: null, + ); + }); + + group('requiresReleaseVersionArg', () { + test('is true', () { + expect(iosFrameworkReleaser.requiresReleaseVersionArg, isTrue); + }); + }); + + group('releaseType', () { + test('is xcframework', () { + expect(iosFrameworkReleaser.releaseType, ReleaseType.iosFramework); + }); + }); + + group('assertArgsAreValid', () { + group('when split-per-abi is true', () { + setUp(() { + when(() => argResults.wasParsed('release-version')) + .thenReturn(false); + }); + + test('exits with code 64', () async { + await expectLater( + () => runWithOverrides(iosFrameworkReleaser.assertArgsAreValid), + exitsWithCode(ExitCode.usage), + ); + }); + }); + + group('when arguments are valid', () { + setUp(() { + when(() => argResults.wasParsed('release-version')) + .thenReturn(true); + }); + + test('returns normally', () { + expect( + () => runWithOverrides(iosFrameworkReleaser.assertArgsAreValid), + returnsNormally, + ); + }); + }); + }); + + 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(iosFrameworkReleaser.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(iosFrameworkReleaser.assertPreconditions), + exitsWithCode(exception.exitCode), + ); + verify( + () => shorebirdValidator.validatePreconditions( + checkUserIsAuthenticated: true, + checkShorebirdInitialized: true, + validators: [flutterValidator], + supportedOperatingSystems: {Platform.macOS}, + ), + ).called(1); + }); + }); + }); + + group('buildReleaseArtifacts', () { + const flutterVersionAndRevision = '3.10.6 (83305b5088)'; + + void setUpProjectRootArtifacts() { + // 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); + } + + setUp(() { + when( + () => artifactBuilder.buildIosFramework(), + ).thenAnswer( + (_) async => File(''), + ); + when(() => artifactManager.getAppXcframeworkDirectory()).thenReturn( + Directory( + p.join( + projectRoot.path, + 'build', + 'ios', + 'framework', + 'Release', + 'Flutter.xcframework', + ), + ), + ); + when( + () => shorebirdFlutter.getVersionAndRevision(), + ).thenAnswer((_) async => flutterVersionAndRevision); + + setUpProjectRootArtifacts(); + }); + + group('when build succeeds', () { + test('produces aar in release directory', () async { + final xcframework = await runWithOverrides( + iosFrameworkReleaser.buildReleaseArtifacts, + ); + + expect(xcframework.path, p.join(projectRoot.path, 'release')); + verify(artifactBuilder.buildIosFramework).called(1); + }); + }); + + group('when build fails', () { + setUp(() { + when( + () => artifactBuilder.buildIosFramework(), + ).thenThrow(Exception('build failed')); + }); + + test('logs error and exits with code 70', () async { + await expectLater( + () => runWithOverrides( + iosFrameworkReleaser.buildReleaseArtifacts, + ), + exitsWithCode(ExitCode.software), + ); + verify( + () => progress.fail( + 'Failed to build iOS framework: Exception: build failed', + ), + ).called(1); + }); + }); + }); + + group('getReleaseVersion', () { + const releaseVersion = '1.0.0'; + setUp(() { + when(() => argResults['release-version']).thenReturn(releaseVersion); + }); + + test('returns value from argResults', () async { + final result = await runWithOverrides( + () => iosFrameworkReleaser.getReleaseVersion( + releaseArtifactRoot: Directory(''), + ), + ); + expect(result, releaseVersion); + }); + }); + + group('uploadReleaseArtifacts', () { + const releaseVersion = '1.0.0'; + const appId = 'appId'; + const flutterRevision = 'deadbeef'; + + final release = Release( + id: 42, + appId: appId, + version: releaseVersion, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + platformStatuses: {}, + createdAt: DateTime(2023), + updatedAt: DateTime(2023), + ); + + setUp(() { + when( + () => codePushClientWrapper.createIosFrameworkReleaseArtifacts( + appId: any(named: 'appId'), + releaseId: any(named: 'releaseId'), + appFrameworkPath: any(named: 'appFrameworkPath'), + ), + ).thenAnswer((_) async {}); + }); + + test('uploads artifacts', () async { + await runWithOverrides( + () => iosFrameworkReleaser.uploadReleaseArtifacts( + release: release, + appId: appId, + ), + ); + + verify( + () => codePushClientWrapper.createIosFrameworkReleaseArtifacts( + appId: appId, + releaseId: release.id, + appFrameworkPath: p.join( + projectRoot.path, + 'release', + ArtifactManager.appXcframeworkName, + ), + ), + ).called(1); + }); + }); + + group('releaseMetadata', () { + const operatingSystem = 'macos'; + const operatingSystemVersion = '11.0.0'; + const xcodeVersion = '123'; + + setUp(() { + when(() => platform.operatingSystem).thenReturn(operatingSystem); + when(() => platform.operatingSystemVersion) + .thenReturn(operatingSystemVersion); + when(() => xcodeBuild.version()) + .thenAnswer((_) async => xcodeVersion); + }); + + test('returns expected metadata', () async { + expect( + await runWithOverrides(iosFrameworkReleaser.releaseMetadata), + equals( + const UpdateReleaseMetadata( + releasePlatform: ReleasePlatform.ios, + flutterVersionOverride: null, + generatedApks: false, + environment: BuildEnvironmentMetadata( + operatingSystem: operatingSystem, + operatingSystemVersion: operatingSystemVersion, + shorebirdVersion: packageVersion, + xcodeVersion: xcodeVersion, + ), + ), + ), + ); + }); + }); + + group('postReleaseInstructions', () { + test('returns expected instructions', () { + final relativeFrameworkDirectoryPath = p.relative( + p.join(projectRoot.path, 'release'), + ); + expect( + runWithOverrides( + () => iosFrameworkReleaser.postReleaseInstructions, + ), + equals(''' + +Your next step is to add the .xcframework files found in the ${lightCyan.wrap(relativeFrameworkDirectoryPath)} directory to your iOS app. + +To do this: + 1. Add the relative path to the ${lightCyan.wrap(relativeFrameworkDirectoryPath)} directory to your app's Framework Search Paths in your Xcode build settings. + 2. Embed the App.xcframework and ShorebirdFlutter.framework in your Xcode project. + +Instructions for these steps can be found at https://docs.flutter.dev/add-to-app/ios/project-setup#option-b---embed-frameworks-in-xcode. +'''), + ); + }); + }); + }, + 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 a26eac3a..51ed7bd9 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 @@ -190,8 +190,8 @@ void main() { isA(), ); expect( - () => command.getReleaser(ReleaseType.iosFramework), - throwsA(isA()), + command.getReleaser(ReleaseType.iosFramework), + isA(), ); }); });