diff --git a/packages/shorebird_cli/lib/src/archive_analysis/ipa.dart b/packages/shorebird_cli/lib/src/archive_analysis/ipa.dart index 0da44d0a..52f02e1a 100644 --- a/packages/shorebird_cli/lib/src/archive_analysis/ipa.dart +++ b/packages/shorebird_cli/lib/src/archive_analysis/ipa.dart @@ -43,23 +43,25 @@ class Ipa { if (releaseVersion == null) { throw Exception('Could not determine release version'); } - if (buildNumber != null) { - return '$releaseVersion+$buildNumber'; - } else { - return releaseVersion; - } + + return buildNumber == null + ? releaseVersion + : '$releaseVersion+$buildNumber'; } Map _getPlist() { final plistPathRegex = RegExp(r'Payload/[\w]+.app/Info.plist'); - final content = ZipDecoder() + final plistFile = ZipDecoder() .decodeBuffer(InputFileStream(_ipaFile.path)) .files .where((file) { - return file.isFile && plistPathRegex.hasMatch(file.name); - }) - .first - .content as Uint8List; + return file.isFile && plistPathRegex.hasMatch(file.name); + }).firstOrNull; + if (plistFile == null) { + return {}; + } + + final content = plistFile.content as Uint8List; return PropertyListSerialization.propertyListWithData( ByteData.view(content.buffer), diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch.dart b/packages/shorebird_cli/lib/src/commands/patch/patch.dart index 98ef7dc9..34c91aa5 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch.dart @@ -1,3 +1,4 @@ export 'patch_aar_command.dart'; export 'patch_android_command.dart'; export 'patch_command.dart'; +export 'patch_ios_command.dart'; diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart index cbf31e00..b4b8e2ee 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_aar_command.dart @@ -378,7 +378,6 @@ ${summary.join('\n')} createArtifactProgress.complete(); Channel? channel; - try { channel = await getChannel(appId: app.id, name: channelName); } catch (error) { @@ -386,8 +385,9 @@ ${summary.join('\n')} } if (channel == null) { - channel = await createChannel(appId: appId, name: channelName); - if (channel == null) { + try { + channel = await createChannel(appId: appId, name: channelName); + } catch (_) { return ExitCode.software.code; } } diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart index 2b70cd69..1045c020 100644 --- a/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_command.dart @@ -10,6 +10,7 @@ class PatchCommand extends ShorebirdCommand { PatchCommand({required super.logger}) { addSubcommand(PatchAarCommand(logger: logger)); addSubcommand(PatchAndroidCommand(logger: logger)); + addSubcommand(PatchIosCommand(logger: logger)); } @override diff --git a/packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart b/packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart new file mode 100644 index 00000000..2b8972a0 --- /dev/null +++ b/packages/shorebird_cli/lib/src/commands/patch/patch_ios_command.dart @@ -0,0 +1,313 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; +import 'package:shorebird_cli/src/archive_analysis/archive_analysis.dart'; +import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/config/config.dart'; +import 'package:shorebird_cli/src/formatters/file_size_formatter.dart'; +import 'package:shorebird_cli/src/shorebird_artifact_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_build_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_code_push_client_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_config_mixin.dart'; +import 'package:shorebird_cli/src/shorebird_environment.dart'; +import 'package:shorebird_cli/src/shorebird_validation_mixin.dart'; +import 'package:shorebird_code_push_client/shorebird_code_push_client.dart'; + +/// {@template patch_ios_command} +/// `shorebird patch ios-preview` command. +/// {@endtemplate} +class PatchIosCommand extends ShorebirdCommand + with + ShorebirdConfigMixin, + ShorebirdBuildMixin, + ShorebirdValidationMixin, + ShorebirdArtifactMixin, + ShorebirdCodePushClientMixin { + /// {@macro patch_ios_command} + PatchIosCommand({ + required super.logger, + super.auth, + super.buildCodePushClient, + super.validators, + HashFunction? hashFn, + IpaReader? ipaReader, + }) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()), + _ipaReader = ipaReader ?? IpaReader() { + 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: 'Patch without confirmation if there are no errors.', + negatable: false, + ) + ..addFlag( + 'dry-run', + abbr: 'n', + negatable: false, + help: 'Validate but do not upload the patch.', + ); + } + + @override + String get name => 'ios-preview'; + + @override + String get description => + 'Publish new patches for a specific iOS release to Shorebird.'; + + final HashFunction _hashFn; + final IpaReader _ipaReader; + + @override + Future run() async { + try { + await validatePreconditions( + checkShorebirdInitialized: true, + checkUserIsAuthenticated: true, + checkValidators: true, + ); + } on PreconditionFailedException catch (error) { + return error.exitCode.code; + } + + const channelName = 'stable'; + const platform = 'ios'; + final force = results['force'] == true; + final dryRun = results['dry-run'] == true; + final flavor = results['flavor'] as String?; + final target = results['target'] as String?; + + if (force && dryRun) { + logger.err('Cannot use both --force and --dry-run.'); + return ExitCode.usage.code; + } + + final shorebirdYaml = getShorebirdYaml()!; + final appId = shorebirdYaml.getAppId(flavor: flavor); + final App? app; + try { + app = await getApp(appId: appId, flavor: flavor); + } catch (_) { + return ExitCode.software.code; + } + + if (app == null) { + logger.err( + ''' +Could not find app with id: "$appId". +Did you forget to run "shorebird init"?''', + ); + return ExitCode.software.code; + } + + final buildProgress = logger.progress('Building release'); + try { + await buildIpa(flavor: flavor, target: target); + } on ProcessException catch (error) { + buildProgress.fail('Failed to build: ${error.message}'); + return ExitCode.software.code; + } + + final File aotFile; + try { + final newestDillFile = newestAppDill(); + aotFile = await buildElfAotSnapshot(appDillPath: newestDillFile.path); + } catch (error) { + buildProgress.fail('$error'); + return ExitCode.software.code; + } + + buildProgress.complete(); + + final String releaseVersion; + + final detectReleaseVersionProgress = logger.progress( + 'Detecting release version', + ); + try { + final pubspec = getPubspecYaml()!; + final ipa = _ipaReader.read( + p.join( + Directory.current.path, + 'build', + 'ios', + 'ipa', + '${pubspec.name}.ipa', + ), + ); + releaseVersion = ipa.versionNumber; + detectReleaseVersionProgress.complete(); + } catch (error) { + detectReleaseVersionProgress.fail( + 'Failed to determine release version: $error', + ); + return ExitCode.software.code; + } + + final Release? release; + try { + release = await getRelease(appId: appId, releaseVersion: releaseVersion); + } catch (_) { + return ExitCode.software.code; + } + + if (release == null) { + logger.err( + ''' +Release not found: "$releaseVersion" + +Patches can only be published for existing releases. +Please create a release using "shorebird release" and try again. +''', + ); + return ExitCode.software.code; + } + + final flutterRevisionProgress = logger.progress( + 'Fetching Flutter revision', + ); + final String shorebirdFlutterRevision; + try { + shorebirdFlutterRevision = await getShorebirdFlutterRevision(); + flutterRevisionProgress.complete(); + } catch (error) { + flutterRevisionProgress.fail('$error'); + return ExitCode.software.code; + } + + if (release.flutterRevision != shorebirdFlutterRevision) { + logger + ..err(''' +Flutter revision mismatch. + +The release you are trying to patch was built with a different version of Flutter. + +Release Flutter Revision: ${release.flutterRevision} +Current Flutter Revision: $shorebirdFlutterRevision +''') + ..info( + ''' +Either create a new release using: + ${lightCyan.wrap('shorebird release')} + +Or downgrade your Flutter version and try again using: + ${lightCyan.wrap('cd ${ShorebirdEnvironment.flutterDirectory.path}')} + ${lightCyan.wrap('git checkout ${release.flutterRevision}')} + +Shorebird plans to support this automatically, let us know if it's important to you: +https://github.com/shorebirdtech/shorebird/issues/472 +''', + ); + return ExitCode.software.code; + } + + if (dryRun) { + logger + ..info('No issues detected.') + ..info('The server may enforce additional checks.'); + return ExitCode.success.code; + } + + final size = formatBytes(aotFile.statSync().size); + + final summary = [ + '''šŸ“± App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.id})')}''', + if (flavor != null) 'šŸ§ Flavor: ${lightCyan.wrap(flavor)}', + 'šŸ“¦ Release Version: ${lightCyan.wrap(releaseVersion)}', + 'šŸ“ŗ Channel: ${lightCyan.wrap(channelName)}', + '''šŸ•¹ļø Platform: ${lightCyan.wrap(platform)} ${lightCyan.wrap('[arm64 ($size)]')}''', + ]; + + logger.info( + ''' + +${styleBold.wrap(lightGreen.wrap('šŸš€ Ready to publish a new patch!'))} + +${summary.join('\n')} +''', + ); + + final needsConfirmation = !force; + if (needsConfirmation) { + final confirm = logger.confirm('Would you like to continue?'); + + if (!confirm) { + logger.info('Aborting.'); + return ExitCode.success.code; + } + } + + final Patch patch; + try { + patch = await createPatch(releaseId: release.id); + } catch (e) { + return ExitCode.software.code; + } + + final codePushClient = buildCodePushClient( + httpClient: auth.client, + hostedUri: hostedUri, + ); + + // TODO(bryanoltman): check for asset changes + + final createArtifactProgress = logger.progress('Uploading artifacts'); + try { + await codePushClient.createPatchArtifact( + patchId: patch.id, + artifactPath: aotFile.path, + arch: 'arm64', + platform: 'ios', + hash: _hashFn(await aotFile.readAsBytes()), + ); + } catch (error) { + createArtifactProgress.fail('$error'); + return ExitCode.software.code; + } + createArtifactProgress.complete(); + + Channel? channel; + try { + channel = await getChannel(appId: appId, name: channelName); + } catch (_) { + return ExitCode.software.code; + } + + if (channel == null) { + try { + channel = await createChannel(appId: appId, name: channelName); + } catch (_) { + return ExitCode.software.code; + } + } + + final publishPatchProgress = logger.progress( + 'Promoting patch to ${channel.name}', + ); + try { + await codePushClient.promotePatch( + patchId: patch.id, + channelId: channel.id, + ); + publishPatchProgress.complete(); + } catch (error) { + publishPatchProgress.fail('$error'); + return ExitCode.software.code; + } + + logger.success('\nāœ… Published Patch!'); + return ExitCode.success.code; + } +} diff --git a/packages/shorebird_cli/lib/src/shorebird_artifact_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_artifact_mixin.dart index 03dac68e..6ee8e1c3 100644 --- a/packages/shorebird_cli/lib/src/shorebird_artifact_mixin.dart +++ b/packages/shorebird_cli/lib/src/shorebird_artifact_mixin.dart @@ -56,4 +56,26 @@ mixin ShorebirdArtifactMixin on ShorebirdCommand { await unzipFn(zipPath, extractedAarDir); return extractedAarDir; } + + /// Finds the most recently-edited app.dill file in the .dart_tool directory. + // TODO(bryanoltman): This is an enormous hack – we don't know that this is + // the correct file. + File newestAppDill() { + final dartToolBuildDir = Directory( + p.join( + Directory.current.path, + '.dart_tool', + 'flutter_build', + ), + ); + + return dartToolBuildDir + .listSync(recursive: true) + .whereType() + .where((f) => p.basename(f.path) == 'app.dill') + .reduce( + (a, b) => + a.statSync().modified.isAfter(b.statSync().modified) ? a : b, + ); + } } diff --git a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart index ab83b803..456ca4e4 100644 --- a/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart +++ b/packages/shorebird_cli/lib/src/shorebird_build_mixin.dart @@ -2,7 +2,9 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; import 'package:shorebird_cli/src/command.dart'; +import 'package:shorebird_cli/src/shorebird_environment.dart'; enum Arch { arm64, @@ -182,4 +184,57 @@ mixin ShorebirdBuildMixin on ShorebirdCommand { ); } } + + Future createDiff({ + required String releaseArtifactPath, + required String patchArtifactPath, + }) async { + final tempDir = await Directory.systemTemp.createTemp(); + final diffPath = p.join(tempDir.path, 'diff.patch'); + final diffExecutable = p.join( + cache.getArtifactDirectory('patch').path, + 'patch', + ); + final diffArguments = [ + releaseArtifactPath, + patchArtifactPath, + diffPath, + ]; + + final result = await process.run( + diffExecutable, + diffArguments, + runInShell: true, + ); + + if (result.exitCode != 0) { + throw Exception('Failed to create diff: ${result.stderr}'); + } + + return diffPath; + } + + /// Creates an AOT snapshot of the given [appDillPath] and returns the + /// resulting snapshot file. + // TODO(bryanoltman): make this work with the --local-engine flag. + Future buildElfAotSnapshot({required String appDillPath}) async { + final outFilePath = p.join(Directory.current.path, 'out.aot'); + final arguments = [ + '--deterministic', + '--snapshot-kind=app-aot-elf', + '--elf=$outFilePath', + appDillPath + ]; + + final result = await process.run( + ShorebirdEnvironment.genSnapshotFile.path, + arguments, + ); + + if (result.exitCode != ExitCode.success.code) { + throw Exception('Failed to create snapshot: ${result.stderr}'); + } + + return File(outFilePath); + } } diff --git a/packages/shorebird_cli/lib/src/shorebird_code_push_client_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_code_push_client_mixin.dart index 42d3cea0..0bf09a9c 100644 --- a/packages/shorebird_cli/lib/src/shorebird_code_push_client_mixin.dart +++ b/packages/shorebird_cli/lib/src/shorebird_code_push_client_mixin.dart @@ -73,7 +73,7 @@ mixin ShorebirdCodePushClientMixin on ShorebirdConfigMixin { } } - Future createChannel({ + Future createChannel({ required String appId, required String name, }) async { @@ -92,7 +92,7 @@ mixin ShorebirdCodePushClientMixin on ShorebirdConfigMixin { return channel; } catch (error) { createChannelProgress.fail('$error'); - return null; + rethrow; } } @@ -195,32 +195,22 @@ mixin ShorebirdCodePushClientMixin on ShorebirdConfigMixin { return releaseArtifact.path; } - Future createDiff({ - required String releaseArtifactPath, - required String patchArtifactPath, - }) async { - final tempDir = await Directory.systemTemp.createTemp(); - final diffPath = p.join(tempDir.path, 'diff.patch'); - final diffExecutable = p.join( - cache.getArtifactDirectory('patch').path, - 'patch', - ); - final diffArguments = [ - releaseArtifactPath, - patchArtifactPath, - diffPath, - ]; - - final result = await process.run( - diffExecutable, - diffArguments, - runInShell: true, + Future createPatch({required int releaseId}) async { + final codePushClient = buildCodePushClient( + httpClient: auth.client, + hostedUri: hostedUri, ); - if (result.exitCode != 0) { - throw Exception('Failed to create diff: ${result.stderr}'); + final Patch patch; + final createPatchProgress = logger.progress('Creating patch'); + try { + patch = await codePushClient.createPatch(releaseId: releaseId); + createPatchProgress.complete(); + } catch (error) { + createPatchProgress.fail('$error'); + rethrow; } - return diffPath; + return patch; } } diff --git a/packages/shorebird_cli/lib/src/shorebird_environment.dart b/packages/shorebird_cli/lib/src/shorebird_environment.dart index 9172fb5c..15f94541 100644 --- a/packages/shorebird_cli/lib/src/shorebird_environment.dart +++ b/packages/shorebird_cli/lib/src/shorebird_environment.dart @@ -63,4 +63,16 @@ abstract class ShorebirdEnvironment { 'flutter', ), ); + + static File get genSnapshotFile => File( + p.join( + flutterDirectory.path, + 'bin', + 'cache', + 'artifacts', + 'engine', + 'ios-release', + 'gen_snapshot_arm64', + ), + ); } diff --git a/packages/shorebird_cli/lib/src/shorebird_ios_release_version_mixin.dart b/packages/shorebird_cli/lib/src/shorebird_ios_release_version_mixin.dart new file mode 100644 index 00000000..c475e854 --- /dev/null +++ b/packages/shorebird_cli/lib/src/shorebird_ios_release_version_mixin.dart @@ -0,0 +1,97 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:propertylistserialization/propertylistserialization.dart'; +import 'package:shorebird_cli/src/command.dart'; + +/// Helpers to determine the release and build number of an iOS app. +mixin ShorebirdIosReleaseVersionMixin on ShorebirdCommand { + /// This key is a user-visible string for the version of the bundle. The + /// required format is three period-separated integers, such as 10.14.1. The + /// string can only contain numeric characters (0-9) and periods. + /// + /// See https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleshortversionstring + static const releaseVersionKey = 'CFBundleShortVersionString'; + + /// The version of the build that identifies an iteration of the bundle. + /// + /// See https://developer.apple.com/documentation/bundleresources/information_property_list/cfbundleversion + static const buildNumberKey = 'CFBundleVersion'; + + /// Checks the iOS Info.plist and referenced .xcconfig files to determine the + /// app's release version and build number. + Future determineIosReleaseVersion() async { + final configPropertyRegex = RegExp(r'\$\((\w+)\)'); + + // TODO(bryanoltman): is it safe to assume "Runner" as the target name? + // See https://github.com/flutter/flutter/issues/9767 + final plistPath = p.join( + Directory.current.path, + 'ios', + 'Runner', + 'Info.plist', + ); + + final plist = PropertyListSerialization.propertyListWithString( + File(plistPath).readAsStringSync(), + ) as Map; + final pListVariables = _configVariables( + path: p.join( + Directory.current.path, + 'ios', + 'Flutter', + 'Release.xcconfig', + ), + ); + var releaseVersion = plist[releaseVersionKey] as String?; + var buildNumber = plist[buildNumberKey] as String?; + + if (releaseVersion == null) { + throw Exception('Could not determine release version'); + } + + if (configPropertyRegex.hasMatch(releaseVersion)) { + releaseVersion = pListVariables[ + configPropertyRegex.firstMatch(releaseVersion)!.group(1)!]; + if (releaseVersion == null) { + throw Exception('Could not determine release version'); + } + } + + if (buildNumber != null && configPropertyRegex.hasMatch(buildNumber)) { + buildNumber = pListVariables[ + configPropertyRegex.firstMatch(buildNumber)!.group(1)!]; + } + + return [releaseVersion, buildNumber].whereType().join('+'); + } + + /// Accepts a path to an .xcconfig file and returns a map of the variables + /// it defines. If the file contains an `#include` directive, the included + /// file will be recursively parsed and its variables will be included in the + /// map. + Map _configVariables({required String path}) { + final properties = {}; + final includeRegex = RegExp(r'^#include "(.+)"$'); + final lines = File(path).readAsLinesSync(); + for (var line in lines) { + line = line.trim(); + if (line.isEmpty || line.startsWith('//')) { + continue; + } + + if (includeRegex.hasMatch(line)) { + final fileName = includeRegex.firstMatch(line)!.group(1)!; + properties.addEntries( + _configVariables(path: p.join(p.dirname(path), fileName)).entries, + ); + continue; + } + + final parts = line.split('='); + properties[parts[0]] = parts[1]; + } + + return properties; + } +} diff --git a/packages/shorebird_cli/test/fixtures/ipas/README.md b/packages/shorebird_cli/test/fixtures/ipas/README.md index 2efd26e2..f098c35d 100644 --- a/packages/shorebird_cli/test/fixtures/ipas/README.md +++ b/packages/shorebird_cli/test/fixtures/ipas/README.md @@ -4,4 +4,5 @@ Some of their contents has been removed to reduce the size of the files. All .dy Files: - base.ipa is meant to represent an ipa uploaded as part of a release. -- no_version.ipa is base.ipa with the version info removed from the Info.plist. +- no_version.ipa is base.ipa with the version info removed from the Info.plist, along with several other files to save space. +- no_plist.ipa is base.ipa with the Info.plist (and many other parts) removed. diff --git a/packages/shorebird_cli/test/fixtures/ipas/no_plist.ipa b/packages/shorebird_cli/test/fixtures/ipas/no_plist.ipa new file mode 100644 index 00000000..7e92a185 Binary files /dev/null and b/packages/shorebird_cli/test/fixtures/ipas/no_plist.ipa differ diff --git a/packages/shorebird_cli/test/fixtures/ipas/no_version.ipa b/packages/shorebird_cli/test/fixtures/ipas/no_version.ipa index 543b63ff..11db636e 100644 Binary files a/packages/shorebird_cli/test/fixtures/ipas/no_version.ipa and b/packages/shorebird_cli/test/fixtures/ipas/no_version.ipa differ diff --git a/packages/shorebird_cli/test/src/archive_analysis/ipa_test.dart b/packages/shorebird_cli/test/src/archive_analysis/ipa_test.dart index 8b58a259..77162be9 100644 --- a/packages/shorebird_cli/test/src/archive_analysis/ipa_test.dart +++ b/packages/shorebird_cli/test/src/archive_analysis/ipa_test.dart @@ -5,7 +5,8 @@ import 'package:test/test.dart'; void main() { final ipaFixturesBasePath = p.join('test', 'fixtures', 'ipas'); final baseIpaBath = p.join(ipaFixturesBasePath, 'base.ipa'); - final noVersionIpaBath = p.join(ipaFixturesBasePath, 'no_version.ipa'); + final noVersionIpaPath = p.join(ipaFixturesBasePath, 'no_version.ipa'); + final noPlistIpaPath = p.join(ipaFixturesBasePath, 'no_plist.ipa'); group(IpaReader, () { test('creates Ipa', () { @@ -20,8 +21,13 @@ void main() { expect(ipa.versionNumber, '1.0.0+1'); }); + test('throws exception if no Info.plist is found', () { + final ipa = Ipa(path: noPlistIpaPath); + expect(() => ipa.versionNumber, throwsException); + }); + test('throws exception if no version is found in Info.plist', () { - final ipa = Ipa(path: noVersionIpaBath); + final ipa = Ipa(path: noVersionIpaPath); expect(() => ipa.versionNumber, throwsException); }); }); diff --git a/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart b/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart new file mode 100644 index 00000000..0a9e4951 --- /dev/null +++ b/packages/shorebird_cli/test/src/commands/patch/patch_ios_command_test.dart @@ -0,0 +1,695 @@ +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:shorebird_cli/src/archive_analysis/archive_analysis.dart'; +import 'package:shorebird_cli/src/auth/auth.dart'; +import 'package:shorebird_cli/src/commands/patch/patch.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 _FakeBaseRequest extends Fake implements http.BaseRequest {} + +class _MockArgResults extends Mock implements ArgResults {} + +class _MockAuth extends Mock implements Auth {} + +class _MockIpaReader extends Mock implements IpaReader {} + +class _MockIpa extends Mock implements Ipa {} + +class _MockLogger extends Mock implements Logger {} + +class _MockProgress extends Mock implements Progress {} + +class _MockProcessResult extends Mock implements ShorebirdProcessResult {} + +class _MockHttpClient extends Mock implements http.Client {} + +class _MockCodePushClient extends Mock implements CodePushClient {} + +class _MockShorebirdFlutterValidator extends Mock + implements ShorebirdFlutterValidator {} + +class _MockShorebirdProcess extends Mock implements ShorebirdProcess {} + +class _FakeShorebirdProcess extends Fake implements ShorebirdProcess {} + +void main() { + const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713'; + const appId = 'test-app-id'; + const versionName = '1.2.3'; + const versionCode = '1'; + const version = '$versionName+$versionCode'; + const arch = 'aarch64'; + const appDisplayName = 'Test App'; + const channelName = 'stable'; + const platform = 'ios'; + const pubspecYamlContent = ''' +name: example +version: $version +environment: + sdk: ">=2.19.0 <3.0.0" + +flutter: + assets: + - shorebird.yaml'''; + + const appMetadata = AppMetadata(appId: appId, displayName: appDisplayName); + const channel = Channel(id: 0, appId: appId, name: channelName); + const patch = Patch(id: 0, number: 1); + const patchArtifact = PatchArtifact( + id: 0, + patchId: 0, + arch: arch, + platform: platform, + hash: '#', + size: 42, + url: 'https://example.com', + ); + const release = Release( + id: 0, + appId: appId, + version: version, + flutterRevision: flutterRevision, + displayName: '1.2.3+1', + ); + + group(PatchIosCommand, () { + late ArgResults argResults; + late Auth auth; + late Ipa ipa; + late IpaReader ipaReader; + late Progress progress; + late Logger logger; + late ShorebirdProcessResult aotBuildProcessResult; + late ShorebirdProcessResult flutterBuildProcessResult; + late ShorebirdProcessResult flutterRevisionProcessResult; + late http.Client httpClient; + late CodePushClient codePushClient; + late Uri? capturedHostedUri; + late ShorebirdFlutterValidator flutterValidator; + late ShorebirdProcess shorebirdProcess; + + late PatchIosCommand command; + + 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) { + // Create a second app.dill for coverage of newestAppDill file. + File( + p.join( + dir.path, + '.dart_tool', + 'flutter_build', + 'subdir', + 'app.dill', + ), + ).createSync(recursive: true); + File( + p.join(dir.path, '.dart_tool', 'flutter_build', 'app.dill'), + ).createSync(recursive: true); + File(p.join(dir.path, 'out.aot')).createSync(); + } + + setUpAll(() { + registerFallbackValue(_FakeBaseRequest()); + registerFallbackValue(_FakeShorebirdProcess()); + }); + + setUp(() { + argResults = _MockArgResults(); + auth = _MockAuth(); + ipaReader = _MockIpaReader(); + ipa = _MockIpa(); + progress = _MockProgress(); + logger = _MockLogger(); + aotBuildProcessResult = _MockProcessResult(); + flutterBuildProcessResult = _MockProcessResult(); + flutterRevisionProcessResult = _MockProcessResult(); + httpClient = _MockHttpClient(); + codePushClient = _MockCodePushClient(); + flutterValidator = _MockShorebirdFlutterValidator(); + shorebirdProcess = _MockShorebirdProcess(); + + command = PatchIosCommand( + auth: auth, + ipaReader: ipaReader, + logger: logger, + validators: [flutterValidator], + buildCodePushClient: ({ + required http.Client httpClient, + Uri? hostedUri, + }) { + capturedHostedUri = hostedUri; + return codePushClient; + }, + ) + ..testArgResults = argResults + ..testProcess = shorebirdProcess + ..testEngineConfig = const EngineConfig.empty(); + + when(() => argResults['arch']).thenReturn(arch); + when(() => argResults['dry-run']).thenReturn(false); + when(() => argResults['force']).thenReturn(false); + when(() => argResults.rest).thenReturn([]); + when(() => auth.isAuthenticated).thenReturn(true); + when(() => auth.client).thenReturn(httpClient); + when( + () => codePushClient.getApps(), + ).thenAnswer((_) async => [appMetadata]); + when( + () => codePushClient.getChannels(appId: any(named: 'appId')), + ).thenAnswer((_) async => [channel]); + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => [release]); + when( + () => codePushClient.createChannel( + appId: any(named: 'appId'), + channel: any(named: 'channel'), + ), + ).thenAnswer((_) async => channel); + when( + () => codePushClient.createPatch(releaseId: any(named: 'releaseId')), + ).thenAnswer((_) async => patch); + when( + () => codePushClient.createPatchArtifact( + artifactPath: any(named: 'artifactPath'), + patchId: any(named: 'patchId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + ), + ).thenAnswer((_) async => patchArtifact); + when( + () => codePushClient.promotePatch( + patchId: any(named: 'patchId'), + channelId: any(named: 'channelId'), + ), + ).thenAnswer((_) async {}); + when(() => ipa.versionNumber).thenReturn(version); + when(() => ipaReader.read(any())).thenReturn(ipa); + when(() => flutterValidator.validate(any())).thenAnswer((_) async => []); + when(() => logger.confirm(any())).thenReturn(true); + when(() => logger.progress(any())).thenReturn(progress); + when(() => aotBuildProcessResult.exitCode) + .thenReturn(ExitCode.success.code); + when(() => flutterBuildProcessResult.exitCode) + .thenReturn(ExitCode.success.code); + when(() => flutterRevisionProcessResult.exitCode) + .thenReturn(ExitCode.success.code); + when( + () => flutterRevisionProcessResult.stdout, + ).thenReturn(flutterRevision); + when( + () => shorebirdProcess.run( + 'git', + any(), + runInShell: any(named: 'runInShell'), + workingDirectory: any(named: 'workingDirectory'), + ), + ).thenAnswer((_) async => flutterRevisionProcessResult); + when( + () => shorebirdProcess.run( + 'flutter', + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => flutterBuildProcessResult); + when( + () => shorebirdProcess.run( + any(that: endsWith('gen_snapshot_arm64')), + any(), + runInShell: any(named: 'runInShell'), + ), + ).thenAnswer((_) async => aotBuildProcessResult); + }); + + test('has a description', () { + expect(command.description, isNotEmpty); + }); + + 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( + 'exits with usage code when ' + 'both --dry-run and --force are specified', () async { + when(() => argResults['dry-run']).thenReturn(true); + when(() => argResults['force']).thenReturn(true); + final tempDir = setUpTempDir(); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.usage.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 fails.', () 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('aborts when user opts out', () async { + when(() => logger.confirm(any())).thenReturn(false); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, ExitCode.success.code); + verify(() => logger.info('Aborting.')).called(1); + }); + + test('errors when unable to detect flutter revision', () async { + const error = 'oops'; + when(() => flutterRevisionProcessResult.exitCode).thenReturn(1); + when(() => flutterRevisionProcessResult.stderr).thenReturn(error); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, ExitCode.software.code); + verify( + () => progress.fail( + 'Exception: Unable to determine flutter revision: $error', + ), + ).called(1); + }); + + test( + 'errors when shorebird flutter revision ' + 'does not match release revision', () async { + const otherRevision = 'other-revision'; + when(() => flutterRevisionProcessResult.stdout).thenReturn(otherRevision); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, ExitCode.software.code); + final shorebirdFlutterPath = ShorebirdEnvironment.flutterDirectory.path; + verify( + () => logger.err(''' +Flutter revision mismatch. + +The release you are trying to patch was built with a different version of Flutter. + +Release Flutter Revision: $flutterRevision +Current Flutter Revision: $otherRevision +'''), + ).called(1); + verify( + () => logger.info(''' +Either create a new release using: + ${lightCyan.wrap('shorebird release')} + +Or downgrade your Flutter version and try again using: + ${lightCyan.wrap('cd $shorebirdFlutterPath')} + ${lightCyan.wrap('git checkout ${release.flutterRevision}')} + +Shorebird plans to support this automatically, let us know if it's important to you: +https://github.com/shorebirdtech/shorebird/issues/472 +'''), + ).called(1); + }); + + test('throws error when fetching releases fails.', () async { + const error = 'something went wrong'; + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).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 release does not exist.', () async { + when( + () => codePushClient.getReleases(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => logger.err( + ''' +Release not found: "$version" + +Patches can only be published for existing releases. +Please create a release using "shorebird release" and try again. +''', + ), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('exits with code 70 when release version cannot be determiend', + () async { + when(() => ipa.versionNumber).thenThrow(Exception('oops')); + + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + () async => command.run(), + getCurrentDirectory: () => tempDir, + ); + + expect(exitCode, equals(ExitCode.software.code)); + verify( + () => progress + .fail(any(that: contains('Failed to determine release version'))), + ).called(1); + }); + + test('throws error when creating aot snapshot fails', () async { + const error = 'oops something went wrong'; + when(() => aotBuildProcessResult.exitCode).thenReturn(1); + when(() => aotBuildProcessResult.stderr).thenReturn(error); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => progress.fail('Exception: Failed to create snapshot: $error'), + ).called(1); + expect(exitCode, ExitCode.software.code); + }); + + test('does not create patch on --dry-run', () async { + when(() => argResults['dry-run']).thenReturn(true); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.success.code)); + verifyNever( + () => codePushClient.createPatch(releaseId: any(named: 'releaseId')), + ); + verify(() => logger.info('No issues detected.')).called(1); + }); + + test('does not prompt on --force', () async { + when(() => argResults['force']).thenReturn(true); + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(exitCode, equals(ExitCode.success.code)); + verifyNever(() => logger.confirm(any())); + verify( + () => codePushClient.createPatch(releaseId: any(named: 'releaseId')), + ).called(1); + }); + + test('throws error when creating patch fails.', () async { + const error = 'something went wrong'; + when( + () => codePushClient.createPatch(releaseId: any(named: 'releaseId')), + ).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 uploading patch artifact fails.', () async { + const error = 'something went wrong'; + when( + () => codePushClient.createPatchArtifact( + artifactPath: any(named: 'artifactPath'), + patchId: any(named: 'patchId'), + arch: any(named: 'arch'), + platform: any(named: 'platform'), + hash: any(named: 'hash'), + ), + ).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 fetching channels fails.', () async { + const error = 'something went wrong'; + when( + () => codePushClient.getChannels(appId: any(named: 'appId')), + ).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 creating channel fails.', () async { + const error = 'something went wrong'; + when( + () => codePushClient.getChannels(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + when( + () => codePushClient.createChannel( + appId: any(named: 'appId'), + channel: any(named: 'channel'), + ), + ).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 promoting patch fails.', () async { + const error = 'something went wrong'; + when( + () => codePushClient.getChannels(appId: any(named: 'appId')), + ).thenAnswer((_) async => []); + when( + () => codePushClient.promotePatch( + patchId: any(named: 'patchId'), + channelId: any(named: 'channelId'), + ), + ).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('succeeds when patch is successful', () async { + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify( + () => logger.info( + any( + that: contains( + '''šŸ•¹ļø Platform: ${lightCyan.wrap(platform)} ${lightCyan.wrap('[arm64 (0 B)]')}''', + ), + ), + ), + ).called(1); + verify(() => logger.success('\nāœ… Published Patch!')).called(1); + expect(exitCode, ExitCode.success.code); + expect(capturedHostedUri, isNull); + }); + + test( + 'succeeds when patch is successful ' + 'with flavors and target', () async { + const flavor = 'development'; + const target = './lib/main_development.dart'; + when(() => argResults['flavor']).thenReturn(flavor); + when(() => argResults['target']).thenReturn(target); + final tempDir = setUpTempDir(); + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync(''' +app_id: productionAppId +flavors: + development: $appId'''); + setUpTempArtifacts(tempDir); + final exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + verify(() => logger.success('\nāœ… Published Patch!')).called(1); + expect(exitCode, ExitCode.success.code); + expect(capturedHostedUri, isNull); + }); + + test('succeeds when patch is successful using custom base_url', () async { + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + const baseUrl = 'https://example.com'; + File( + p.join(tempDir.path, 'shorebird.yaml'), + ).writeAsStringSync( + ''' +app_id: $appId +base_url: $baseUrl''', + ); + await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + expect(capturedHostedUri, equals(Uri.parse(baseUrl))); + }); + + test('prints flutter validation warnings', () async { + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + 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 exitCode = await IOOverrides.runZoned( + command.run, + getCurrentDirectory: () => tempDir, + ); + + expect(exitCode, equals(ExitCode.success.code)); + 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 { + final tempDir = setUpTempDir(); + setUpTempArtifacts(tempDir); + when(() => flutterValidator.validate(any())).thenAnswer( + (_) async => [ + const ValidationIssue( + severity: ValidationIssueSeverity.error, + message: 'There was an issue', + ), + ], + ); + + 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); + }); + }); +}