feat(shorebird_cli): add patch ios-preview command (#595)

This commit is contained in:
Bryan Oltman
2023-06-07 08:38:55 -04:00
committed by GitHub
parent bbf1e2c7a0
commit 4acf18667e
15 changed files with 1236 additions and 41 deletions
@@ -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<String, Object> _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),
@@ -1,3 +1,4 @@
export 'patch_aar_command.dart';
export 'patch_android_command.dart';
export 'patch_command.dart';
export 'patch_ios_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;
}
}
@@ -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
@@ -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<int> 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;
}
}
@@ -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<File>()
.where((f) => p.basename(f.path) == 'app.dill')
.reduce(
(a, b) =>
a.statSync().modified.isAfter(b.statSync().modified) ? a : b,
);
}
}
@@ -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<String> 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<File> 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);
}
}
@@ -73,7 +73,7 @@ mixin ShorebirdCodePushClientMixin on ShorebirdConfigMixin {
}
}
Future<Channel?> createChannel({
Future<Channel> 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<String> 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<Patch> 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;
}
}
@@ -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',
),
);
}
@@ -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<String> 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<String, Object>;
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<String>().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<String, String> _configVariables({required String path}) {
final properties = <String, String>{};
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;
}
}
+2 -1
View File
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -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);
});
});
@@ -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);
});
});
}