feat(shorebird_cli): add shorebird build ipa (#534)

This commit is contained in:
Felix Angelov
2023-05-24 07:51:21 -07:00
committed by GitHub
parent 0a4c94bac3
commit dc4d3d2014
5 changed files with 418 additions and 20 deletions
@@ -1,3 +1,4 @@
export 'build_apk_command.dart';
export 'build_app_bundle_command.dart';
export 'build_command.dart';
export 'build_ipa_command.dart';
@@ -1,32 +1,16 @@
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/build/build.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_config_mixin.dart';
import 'package:shorebird_cli/src/shorebird_validation_mixin.dart';
/// {@template build_command}
///
/// `shorebird build`
/// Build a new release of your application.
/// {@endtemplate}
class BuildCommand extends ShorebirdCommand
with ShorebirdValidationMixin, ShorebirdConfigMixin, ShorebirdBuildMixin {
class BuildCommand extends ShorebirdCommand {
/// {@macro build_command}
BuildCommand({required super.logger}) {
addSubcommand(
BuildApkCommand(
auth: auth,
logger: logger,
validators: validators,
),
);
addSubcommand(
BuildAppBundleCommand(
auth: auth,
logger: logger,
validators: validators,
),
);
addSubcommand(BuildApkCommand(logger: logger));
addSubcommand(BuildAppBundleCommand(logger: logger));
addSubcommand(BuildIpaCommand(logger: logger));
}
@override
@@ -0,0 +1,104 @@
import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/auth_logger_mixin.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_config_mixin.dart';
import 'package:shorebird_cli/src/shorebird_validation_mixin.dart';
/// {@template build_ipa_command}
/// `shorebird build ipa`
/// Builds an .xcarchive and optionally .ipa for an iOS app to be generated for
/// App Store submission.
/// {@endtemplate}
class BuildIpaCommand extends ShorebirdCommand
with
AuthLoggerMixin,
ShorebirdValidationMixin,
ShorebirdConfigMixin,
ShorebirdBuildMixin {
/// {@macro build_ipa_command}
BuildIpaCommand({required super.logger, super.auth, super.validators}) {
argParser
..addOption(
'target',
abbr: 't',
help: 'The main entrypoint file of the application.',
)
..addOption(
'flavor',
help: 'The product flavor to use when building the app.',
)
..addFlag(
'codesign',
help:
'''Codesign the application bundle (only available on device builds).''',
);
}
@override
String get description =>
'''Builds an .xcarchive and optionally .ipa for an iOS app to be generated for App Store submission.''';
@override
String get name => 'ipa';
@override
Future<int> run() async {
if (!auth.isAuthenticated) {
printNeedsAuthInstructions();
return ExitCode.noUser.code;
}
final validationIssues = await runValidators();
if (validationIssuesContainsError(validationIssues)) {
logValidationFailure(issues: validationIssues);
return ExitCode.config.code;
}
final flavor = results['flavor'] as String?;
final target = results['target'] as String?;
final codesign = results['codesign'] as bool;
if (!codesign) {
logger.warn('''
Codesigning is disabled. You must manually codesign before deploying to devices.''');
}
final buildProgress = logger.progress('Building ipa');
try {
await buildIpa(
flavor: flavor,
target: target,
codesign: codesign,
);
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
}
buildProgress.complete();
const xcarchivePath = './build/ios/archive/Runner.xcarchive';
logger.info('''
📦 Generated an xcode archive at:
${lightCyan.wrap(xcarchivePath)}''');
if (!codesign) {
logger.info(
'Codesigning disabled via "--no-codesign". Skipping ipa generation.',
);
return ExitCode.success.code;
}
const ipaPath = './build/ios/ipa/Runner.ipa';
logger.info('''
📦 Generated an ipa at:
${lightCyan.wrap(ipaPath)}''');
return ExitCode.success.code;
}
}
@@ -119,4 +119,36 @@ mixin ShorebirdBuildMixin on ShorebirdCommand {
);
}
}
Future<void> buildIpa({
String? flavor,
String? target,
bool codesign = true,
}) async {
const executable = 'flutter';
final arguments = [
'build',
'ipa',
'--release',
if (flavor != null) '--flavor=$flavor',
if (target != null) '--target=$target',
if (!codesign) '--no-codesign',
...results.rest,
];
final result = await process.run(
executable,
arguments,
runInShell: true,
);
if (result.exitCode != ExitCode.success.code) {
throw ProcessException(
'flutter',
arguments,
result.stderr.toString(),
result.exitCode,
);
}
}
}
@@ -0,0 +1,277 @@
import 'dart:io';
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:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/commands/build/build.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:test/test.dart';
class _MockArgResults extends Mock implements ArgResults {}
class _MockHttpClient extends Mock implements http.Client {}
class _MockAuth extends Mock implements Auth {}
class _MockLogger extends Mock implements Logger {}
class _MockProgress extends Mock implements Progress {}
class _MockProcessResult extends Mock implements ShorebirdProcessResult {}
class _MockShorebirdFlutterValidator extends Mock
implements ShorebirdFlutterValidator {}
class _MockShorebirdProcess extends Mock implements ShorebirdProcess {}
void main() {
group(BuildIpaCommand, () {
late ArgResults argResults;
late http.Client httpClient;
late Auth auth;
late Logger logger;
late ShorebirdProcessResult processResult;
late BuildIpaCommand command;
late ShorebirdFlutterValidator flutterValidator;
late ShorebirdProcess shorebirdProcess;
setUp(() {
argResults = _MockArgResults();
httpClient = _MockHttpClient();
auth = _MockAuth();
logger = _MockLogger();
shorebirdProcess = _MockShorebirdProcess();
processResult = _MockProcessResult();
flutterValidator = _MockShorebirdFlutterValidator();
command = BuildIpaCommand(
auth: auth,
logger: logger,
validators: [flutterValidator],
)
..testArgResults = argResults
..testProcess = shorebirdProcess
..testEngineConfig = const EngineConfig.empty();
registerFallbackValue(shorebirdProcess);
when(
() => shorebirdProcess.run(
any(),
any(),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((_) async => processResult);
when(() => argResults['codesign']).thenReturn(true);
when(() => argResults.rest).thenReturn([]);
when(() => auth.isAuthenticated).thenReturn(true);
when(() => auth.client).thenReturn(httpClient);
when(() => logger.progress(any())).thenReturn(_MockProgress());
when(() => logger.info(any())).thenReturn(null);
when(() => flutterValidator.validate(any())).thenAnswer((_) async => []);
});
test('has correct description', () {
expect(command.description, isNotEmpty);
});
test('exits with no user when not logged in', () async {
when(() => auth.isAuthenticated).thenReturn(false);
final result = await command.run();
expect(result, equals(ExitCode.noUser.code));
verify(
() => logger.err(any(that: contains('You must be logged in to run'))),
).called(1);
});
test('exits with code 70 when building ipa fails', () async {
when(() => processResult.exitCode).thenReturn(1);
when(() => processResult.stderr).thenReturn('oops');
final tempDir = Directory.systemTemp.createTempSync();
final result = await IOOverrides.runZoned(
() async => command.run(),
getCurrentDirectory: () => tempDir,
);
expect(result, equals(ExitCode.software.code));
verify(
() => shorebirdProcess.run(
'flutter',
['build', 'ipa', '--release'],
runInShell: any(named: 'runInShell'),
),
).called(1);
});
test('exits with code 0 when building ipa succeeds', () async {
when(() => processResult.exitCode).thenReturn(ExitCode.success.code);
final tempDir = Directory.systemTemp.createTempSync();
final result = await IOOverrides.runZoned(
() async => command.run(),
getCurrentDirectory: () => tempDir,
);
expect(result, equals(ExitCode.success.code));
verify(
() => shorebirdProcess.run(
'flutter',
['build', 'ipa', '--release'],
runInShell: true,
),
).called(1);
verifyInOrder([
() => logger.info(
'''
📦 Generated an xcode archive at:
${lightCyan.wrap("./build/ios/archive/Runner.xcarchive")}''',
),
() => logger.info(
'''
📦 Generated an ipa at:
${lightCyan.wrap("./build/ios/ipa/Runner.ipa")}''',
),
]);
});
test(
'exits with code 0 when building ipa succeeds '
'with flavor and target', () async {
const flavor = 'development';
const target = './lib/main_development.dart';
when(() => argResults['flavor']).thenReturn(flavor);
when(() => argResults['target']).thenReturn(target);
when(() => processResult.exitCode).thenReturn(ExitCode.success.code);
final tempDir = Directory.systemTemp.createTempSync();
final result = await IOOverrides.runZoned(
() async => command.run(),
getCurrentDirectory: () => tempDir,
);
expect(result, equals(ExitCode.success.code));
verify(
() => shorebirdProcess.run(
'flutter',
[
'build',
'ipa',
'--release',
'--flavor=$flavor',
'--target=$target',
],
runInShell: true,
),
).called(1);
verifyInOrder([
() => logger.info(
'''
📦 Generated an xcode archive at:
${lightCyan.wrap("./build/ios/archive/Runner.xcarchive")}''',
),
() => logger.info(
'''
📦 Generated an ipa at:
${lightCyan.wrap("./build/ios/ipa/Runner.ipa")}''',
),
]);
});
test(
'exits with code 0 when building ipa succeeds '
'with --no-codesign', () async {
when(() => argResults['codesign']).thenReturn(false);
when(() => processResult.exitCode).thenReturn(ExitCode.success.code);
final tempDir = Directory.systemTemp.createTempSync();
final result = await IOOverrides.runZoned(
() async => command.run(),
getCurrentDirectory: () => tempDir,
);
expect(result, equals(ExitCode.success.code));
verify(
() => shorebirdProcess.run(
'flutter',
['build', 'ipa', '--release', '--no-codesign'],
runInShell: true,
),
).called(1);
verify(
() => logger.info(
'''
📦 Generated an xcode archive at:
${lightCyan.wrap("./build/ios/archive/Runner.xcarchive")}''',
),
).called(1);
verifyNever(
() => logger.info(
'''
📦 Generated an ipa at:
${lightCyan.wrap("./build/ios/ipa/Runner.ipa")}''',
),
);
});
test('prints flutter validation warnings', () async {
when(() => flutterValidator.validate(any())).thenAnswer(
(_) async => [
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Flutter issue 1',
),
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Flutter issue 2',
),
],
);
when(() => processResult.exitCode).thenReturn(ExitCode.success.code);
final result = await command.run();
expect(result, 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 {
when(() => flutterValidator.validate(any())).thenAnswer(
(_) async => [
ValidationIssue(
severity: ValidationIssueSeverity.error,
message: 'There was an issue',
fix: () async {},
),
],
);
final result = await command.run();
expect(result, equals(ExitCode.config.code));
verify(() => logger.err('Aborting due to validation errors.')).called(1);
verify(
() => logger.info(
any(
that: stringContainsInOrder([
'issue can be fixed automatically',
'shorebird doctor --fix',
]),
),
),
).called(1);
});
});
}