refactor(shorebird_cli): Add IosFrameworkReleaser for new release command (#2013)

This commit is contained in:
Bryan Oltman
2024-05-07 14:46:42 -04:00
committed by GitHub
parent b37c5b1b8a
commit 222308f1d9
5 changed files with 577 additions and 8 deletions
@@ -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<void> assertArgsAreValid() async {
if (!argResults.wasParsed('release-version')) {
logger.err('Missing required argument: --release-version');
exit(ExitCode.usage.code);
}
}
@override
Future<void> 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<FileSystemEntity> 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<String> getReleaseVersion({
required FileSystemEntity releaseArtifactRoot,
}) async {
return argResults['release-version'] as String;
}
@override
Future<void> 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<UpdateReleaseMetadata> 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(),
),
);
}
@@ -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';
@@ -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,
@@ -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>(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',
);
}
@@ -190,8 +190,8 @@ void main() {
isA<IosReleaser>(),
);
expect(
() => command.getReleaser(ReleaseType.iosFramework),
throwsA(isA<UnimplementedError>()),
command.getReleaser(ReleaseType.iosFramework),
isA<IosFrameworkReleaser>(),
);
});
});