refactor(shorebird_cli): add iOS support to new release command (#2008)
This commit is contained in:
@@ -140,7 +140,8 @@ class AarReleaser extends Releaser {
|
||||
}
|
||||
|
||||
@override
|
||||
UpdateReleaseMetadata get releaseMetadata => UpdateReleaseMetadata(
|
||||
Future<UpdateReleaseMetadata> releaseMetadata() async =>
|
||||
UpdateReleaseMetadata(
|
||||
releasePlatform: releaseType.releasePlatform,
|
||||
flutterVersionOverride: argResults['flutter-version'] as String?,
|
||||
generatedApks: false,
|
||||
|
||||
@@ -164,7 +164,8 @@ Please comment and upvote ${link(uri: Uri.parse('https://github.com/shorebirdtec
|
||||
}
|
||||
|
||||
@override
|
||||
UpdateReleaseMetadata get releaseMetadata => UpdateReleaseMetadata(
|
||||
Future<UpdateReleaseMetadata> releaseMetadata() async =>
|
||||
UpdateReleaseMetadata(
|
||||
releasePlatform: releaseType.releasePlatform,
|
||||
flutterVersionOverride: argResults['flutter-version'] as String?,
|
||||
generatedApks: generateApk,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:platform/platform.dart';
|
||||
import 'package:shorebird_cli/src/archive_analysis/plist.dart';
|
||||
import 'package:shorebird_cli/src/artifact_builder.dart';
|
||||
import 'package:shorebird_cli/src/artifact_manager.dart';
|
||||
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
|
||||
import 'package:shorebird_cli/src/doctor.dart';
|
||||
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
|
||||
import 'package:shorebird_cli/src/logger.dart';
|
||||
import 'package:shorebird_cli/src/platform.dart';
|
||||
import 'package:shorebird_cli/src/platform/ios.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_flutter.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_validator.dart';
|
||||
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
|
||||
import 'package:shorebird_cli/src/version.dart';
|
||||
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
|
||||
|
||||
/// {@template ios_releaser}
|
||||
/// Functions to build and publish an iOS release.
|
||||
/// {@endtemplate}
|
||||
class IosReleaser extends Releaser {
|
||||
/// {@macro ios_releaser}
|
||||
IosReleaser({
|
||||
required super.argResults,
|
||||
required super.flavor,
|
||||
required super.target,
|
||||
});
|
||||
|
||||
/// Whether to codesign the release.
|
||||
bool get codesign => argResults['codesign'] == true;
|
||||
|
||||
@override
|
||||
ReleaseType get releaseType => ReleaseType.ios;
|
||||
|
||||
@override
|
||||
Future<void> assertArgsAreValid() async {
|
||||
if (argResults.rest.contains('--obfuscate')) {
|
||||
// Obfuscated releases break patching, so we don't support them.
|
||||
// See https://github.com/shorebirdtech/shorebird/issues/1619
|
||||
logger
|
||||
..err('Shorebird does not currently support obfuscation on iOS.')
|
||||
..info(
|
||||
'''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''',
|
||||
);
|
||||
exit(ExitCode.unavailable.code);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> assertPreconditions() async {
|
||||
try {
|
||||
await shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: true,
|
||||
checkShorebirdInitialized: true,
|
||||
validators: doctor.iosCommandValidators,
|
||||
supportedOperatingSystems: {Platform.macOS},
|
||||
);
|
||||
} on PreconditionFailedException catch (e) {
|
||||
exit(e.exitCode.code);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FileSystemEntity> buildReleaseArtifacts() async {
|
||||
if (!codesign) {
|
||||
logger
|
||||
..info(
|
||||
'''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''',
|
||||
)
|
||||
..warn(
|
||||
'''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''',
|
||||
);
|
||||
}
|
||||
|
||||
final File exportOptionsPlist;
|
||||
try {
|
||||
exportOptionsPlist = ios.exportOptionsPlistFromArgs(argResults);
|
||||
} catch (error) {
|
||||
logger.err('$error');
|
||||
exit(ExitCode.usage.code);
|
||||
}
|
||||
|
||||
final flutterVersionString = await shorebirdFlutter.getVersionAndRevision();
|
||||
final buildProgress =
|
||||
logger.progress('Building ipa with Flutter $flutterVersionString');
|
||||
try {
|
||||
await artifactBuilder.buildIpa(
|
||||
codesign: codesign,
|
||||
exportOptionsPlist: exportOptionsPlist,
|
||||
flavor: flavor,
|
||||
target: target,
|
||||
);
|
||||
buildProgress.complete();
|
||||
} on ArtifactBuildException catch (error) {
|
||||
buildProgress.fail(error.message);
|
||||
exit(ExitCode.software.code);
|
||||
}
|
||||
|
||||
final xcarchiveDirectory = artifactManager.getXcarchiveDirectory();
|
||||
if (xcarchiveDirectory == null) {
|
||||
logger.err('Unable to find .xcarchive directory');
|
||||
exit(ExitCode.software.code);
|
||||
}
|
||||
|
||||
final appDirectory = artifactManager.getIosAppDirectory(
|
||||
xcarchiveDirectory: xcarchiveDirectory,
|
||||
);
|
||||
|
||||
if (appDirectory == null) {
|
||||
logger.err('Unable to find .app directory');
|
||||
exit(ExitCode.software.code);
|
||||
}
|
||||
|
||||
return xcarchiveDirectory;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getReleaseVersion({
|
||||
required FileSystemEntity releaseArtifactRoot,
|
||||
}) async {
|
||||
final plistFile = File(p.join(releaseArtifactRoot.path, 'Info.plist'));
|
||||
if (!plistFile.existsSync()) {
|
||||
logger.err('No Info.plist file found at ${plistFile.path}');
|
||||
exit(ExitCode.software.code);
|
||||
}
|
||||
|
||||
try {
|
||||
return Plist(file: plistFile).versionNumber;
|
||||
} catch (error) {
|
||||
logger.err(
|
||||
'''Failed to determine release version from ${plistFile.path}: $error''',
|
||||
);
|
||||
exit(ExitCode.software.code);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> uploadReleaseArtifacts({
|
||||
required Release release,
|
||||
required String appId,
|
||||
}) async {
|
||||
final xcarchiveDirectory = artifactManager.getXcarchiveDirectory()!;
|
||||
await codePushClientWrapper.createIosReleaseArtifacts(
|
||||
appId: appId,
|
||||
releaseId: release.id,
|
||||
xcarchivePath: xcarchiveDirectory.path,
|
||||
runnerPath: artifactManager
|
||||
.getIosAppDirectory(xcarchiveDirectory: xcarchiveDirectory)!
|
||||
.path,
|
||||
isCodesigned: codesign,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String get postReleaseInstructions {
|
||||
final relativeArchivePath = p.relative(
|
||||
artifactManager.getXcarchiveDirectory()!.path,
|
||||
);
|
||||
if (codesign) {
|
||||
final ipa = artifactManager.getIpa();
|
||||
if (ipa == null) {
|
||||
logger.err('Could not find ipa file');
|
||||
exit(ExitCode.software.code);
|
||||
}
|
||||
|
||||
final relativeIpaPath = p.relative(ipa.path);
|
||||
return '''
|
||||
|
||||
Your next step is to upload your app to App Store Connect.
|
||||
|
||||
To upload to the App Store, do one of the following:
|
||||
1. Open ${lightCyan.wrap(relativeArchivePath)} in Xcode and use the "Distribute App" flow.
|
||||
2. Drag and drop the ${lightCyan.wrap(relativeIpaPath)} bundle into the Apple Transporter macOS app (https://apps.apple.com/us/app/transporter/id1450874784).
|
||||
3. Run ${lightCyan.wrap('xcrun altool --upload-app --type ios -f $relativeIpaPath --apiKey your_api_key --apiIssuer your_issuer_id')}.
|
||||
See "man altool" for details about how to authenticate with the App Store Connect API key.
|
||||
''';
|
||||
} else {
|
||||
return '''
|
||||
|
||||
Your next step is to submit the archive at ${lightCyan.wrap(relativeArchivePath)} to the App Store using Xcode.
|
||||
|
||||
You can open the archive in Xcode by running:
|
||||
${lightCyan.wrap('open $relativeArchivePath')}
|
||||
|
||||
${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.')}
|
||||
''';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UpdateReleaseMetadata> releaseMetadata() async =>
|
||||
UpdateReleaseMetadata(
|
||||
releasePlatform: releaseType.releasePlatform,
|
||||
flutterVersionOverride: argResults['flutter-version'] as String?,
|
||||
generatedApks: false,
|
||||
environment: BuildEnvironmentMetadata(
|
||||
operatingSystem: platform.operatingSystem,
|
||||
operatingSystemVersion: platform.operatingSystemVersion,
|
||||
shorebirdVersion: packageVersion,
|
||||
xcodeVersion: await xcodeBuild.version(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export 'aar_releaser.dart';
|
||||
export 'android_releaser.dart';
|
||||
export 'ios_releaser.dart';
|
||||
export 'release_new_command.dart';
|
||||
export 'releaser.dart';
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
|
||||
import 'package:shorebird_cli/src/command.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/aar_releaser.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/android_releaser.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/ios_releaser.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
|
||||
import 'package:shorebird_cli/src/config/config.dart';
|
||||
@@ -48,6 +49,16 @@ On Android it is used as "versionCode".
|
||||
On Xcode builds it is used as "CFBundleVersion".''',
|
||||
defaultsTo: '1.0',
|
||||
)
|
||||
..addFlag(
|
||||
'codesign',
|
||||
help: 'Codesign the application bundle.',
|
||||
defaultsTo: true,
|
||||
)
|
||||
..addOption(
|
||||
exportOptionsPlistArgName,
|
||||
help:
|
||||
'''Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.''',
|
||||
)
|
||||
..addOption(
|
||||
'flutter-version',
|
||||
help: 'The Flutter version to use when building the app (e.g: 3.16.3).',
|
||||
@@ -125,7 +136,7 @@ of the iOS app that is using this module.''',
|
||||
target: target,
|
||||
);
|
||||
case ReleaseType.ios:
|
||||
throw UnimplementedError();
|
||||
return IosReleaser(argResults: results, flavor: flavor, target: target);
|
||||
case ReleaseType.iosFramework:
|
||||
throw UnimplementedError();
|
||||
case ReleaseType.aar:
|
||||
@@ -201,12 +212,14 @@ of the iOS app that is using this module.''',
|
||||
version: releaseVersion,
|
||||
releasePlatform: releaser.releaseType.releasePlatform,
|
||||
);
|
||||
await prepareRelease(release: release, pipeline: releaser);
|
||||
await prepareRelease(release: release, releaser: releaser);
|
||||
await releaser.uploadReleaseArtifacts(release: release, appId: appId);
|
||||
await finalizeRelease(release: release, pipeline: releaser);
|
||||
await finalizeRelease(release: release, releaser: releaser);
|
||||
|
||||
logger
|
||||
..success('✅ Published Release ${release.version}!')
|
||||
..success('''
|
||||
|
||||
✅ Published Release ${release.version}!''')
|
||||
..info(releaser.postReleaseInstructions);
|
||||
|
||||
printPatchInstructions(
|
||||
@@ -369,12 +382,12 @@ ${summary.join('\n')}
|
||||
/// Prepares the release by updating the release status to draft.
|
||||
Future<void> prepareRelease({
|
||||
required Release release,
|
||||
required Releaser pipeline,
|
||||
required Releaser releaser,
|
||||
}) async {
|
||||
await codePushClientWrapper.updateReleaseStatus(
|
||||
appId: appId,
|
||||
releaseId: release.id,
|
||||
platform: pipeline.releaseType.releasePlatform,
|
||||
platform: releaser.releaseType.releasePlatform,
|
||||
status: ReleaseStatus.draft,
|
||||
);
|
||||
}
|
||||
@@ -382,18 +395,18 @@ ${summary.join('\n')}
|
||||
/// Finalizes the release by updating the status to active.
|
||||
Future<void> finalizeRelease({
|
||||
required Release release,
|
||||
required Releaser pipeline,
|
||||
required Releaser releaser,
|
||||
}) async {
|
||||
await codePushClientWrapper.updateReleaseStatus(
|
||||
appId: appId,
|
||||
releaseId: release.id,
|
||||
platform: pipeline.releaseType.releasePlatform,
|
||||
platform: releaser.releaseType.releasePlatform,
|
||||
status: ReleaseStatus.active,
|
||||
metadata: pipeline.releaseMetadata,
|
||||
metadata: await releaser.releaseMetadata(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Instructions explaining how to patch the release that was just created.
|
||||
/// Instructions explaining how to patch the release that was just creatd.
|
||||
void printPatchInstructions({
|
||||
required Releaser releaser,
|
||||
required String releaseVersion,
|
||||
|
||||
@@ -56,7 +56,7 @@ abstract class Releaser {
|
||||
|
||||
/// Metadata to attach to the release when creating it, used for debugging
|
||||
/// and support.
|
||||
UpdateReleaseMetadata get releaseMetadata;
|
||||
Future<UpdateReleaseMetadata> releaseMetadata();
|
||||
|
||||
/// Instructions explaining next steps after running `shorebird release`. This
|
||||
/// could include how to upload the generated artifact to a store and how to
|
||||
|
||||
@@ -46,7 +46,11 @@ class InvalidExportOptionsPlistException implements Exception {
|
||||
/// {@macro invalid_export_options_plist_exception}
|
||||
InvalidExportOptionsPlistException(this.message);
|
||||
|
||||
/// An explanation of this exception.
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// The minimum allowed Flutter version for creating iOS releases.
|
||||
|
||||
@@ -408,18 +408,20 @@ void main() {
|
||||
.thenReturn(operatingSystemVersion);
|
||||
});
|
||||
|
||||
test('returns expected metadata', () {
|
||||
test('returns expected metadata', () async {
|
||||
expect(
|
||||
runWithOverrides(() => aarReleaser.releaseMetadata),
|
||||
const UpdateReleaseMetadata(
|
||||
releasePlatform: ReleasePlatform.android,
|
||||
flutterVersionOverride: null,
|
||||
generatedApks: false,
|
||||
environment: BuildEnvironmentMetadata(
|
||||
operatingSystem: operatingSystem,
|
||||
operatingSystemVersion: operatingSystemVersion,
|
||||
shorebirdVersion: packageVersion,
|
||||
xcodeVersion: null,
|
||||
await runWithOverrides(aarReleaser.releaseMetadata),
|
||||
equals(
|
||||
const UpdateReleaseMetadata(
|
||||
releasePlatform: ReleasePlatform.android,
|
||||
flutterVersionOverride: null,
|
||||
generatedApks: false,
|
||||
environment: BuildEnvironmentMetadata(
|
||||
operatingSystem: operatingSystem,
|
||||
operatingSystemVersion: operatingSystemVersion,
|
||||
shorebirdVersion: packageVersion,
|
||||
xcodeVersion: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -471,9 +471,9 @@ void main() {
|
||||
when(() => argResults['android-artifact']).thenReturn('apk');
|
||||
});
|
||||
|
||||
test('returns expected metadata', () {
|
||||
test('returns expected metadata', () async {
|
||||
expect(
|
||||
runWithOverrides(() => androidReleaser.releaseMetadata),
|
||||
await runWithOverrides(() => androidReleaser.releaseMetadata()),
|
||||
const UpdateReleaseMetadata(
|
||||
releasePlatform: ReleasePlatform.android,
|
||||
flutterVersionOverride: null,
|
||||
@@ -494,9 +494,9 @@ void main() {
|
||||
when(() => argResults['android-artifact']).thenReturn('aab');
|
||||
});
|
||||
|
||||
test('returns expected metadata', () {
|
||||
test('returns expected metadata', () async {
|
||||
expect(
|
||||
runWithOverrides(() => androidReleaser.releaseMetadata),
|
||||
await runWithOverrides(() => androidReleaser.releaseMetadata()),
|
||||
const UpdateReleaseMetadata(
|
||||
releasePlatform: ReleasePlatform.android,
|
||||
flutterVersionOverride: null,
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
import 'package:args/args.dart';
|
||||
import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:platform/platform.dart';
|
||||
import 'package:scoped/scoped.dart';
|
||||
import 'package:shorebird_cli/src/artifact_builder.dart';
|
||||
import 'package:shorebird_cli/src/artifact_manager.dart';
|
||||
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/ios_releaser.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
|
||||
import 'package:shorebird_cli/src/doctor.dart';
|
||||
import 'package:shorebird_cli/src/executables/xcodebuild.dart';
|
||||
import 'package:shorebird_cli/src/logger.dart';
|
||||
import 'package:shorebird_cli/src/os/operating_system_interface.dart';
|
||||
import 'package:shorebird_cli/src/platform.dart';
|
||||
import 'package:shorebird_cli/src/platform/ios.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_env.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_flutter.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_process.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_validator.dart';
|
||||
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
|
||||
import 'package:shorebird_cli/src/validators/validators.dart';
|
||||
import 'package:shorebird_cli/src/version.dart';
|
||||
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../../matchers.dart';
|
||||
import '../../mocks.dart';
|
||||
|
||||
void main() {
|
||||
group(
|
||||
IosReleaser,
|
||||
() {
|
||||
late ArgResults argResults;
|
||||
late ArtifactBuilder artifactBuilder;
|
||||
late ArtifactManager artifactManager;
|
||||
late CodePushClientWrapper codePushClientWrapper;
|
||||
late Directory projectRoot;
|
||||
late Doctor doctor;
|
||||
late Platform platform;
|
||||
late Progress progress;
|
||||
late Logger logger;
|
||||
late Ios ios;
|
||||
late OperatingSystemInterface operatingSystemInterface;
|
||||
late ShorebirdFlutterValidator flutterValidator;
|
||||
late ShorebirdProcess shorebirdProcess;
|
||||
late ShorebirdEnv shorebirdEnv;
|
||||
late ShorebirdFlutter shorebirdFlutter;
|
||||
late ShorebirdValidator shorebirdValidator;
|
||||
late XcodeBuild xcodeBuild;
|
||||
late IosReleaser iosReleaser;
|
||||
|
||||
R runWithOverrides<R>(R Function() body) {
|
||||
return runScoped(
|
||||
body,
|
||||
values: {
|
||||
artifactBuilderRef.overrideWith(() => artifactBuilder),
|
||||
artifactManagerRef.overrideWith(() => artifactManager),
|
||||
codePushClientWrapperRef.overrideWith(() => codePushClientWrapper),
|
||||
doctorRef.overrideWith(() => doctor),
|
||||
iosRef.overrideWith(() => ios),
|
||||
loggerRef.overrideWith(() => logger),
|
||||
osInterfaceRef.overrideWith(() => operatingSystemInterface),
|
||||
platformRef.overrideWith(() => platform),
|
||||
processRef.overrideWith(() => shorebirdProcess),
|
||||
shorebirdEnvRef.overrideWith(() => shorebirdEnv),
|
||||
shorebirdFlutterRef.overrideWith(() => shorebirdFlutter),
|
||||
shorebirdValidatorRef.overrideWith(() => shorebirdValidator),
|
||||
xcodeBuildRef.overrideWith(() => xcodeBuild),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setUpAll(() {
|
||||
registerFallbackValue(Directory(''));
|
||||
registerFallbackValue(ReleasePlatform.android);
|
||||
setExitFunctionForTests();
|
||||
});
|
||||
|
||||
tearDownAll(restoreExitFunction);
|
||||
|
||||
setUp(() {
|
||||
argResults = MockArgResults();
|
||||
artifactBuilder = MockArtifactBuilder();
|
||||
artifactManager = MockArtifactManager();
|
||||
codePushClientWrapper = MockCodePushClientWrapper();
|
||||
doctor = MockDoctor();
|
||||
platform = MockPlatform();
|
||||
projectRoot = Directory.systemTemp.createTempSync();
|
||||
operatingSystemInterface = MockOperatingSystemInterface();
|
||||
progress = MockProgress();
|
||||
logger = MockLogger();
|
||||
ios = MockIos();
|
||||
flutterValidator = MockShorebirdFlutterValidator();
|
||||
shorebirdProcess = MockShorebirdProcess();
|
||||
shorebirdEnv = MockShorebirdEnv();
|
||||
shorebirdFlutter = MockShorebirdFlutter();
|
||||
shorebirdValidator = MockShorebirdValidator();
|
||||
xcodeBuild = MockXcodeBuild();
|
||||
|
||||
when(() => argResults.rest).thenReturn([]);
|
||||
|
||||
when(() => logger.progress(any())).thenReturn(progress);
|
||||
|
||||
iosReleaser = IosReleaser(
|
||||
argResults: argResults,
|
||||
flavor: null,
|
||||
target: null,
|
||||
);
|
||||
});
|
||||
|
||||
group('releaseType', () {
|
||||
test('is ios', () {
|
||||
expect(iosReleaser.releaseType, ReleaseType.ios);
|
||||
});
|
||||
});
|
||||
|
||||
group('assertPreconditions', () {
|
||||
setUp(() {
|
||||
when(() => doctor.iosCommandValidators)
|
||||
.thenReturn([flutterValidator]);
|
||||
when(flutterValidator.validate).thenAnswer((_) async => []);
|
||||
});
|
||||
|
||||
group('when validation succeeds', () {
|
||||
setUp(() {
|
||||
when(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated:
|
||||
any(named: 'checkUserIsAuthenticated'),
|
||||
checkShorebirdInitialized:
|
||||
any(named: 'checkShorebirdInitialized'),
|
||||
validators: any(named: 'validators'),
|
||||
supportedOperatingSystems:
|
||||
any(named: 'supportedOperatingSystems'),
|
||||
),
|
||||
).thenAnswer((_) async {});
|
||||
});
|
||||
|
||||
test('returns normally', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(iosReleaser.assertPreconditions),
|
||||
returnsNormally,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('when validation fails', () {
|
||||
final exception = ValidationFailedException();
|
||||
|
||||
setUp(() {
|
||||
when(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated:
|
||||
any(named: 'checkUserIsAuthenticated'),
|
||||
checkShorebirdInitialized:
|
||||
any(named: 'checkShorebirdInitialized'),
|
||||
validators: any(named: 'validators'),
|
||||
supportedOperatingSystems:
|
||||
any(named: 'supportedOperatingSystems'),
|
||||
),
|
||||
).thenThrow(exception);
|
||||
});
|
||||
|
||||
test('exits with code 70', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(iosReleaser.assertPreconditions),
|
||||
exitsWithCode(exception.exitCode),
|
||||
);
|
||||
verify(
|
||||
() => shorebirdValidator.validatePreconditions(
|
||||
checkUserIsAuthenticated: true,
|
||||
checkShorebirdInitialized: true,
|
||||
validators: [flutterValidator],
|
||||
supportedOperatingSystems: {Platform.macOS},
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('assertArgsAreValid', () {
|
||||
group('when --obfuscate is passed', () {
|
||||
setUp(() {
|
||||
when(() => argResults.rest).thenReturn(['--obfuscate']);
|
||||
});
|
||||
|
||||
test('logs error and exits', () async {
|
||||
await expectLater(
|
||||
runWithOverrides(iosReleaser.assertArgsAreValid),
|
||||
exitsWithCode(ExitCode.unavailable),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => logger.err(
|
||||
'Shorebird does not currently support obfuscation on iOS.',
|
||||
),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.info(
|
||||
'''We hope to support obfuscation in the future. We are tracking this work at ${link(uri: Uri.parse('https://github.com/shorebirdtech/shorebird/issues/1619'))}.''',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when --obfuscate is not passed', () {
|
||||
test('returns normally', () async {
|
||||
await expectLater(
|
||||
runWithOverrides(iosReleaser.assertArgsAreValid),
|
||||
completes,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('buildReleaseArtifacts', () {
|
||||
const flutterVersionAndRevision = '3.10.6 (83305b5088)';
|
||||
|
||||
late Directory xcarchiveDirectory;
|
||||
late Directory iosAppDirectory;
|
||||
|
||||
setUp(() {
|
||||
xcarchiveDirectory = Directory.systemTemp.createTempSync();
|
||||
iosAppDirectory = Directory.systemTemp.createTempSync();
|
||||
when(() => argResults['codesign']).thenReturn(true);
|
||||
when(
|
||||
() => artifactBuilder.buildIpa(
|
||||
codesign: any(named: 'codesign'),
|
||||
exportOptionsPlist: any(named: 'exportOptionsPlist'),
|
||||
flavor: any(named: 'flavor'),
|
||||
target: any(named: 'target'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
|
||||
when(
|
||||
() => artifactManager.getIosAppDirectory(
|
||||
xcarchiveDirectory: any(named: 'xcarchiveDirectory'),
|
||||
),
|
||||
).thenReturn(iosAppDirectory);
|
||||
when(() => artifactManager.getXcarchiveDirectory())
|
||||
.thenReturn(xcarchiveDirectory);
|
||||
when(
|
||||
() => ios.exportOptionsPlistFromArgs(argResults),
|
||||
).thenReturn(File(''));
|
||||
when(() => shorebirdEnv.getShorebirdProjectRoot())
|
||||
.thenReturn(projectRoot);
|
||||
when(
|
||||
() => shorebirdFlutter.getVersionAndRevision(),
|
||||
).thenAnswer((_) async => flutterVersionAndRevision);
|
||||
});
|
||||
|
||||
group('when not codesigning', () {
|
||||
setUp(() {
|
||||
when(() => argResults['codesign']).thenReturn(false);
|
||||
});
|
||||
|
||||
test('logs warning about patching', () async {
|
||||
await runWithOverrides(iosReleaser.buildReleaseArtifacts);
|
||||
|
||||
verify(
|
||||
() => logger.info(
|
||||
'''Building for device with codesigning disabled. You will have to manually codesign before deploying to device.''',
|
||||
),
|
||||
).called(1);
|
||||
verify(
|
||||
() => logger.warn(
|
||||
'''shorebird preview will not work for releases created with "--no-codesign". However, you can still preview your app by signing the generated .xcarchive in Xcode.''',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when export options plist fails to generate', () {
|
||||
const error = 'error';
|
||||
setUp(() {
|
||||
when(
|
||||
() => ios.exportOptionsPlistFromArgs(argResults),
|
||||
).thenThrow(error);
|
||||
});
|
||||
|
||||
test('logs error and exits with code 64', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(iosReleaser.buildReleaseArtifacts),
|
||||
exitsWithCode(ExitCode.usage),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => logger.err(error),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when build fails', () {
|
||||
setUp(() {
|
||||
when(
|
||||
() => artifactBuilder.buildIpa(
|
||||
codesign: any(named: 'codesign'),
|
||||
exportOptionsPlist: any(named: 'exportOptionsPlist'),
|
||||
flavor: any(named: 'flavor'),
|
||||
target: any(named: 'target'),
|
||||
),
|
||||
).thenThrow(ArtifactBuildException('Failed to build'));
|
||||
});
|
||||
|
||||
test('logs error and exits with code 70', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(iosReleaser.buildReleaseArtifacts),
|
||||
exitsWithCode(ExitCode.software),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => progress.fail('Failed to build'),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when build succeeds', () {
|
||||
test('verifies artifacts exist and returns xcarchive path', () async {
|
||||
expect(
|
||||
await runWithOverrides(iosReleaser.buildReleaseArtifacts),
|
||||
equals(xcarchiveDirectory),
|
||||
);
|
||||
|
||||
verify(() => artifactManager.getXcarchiveDirectory()).called(1);
|
||||
verify(
|
||||
() => artifactManager.getIosAppDirectory(
|
||||
xcarchiveDirectory: xcarchiveDirectory,
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when xcarchive not found after build', () {
|
||||
setUp(() {
|
||||
when(() => artifactManager.getXcarchiveDirectory())
|
||||
.thenReturn(null);
|
||||
});
|
||||
|
||||
test('logs message and exits with code 70', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(iosReleaser.buildReleaseArtifacts),
|
||||
exitsWithCode(ExitCode.software),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => logger.err('Unable to find .xcarchive directory'),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when app not found after build', () {
|
||||
setUp(() {
|
||||
when(
|
||||
() => artifactManager.getIosAppDirectory(
|
||||
xcarchiveDirectory: any(named: 'xcarchiveDirectory'),
|
||||
),
|
||||
).thenReturn(null);
|
||||
});
|
||||
|
||||
test('logs message and exits with code 70', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(iosReleaser.buildReleaseArtifacts),
|
||||
exitsWithCode(ExitCode.software),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => logger.err('Unable to find .app directory'),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('getReleaseVersion', () {
|
||||
late Directory xcarchiveDirectory;
|
||||
|
||||
setUp(() {
|
||||
xcarchiveDirectory = Directory.systemTemp.createTempSync();
|
||||
});
|
||||
|
||||
group('when plist does not exist', () {
|
||||
test('logs error and exits', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(
|
||||
() => iosReleaser.getReleaseVersion(
|
||||
releaseArtifactRoot: xcarchiveDirectory,
|
||||
),
|
||||
),
|
||||
exitsWithCode(ExitCode.software),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => logger.err(
|
||||
'''No Info.plist file found at ${p.join(xcarchiveDirectory.path, 'Info.plist')}''',
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when plist does not contain version number', () {
|
||||
late File plist;
|
||||
setUp(() {
|
||||
plist = File(p.join(xcarchiveDirectory.path, 'Info.plist'))
|
||||
..createSync()
|
||||
..writeAsStringSync(
|
||||
'''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ApplicationProperties</key>
|
||||
<dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>'
|
||||
''',
|
||||
);
|
||||
});
|
||||
|
||||
test('logs error and exits', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(
|
||||
() => iosReleaser.getReleaseVersion(
|
||||
releaseArtifactRoot: xcarchiveDirectory,
|
||||
),
|
||||
),
|
||||
exitsWithCode(ExitCode.software),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => logger.err(
|
||||
any(
|
||||
that: startsWith(
|
||||
'Failed to determine release version from ${plist.path}',
|
||||
),
|
||||
),
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when plist contains version number', () {
|
||||
setUp(() {
|
||||
File(p.join(xcarchiveDirectory.path, 'Info.plist'))
|
||||
..createSync()
|
||||
..writeAsStringSync(
|
||||
'''
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ApplicationProperties</key>
|
||||
<dict>
|
||||
<key>ApplicationPath</key>
|
||||
<string>Applications/Runner.app</string>
|
||||
<key>Architectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.shorebird.timeShift</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.3</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
<key>ArchiveVersion</key>
|
||||
<integer>2</integer>
|
||||
<key>Name</key>
|
||||
<string>Runner</string>
|
||||
<key>SchemeName</key>
|
||||
<string>Runner</string>
|
||||
</dict>
|
||||
</plist>''',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns version number from plist', () async {
|
||||
expect(
|
||||
await runWithOverrides(
|
||||
() => iosReleaser.getReleaseVersion(
|
||||
releaseArtifactRoot: xcarchiveDirectory,
|
||||
),
|
||||
),
|
||||
equals('1.2.3+1'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('uploadReleaseArtifacts', () {
|
||||
const appId = 'appId';
|
||||
const releaseVersion = '1.0.0';
|
||||
const flutterRevision = 'deadbeef';
|
||||
const codesign = true;
|
||||
|
||||
final release = Release(
|
||||
id: 42,
|
||||
appId: appId,
|
||||
version: releaseVersion,
|
||||
flutterRevision: flutterRevision,
|
||||
displayName: '1.2.3+1',
|
||||
platformStatuses: {},
|
||||
createdAt: DateTime(2023),
|
||||
updatedAt: DateTime(2023),
|
||||
);
|
||||
|
||||
late Directory xcarchiveDirectory;
|
||||
late Directory iosAppDirectory;
|
||||
|
||||
setUp(() {
|
||||
when(() => argResults['codesign']).thenReturn(codesign);
|
||||
|
||||
xcarchiveDirectory = Directory.systemTemp.createTempSync();
|
||||
iosAppDirectory = Directory.systemTemp.createTempSync();
|
||||
when(artifactManager.getXcarchiveDirectory)
|
||||
.thenReturn(xcarchiveDirectory);
|
||||
when(
|
||||
() => artifactManager.getIosAppDirectory(
|
||||
xcarchiveDirectory: any(named: 'xcarchiveDirectory'),
|
||||
),
|
||||
).thenReturn(iosAppDirectory);
|
||||
when(
|
||||
() => codePushClientWrapper.createIosReleaseArtifacts(
|
||||
appId: any(named: 'appId'),
|
||||
releaseId: any(named: 'releaseId'),
|
||||
xcarchivePath: any(named: 'xcarchivePath'),
|
||||
runnerPath: any(named: 'runnerPath'),
|
||||
isCodesigned: any(named: 'isCodesigned'),
|
||||
),
|
||||
).thenAnswer((_) async => {});
|
||||
});
|
||||
|
||||
test('forwards call to codePushClientWrapper', () async {
|
||||
await runWithOverrides(
|
||||
() => iosReleaser.uploadReleaseArtifacts(
|
||||
release: release,
|
||||
appId: appId,
|
||||
),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => codePushClientWrapper.createIosReleaseArtifacts(
|
||||
appId: appId,
|
||||
releaseId: release.id,
|
||||
xcarchivePath: xcarchiveDirectory.path,
|
||||
runnerPath: iosAppDirectory.path,
|
||||
isCodesigned: codesign,
|
||||
),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('releaseMetadata', () {
|
||||
const operatingSystem = 'macOS';
|
||||
const operatingSystemVersion = '11.0.0';
|
||||
const xcodeVersion = '123';
|
||||
const flutterVersionOverride = '1.2.3';
|
||||
|
||||
setUp(() {
|
||||
when(() => platform.operatingSystem).thenReturn(operatingSystem);
|
||||
when(() => platform.operatingSystemVersion)
|
||||
.thenReturn(operatingSystemVersion);
|
||||
when(() => xcodeBuild.version())
|
||||
.thenAnswer((_) async => xcodeVersion);
|
||||
when(() => argResults['flutter-version'])
|
||||
.thenReturn(flutterVersionOverride);
|
||||
});
|
||||
|
||||
test('returns expected metadata', () async {
|
||||
expect(
|
||||
await runWithOverrides(iosReleaser.releaseMetadata),
|
||||
const UpdateReleaseMetadata(
|
||||
releasePlatform: ReleasePlatform.ios,
|
||||
flutterVersionOverride: flutterVersionOverride,
|
||||
generatedApks: false,
|
||||
environment: BuildEnvironmentMetadata(
|
||||
operatingSystem: operatingSystem,
|
||||
operatingSystemVersion: operatingSystemVersion,
|
||||
shorebirdVersion: packageVersion,
|
||||
xcodeVersion: xcodeVersion,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('postReleaseInstructions', () {
|
||||
late Directory xcarchiveDirectory;
|
||||
|
||||
setUp(() {
|
||||
xcarchiveDirectory = Directory.systemTemp.createTempSync();
|
||||
when(() => artifactManager.getXcarchiveDirectory())
|
||||
.thenReturn(xcarchiveDirectory);
|
||||
});
|
||||
|
||||
group('when codesigning', () {
|
||||
setUp(() {
|
||||
when(() => argResults['codesign']).thenReturn(true);
|
||||
});
|
||||
|
||||
group('when no ipa found', () {
|
||||
test('logs error and exits', () async {
|
||||
await expectLater(
|
||||
() => runWithOverrides(
|
||||
() => iosReleaser.postReleaseInstructions,
|
||||
),
|
||||
exitsWithCode(ExitCode.software),
|
||||
);
|
||||
|
||||
verify(
|
||||
() => logger.err('Could not find ipa file'),
|
||||
).called(1);
|
||||
});
|
||||
});
|
||||
|
||||
group('when ipa found', () {
|
||||
late File ipa;
|
||||
setUp(() {
|
||||
final tempDir = Directory.systemTemp.createTempSync();
|
||||
ipa = File(p.join(tempDir.path, 'ipa.ipa'))..createSync();
|
||||
when(() => artifactManager.getIpa()).thenReturn(ipa);
|
||||
});
|
||||
|
||||
test('prints ipa upload steps', () {
|
||||
expect(
|
||||
runWithOverrides(() => iosReleaser.postReleaseInstructions),
|
||||
equals('''
|
||||
|
||||
Your next step is to upload your app to App Store Connect.
|
||||
|
||||
To upload to the App Store, do one of the following:
|
||||
1. Open ${lightCyan.wrap(p.relative(xcarchiveDirectory.path))} in Xcode and use the "Distribute App" flow.
|
||||
2. Drag and drop the ${lightCyan.wrap(p.relative(ipa.path))} bundle into the Apple Transporter macOS app (https://apps.apple.com/us/app/transporter/id1450874784).
|
||||
3. Run ${lightCyan.wrap('xcrun altool --upload-app --type ios -f ${p.relative(ipa.path)} --apiKey your_api_key --apiIssuer your_issuer_id')}.
|
||||
See "man altool" for details about how to authenticate with the App Store Connect API key.
|
||||
'''),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group('when not codesigning', () {
|
||||
setUp(() {
|
||||
when(() => argResults['codesign']).thenReturn(false);
|
||||
});
|
||||
|
||||
test('prints xcarchive upload steps', () {
|
||||
expect(
|
||||
runWithOverrides(() => iosReleaser.postReleaseInstructions),
|
||||
equals(
|
||||
'''
|
||||
|
||||
Your next step is to submit the archive at ${lightCyan.wrap(p.relative(xcarchiveDirectory.path))} to the App Store using Xcode.
|
||||
|
||||
You can open the archive in Xcode by running:
|
||||
${lightCyan.wrap('open ${p.relative(xcarchiveDirectory.path)}')}
|
||||
|
||||
${styleBold.wrap('Make sure to uncheck "Manage Version and Build Number", or else shorebird will not work.')}
|
||||
''',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
testOn: 'mac-os',
|
||||
);
|
||||
}
|
||||
+13
-10
@@ -3,11 +3,8 @@ import 'package:mason_logger/mason_logger.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:scoped/scoped.dart';
|
||||
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/aar_releaser.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/android_releaser.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/release_new_command.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/release_new.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/release_type.dart';
|
||||
import 'package:shorebird_cli/src/commands/release_new/releaser.dart';
|
||||
import 'package:shorebird_cli/src/config/config.dart';
|
||||
import 'package:shorebird_cli/src/logger.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_env.dart';
|
||||
@@ -139,8 +136,8 @@ void main() {
|
||||
when(() => releaser.postReleaseInstructions)
|
||||
.thenReturn(postReleaseInstructions);
|
||||
when(() => releaser.releaseType).thenReturn(ReleaseType.android);
|
||||
when(() => releaser.releaseMetadata)
|
||||
.thenReturn(UpdateReleaseMetadata.forTest());
|
||||
when(() => releaser.releaseMetadata())
|
||||
.thenAnswer((_) async => UpdateReleaseMetadata.forTest());
|
||||
when(() => releaser.requiresReleaseVersionArg).thenReturn(false);
|
||||
|
||||
when(() => shorebirdEnv.getShorebirdYaml()).thenReturn(shorebirdYaml);
|
||||
@@ -189,8 +186,8 @@ void main() {
|
||||
isA<AarReleaser>(),
|
||||
);
|
||||
expect(
|
||||
() => command.getReleaser(ReleaseType.ios),
|
||||
throwsA(isA<UnimplementedError>()),
|
||||
command.getReleaser(ReleaseType.ios),
|
||||
isA<IosReleaser>(),
|
||||
);
|
||||
expect(
|
||||
() => command.getReleaser(ReleaseType.iosFramework),
|
||||
@@ -215,7 +212,9 @@ void main() {
|
||||
release: release,
|
||||
appId: appId,
|
||||
),
|
||||
() => logger.success('✅ Published Release ${release.version}!'),
|
||||
() => logger.success('''
|
||||
|
||||
✅ Published Release ${release.version}!'''),
|
||||
() => logger.info(postReleaseInstructions),
|
||||
() => logger.info(
|
||||
'''To create a patch for this release, run ${lightCyan.wrap('shorebird patch --platform=android --release-version=${release.version}')}''',
|
||||
@@ -275,7 +274,11 @@ Note: ${lightCyan.wrap('shorebird patch --platform=android')} without the --rele
|
||||
release: release,
|
||||
appId: appId,
|
||||
),
|
||||
() => logger.success('✅ Published Release ${release.version}!'),
|
||||
() => logger.success(
|
||||
'''
|
||||
|
||||
✅ Published Release ${release.version}!''',
|
||||
),
|
||||
() => logger.info(postReleaseInstructions),
|
||||
() => logger.info(
|
||||
'''To create a patch for this release, run ${lightCyan.wrap('shorebird patch --platform=android --flavor=$flavor --target=$target --release-version=${release.version}')}''',
|
||||
|
||||
@@ -46,7 +46,7 @@ class FakeReleaser extends Releaser {
|
||||
String get postReleaseInstructions => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
UpdateReleaseMetadata get releaseMetadata => throw UnimplementedError();
|
||||
Future<UpdateReleaseMetadata> releaseMetadata() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
ReleaseType get releaseType => throw UnimplementedError();
|
||||
|
||||
@@ -11,6 +11,13 @@ import 'package:test/test.dart';
|
||||
import '../mocks.dart';
|
||||
|
||||
void main() {
|
||||
group(InvalidExportOptionsPlistException, () {
|
||||
test('toString', () {
|
||||
final exception = InvalidExportOptionsPlistException('message');
|
||||
expect(exception.toString(), 'message');
|
||||
});
|
||||
});
|
||||
|
||||
group(Ios, () {
|
||||
late Ios ios;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user