feat(shorebird_cli): add shorebird build ipa (#534)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user