diff --git a/packages/shorebird_cli/lib/src/commands/release/release.dart b/packages/shorebird_cli/lib/src/commands/release/release.dart index 4c1329e5..a1f36e4a 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release.dart @@ -1,2 +1,3 @@ export 'release_android_command.dart'; export 'release_command.dart'; +export 'release_ios_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/release/release_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_command.dart index 3f627b34..aaceeeef 100644 --- a/packages/shorebird_cli/lib/src/commands/release/release_command.dart +++ b/packages/shorebird_cli/lib/src/commands/release/release_command.dart @@ -39,6 +39,7 @@ class ReleaseCommand extends ShorebirdCommand HashFunction? hashFn, }) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()) { addSubcommand(ReleaseAndroidCommand(logger: logger)); + addSubcommand(ReleaseIosCommand(logger: logger)); argParser ..addOption( 'platform', diff --git a/packages/shorebird_cli/lib/src/commands/release/release_ios_command.dart b/packages/shorebird_cli/lib/src/commands/release/release_ios_command.dart new file mode 100644 index 00000000..c0e785d8 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/release/release_ios_command.dart @@ -0,0 +1,122 @@ +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:shorebird_cli/src/auth_logger_mixin.dart'; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_validation_mixin.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template release_ios_command} +/// `shorebird release ios` +/// Create new app releases for iOS. +/// {@endtemplate} +class ReleaseIosCommand extends ShorebirdCommand + with + AuthLoggerMixin, + ShorebirdValidationMixin, + ShorebirdConfigMixin, + ShorebirdBuildMixin { + /// {@macro release_ios_command} + ReleaseIosCommand({ + required super.logger, + super.auth, + super.buildCodePushClient, + super.cache, + super.validators, + }) { + argParser + ..addOption( + 'target', + abbr: 't', + help: 'The main entrypoint file of the application.', + ) + ..addOption( + 'flavor', + help: 'The product flavor to use when building the app.', + ) + ..addFlag( + 'force', + abbr: 'f', + help: 'Release without confirmation if there are no errors.', + negatable: false, + ); + } + + @override + String get description => ''' +Builds and submits your iOS app to Shorebird. +Shorebird saves the compiled Dart code from your application in order to +make smaller updates to your app. +'''; + + @override + String get name => 'ios'; + + @override + Future run() async { + if (!isShorebirdInitialized) { + logger.err( + 'Shorebird is not initialized. Did you run "shorebird init"?', + ); + return ExitCode.config.code; + } + + if (!auth.isAuthenticated) { + printNeedsAuthInstructions(); + return ExitCode.noUser.code; + } + + final validationIssues = await runValidators(); + if (validationIssuesContainsError(validationIssues)) { + logValidationFailure(issues: validationIssues); + return ExitCode.config.code; + } + + final flavor = results['flavor'] as String?; + final target = results['target'] as String?; + final buildProgress = logger.progress('Building release'); + try { + await buildIpa(flavor: flavor, target: target); + buildProgress.complete(); + } on ProcessException catch (error) { + buildProgress.fail('Failed to build: ${error.message}'); + return ExitCode.software.code; + } + + final shorebirdYaml = getShorebirdYaml()!; + final codePushClient = buildCodePushClient( + httpClient: auth.client, + hostedUri: hostedUri, + ); + + late final List apps; + final fetchAppsProgress = logger.progress('Fetching apps'); + try { + apps = (await codePushClient.getApps()) + .map((a) => App(id: a.appId, displayName: a.displayName)) + .toList(); + fetchAppsProgress.complete(); + } catch (error) { + fetchAppsProgress.fail('$error'); + return ExitCode.software.code; + } + + final appId = shorebirdYaml.getAppId(flavor: flavor); + final app = apps.firstWhereOrNull((a) => a.id == appId); + if (app == null) { + logger.err( + ''' +Could not find app with id: "$appId". +Did you forget to run "shorebird init"?''', + ); + return ExitCode.software.code; + } + + logger.info('🚧 This is still a work in progress check back later...'); + return ExitCode.success.code; + } +} 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 new file mode 100644 index 00000000..94b14e43 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/release/release_ios_command_test.dart @@ -0,0 +1,364 @@ +import 'dart:io' hide Platform; + +import 'package:args/args.dart'; +import 'package:http/http.dart' as http; +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:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/cache.dart'; +import 'package:shorebird_cli/src/commands/commands.dart'; +import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_environment.dart'; +import 'package:shorebird_cli/src/shorebird_process.dart'; +import 'package:shorebird_cli/src/validators/validators.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; +import 'package:test/test.dart'; + +class _MockArgResults extends Mock implements ArgResults {} + +class _MockHttpClient extends Mock implements http.Client {} + +class _MockAuth extends Mock implements Auth {} + +class _MockCache extends Mock implements Cache {} + +class _MockLogger extends Mock implements Logger {} + +class _MockPlatform extends Mock implements Platform {} + +class _MockProgress extends Mock implements Progress {} + +class _MockProcessResult extends Mock implements ShorebirdProcessResult {} + +class _MockCodePushClient extends Mock implements CodePushClient {} + +class _MockShorebirdFlutterValidator extends Mock + implements ShorebirdFlutterValidator {} + +class _MockShorebirdProcess extends Mock implements ShorebirdProcess {} + +void main() { + group(ReleaseIosCommand, () { + const appId = 'test-app-id'; + const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; + const versionName = '1.2.3'; + const versionCode = '1'; + const version = '$versionName+$versionCode'; + const appDisplayName = 'Test App'; + const arch = 'armv7'; + const platform = 'ios'; + const appMetadata = AppMetadata(appId: appId, displayName: appDisplayName); + const release = Release( + id: 0, + appId: appId, + version: version, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + ); + const releaseArtifact = ReleaseArtifact( + id: 0, + releaseId: 0, + arch: arch, + platform: platform, + hash: '#', + size: 42, + url: 'https://example.com', + ); + + const pubspecYamlContent = ''' +name: example +version: $version +environment: + sdk: ">=2.19.0 <3.0.0" + +flutter: + assets: + - shorebird.yaml'''; + + late ArgResults argResults; + late http.Client httpClient; + late Directory shorebirdRoot; + late Platform environmentPlatform; + late Auth auth; + late Cache cache; + late Progress progress; + late Logger logger; + late ShorebirdProcessResult flutterBuildProcessResult; + late ShorebirdProcessResult flutterRevisionProcessResult; + late CodePushClient codePushClient; + late ReleaseIosCommand command; + late Uri? capturedHostedUri; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + + Directory setUpTempDir() { + final tempDir = Directory.systemTemp.createTempSync(); + File( + p.join(tempDir.path, 'pubspec.yaml'), + ).writeAsStringSync(pubspecYamlContent); + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync('app_id: $appId'); + return tempDir; + } + + void setUpTempArtifacts(Directory dir, {String? flavor}) { + for (final archMetadata + in ShorebirdBuildMixin.allAndroidArchitectures.values) { + final artifactPath = p.join( + dir.path, + 'build', + 'app', + 'intermediates', + 'stripped_native_libs', + flavor != null ? '${flavor}Release' : 'release', + 'out', + 'lib', + archMetadata.path, + 'libapp.so', + ); + File(artifactPath).createSync(recursive: true); + } + + final bundleDirPath = p.join('build', 'app', 'outputs', 'bundle'); + final bundlePath = flavor != null + ? p.join(bundleDirPath, '${flavor}Release', 'app-$flavor-release.aab') + : p.join(bundleDirPath, 'release', 'app-release.aab'); + File(bundlePath).createSync(recursive: true); + } + + setUp(() { + argResults = _MockArgResults(); + httpClient = _MockHttpClient(); + environmentPlatform = _MockPlatform(); + shorebirdRoot = Directory.systemTemp.createTempSync(); + auth = _MockAuth(); + cache = _MockCache(); + progress = _MockProgress(); + logger = _MockLogger(); + flutterBuildProcessResult = _MockProcessResult(); + flutterRevisionProcessResult = _MockProcessResult(); + codePushClient = _MockCodePushClient(); + flutterValidator = _MockShorebirdFlutterValidator(); + shorebirdProcess = _MockShorebirdProcess(); + command = ReleaseIosCommand( + auth: auth, + buildCodePushClient: ({ + required http.Client httpClient, + Uri? hostedUri, + }) { + capturedHostedUri = hostedUri; + return codePushClient; + }, + cache: cache, + logger: logger, + validators: [flutterValidator], + ) + ..testArgResults = argResults + ..testProcess = shorebirdProcess + ..testEngineConfig = const EngineConfig.empty(); + + registerFallbackValue(shorebirdProcess); + + ShorebirdEnvironment.platform = environmentPlatform; + when(() => environmentPlatform.script).thenReturn( + Uri.file( + p.join( + shorebirdRoot.path, + 'bin', + 'cache', + 'shorebird.snapshot', + ), + ), + ); + when( + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => flutterBuildProcessResult); + when( + () => shorebirdProcess.run( + 'git', + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => flutterRevisionProcessResult); + when(() => argResults.rest).thenReturn([]); + when(() => argResults['arch']).thenReturn(arch); + when(() => argResults['platform']).thenReturn(platform); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => auth.client).thenReturn(httpClient); + when(() => cache.updateAll()).thenAnswer((_) async => {}); + when( + () => cache.getArtifactDirectory(any()), + ).thenReturn(Directory.systemTemp.createTempSync()); + when(() => logger.progress(any())).thenReturn(progress); + when(() => logger.confirm(any())).thenReturn(true); + when( + () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), + ).thenReturn(version); + when( + () => flutterBuildProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => flutterRevisionProcessResult.exitCode, + ).thenReturn(ExitCode.success.code); + when( + () => flutterRevisionProcessResult.stdout, + ).thenReturn(flutterRevision); + when( + () => codePushClient.getApps(), + ).thenAnswer((_) async => [appMetadata]); + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => [release]); + when( + () => codePushClient.createRelease( + appId: any(named: 'appId'), + version: any(named: 'version'), + flutterRevision: any(named: 'flutterRevision'), + ), + ).thenAnswer((_) async => release); + when( + () => codePushClient.createReleaseArtifact( + artifactPath: any(named: 'artifactPath'), + releaseId: any(named: 'releaseId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + ), + ).thenAnswer((_) async => releaseArtifact); + when(() => flutterValidator.validate(any())).thenAnswer((_) async => []); + }); + + test('has a description', () { + expect(command.description, isNotEmpty); + }); + + test('throws config error when shorebird is not initialized', () async { + final tempDir = Directory.systemTemp.createTempSync(); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => logger.err( + 'Shorebird is not initialized. Did you run "shorebird init"?', + ), + ).called(1); + expect(exitCode, ExitCode.config.code); + }); + + test('throws no user error when user is not logged in', () async { + when(() => auth.isAuthenticated).thenReturn(false); + final tempDir = setUpTempDir(); + final exitCode = await IOOverrides.runZoned( + () => command.run(), + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.noUser.code)); + }); + + test('exits with code 70 when building fails', () async { + when(() => flutterBuildProcessResult.exitCode).thenReturn(1); + when(() => flutterBuildProcessResult.stderr).thenReturn('oops'); + + final tempDir = setUpTempDir(); + final exitCode = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(exitCode, equals(ExitCode.software.code)); + }); + + test('throws error when fetching apps fails.', () async { + const error = 'something went wrong'; + when(() => codePushClient.getApps()).thenThrow(error); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => progress.fail(error)).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('throws error when app does not exist.', () async { + when( + () => logger.prompt(any(), defaultValue: any(named: 'defaultValue')), + ).thenReturn(appDisplayName); + when(() => codePushClient.getApps()).thenAnswer((_) async => []); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => logger.err( + ''' +Could not find app with id: "$appId". +Did you forget to run "shorebird init"?''', + ), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('prints flutter validation warnings', () async { + when(() => flutterValidator.validate(any())).thenAnswer( + (_) async => [ + const ValidationIssue( + severity: ValidationIssueSeverity.warning, + message: 'Flutter issue 1', + ), + const ValidationIssue( + severity: ValidationIssueSeverity.warning, + message: 'Flutter issue 2', + ), + ], + ); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, ExitCode.success.code); + expect(capturedHostedUri, isNull); + verify( + () => logger.info(any(that: contains('Flutter issue 1'))), + ).called(1); + verify( + () => logger.info(any(that: contains('Flutter issue 2'))), + ).called(1); + }); + + test('aborts if validation errors are present', () async { + when(() => flutterValidator.validate(any())).thenAnswer( + (_) async => [ + const ValidationIssue( + severity: ValidationIssueSeverity.error, + message: 'There was an issue', + ), + ], + ); + + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.config.code)); + verify(() => logger.err('Aborting due to validation errors.')).called(1); + }); + }); +}