feat(shorebird_cli): add patch ios-preview command (#595)
This commit is contained in:
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user