refactor(shorebird_cli): refactor shorebird release in favor of shorebird release android (#541)

This commit is contained in:
Felix Angelov
2023-05-24 14:16:51 -07:00
committed by GitHub
parent 919792d04d
commit 146f84b9bc
5 changed files with 946 additions and 5 deletions
@@ -1 +1,2 @@
export 'release_android_command.dart';
export 'release_command.dart';
@@ -0,0 +1,273 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/auth_logger_mixin.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/config/shorebird_yaml.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_create_app_mixin.dart';
import 'package:shorebird_cli/src/shorebird_java_mixin.dart';
import 'package:shorebird_cli/src/shorebird_release_version_mixin.dart';
import 'package:shorebird_cli/src/shorebird_validation_mixin.dart';
import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// {@template release_android_command}
/// `shorebird release android`
/// Create new app releases for Android.
/// {@endtemplate}
class ReleaseAndroidCommand extends ShorebirdCommand
with
AuthLoggerMixin,
ShorebirdValidationMixin,
ShorebirdConfigMixin,
ShorebirdBuildMixin,
ShorebirdCreateAppMixin,
ShorebirdJavaMixin,
ShorebirdReleaseVersionMixin {
/// {@macro release_android_command}
ReleaseAndroidCommand({
required super.logger,
super.auth,
super.cache,
super.buildCodePushClient,
super.validators,
HashFunction? hashFn,
}) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()) {
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: 'Release without confirmation if there are no errors.',
negatable: false,
);
}
@override
String get description => '''
Builds and submits your Android app to Shorebird.
Shorebird saves the compiled Dart code from your application in order to
make smaller updates to your app.
''';
@override
String get name => 'android';
final HashFunction _hashFn;
@override
Future<int> run() async {
if (!isShorebirdInitialized) {
logger.err(
'Shorebird is not initialized. Did you run "shorebird init"?',
);
return ExitCode.config.code;
}
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 buildProgress = logger.progress('Building release');
try {
await buildAppBundle(flavor: flavor, target: target);
buildProgress.complete();
} on ProcessException catch (error) {
buildProgress.fail('Failed to build: ${error.message}');
return ExitCode.software.code;
}
final shorebirdYaml = getShorebirdYaml()!;
final codePushClient = buildCodePushClient(
httpClient: auth.client,
hostedUri: hostedUri,
);
late final List<App> apps;
final fetchAppsProgress = logger.progress('Fetching apps');
try {
apps = (await codePushClient.getApps())
.map((a) => App(id: a.appId, displayName: a.displayName))
.toList();
fetchAppsProgress.complete();
} catch (error) {
fetchAppsProgress.fail('$error');
return ExitCode.software.code;
}
final appId = shorebirdYaml.getAppId(flavor: flavor);
final app = apps.firstWhereOrNull((a) => a.id == appId);
if (app == null) {
logger.err(
'''
Could not find app with id: "$appId".
Did you forget to run "shorebird init"?''',
);
return ExitCode.software.code;
}
final bundleDirPath = p.join('build', 'app', 'outputs', 'bundle');
final bundlePath = flavor != null
? p.join(bundleDirPath, '${flavor}Release', 'app-$flavor-release.aab')
: p.join(bundleDirPath, 'release', 'app-release.aab');
final String releaseVersion;
final detectReleaseVersionProgress = logger.progress(
'Detecting release version',
);
try {
releaseVersion = await extractReleaseVersionFromAppBundle(bundlePath);
detectReleaseVersionProgress.complete();
} catch (error) {
detectReleaseVersionProgress.fail('$error');
return ExitCode.software.code;
}
final platform = results['platform'] as String;
final archNames = architectures.keys.map(
(arch) => arch.name,
);
final summary = [
'''📱 App: ${lightCyan.wrap(app.displayName)} ${lightCyan.wrap('(${app.id})')}''',
if (flavor != null) '🍧 Flavor: ${lightCyan.wrap(flavor)}',
'📦 Release Version: ${lightCyan.wrap(releaseVersion)}',
'''🕹️ Platform: ${lightCyan.wrap(platform)} ${lightCyan.wrap('(${archNames.join(', ')})')}''',
];
logger.info('''
${styleBold.wrap(lightGreen.wrap('🚀 Ready to create a new release!'))}
${summary.join('\n')}
''');
final force = results['force'] == true;
final needConfirmation = !force;
if (needConfirmation) {
final confirm = logger.confirm('Would you like to continue?');
if (!confirm) {
logger.info('Aborting.');
return ExitCode.success.code;
}
}
late final List<Release> releases;
final fetchReleasesProgress = logger.progress('Fetching releases');
try {
releases = await codePushClient.getReleases(appId: app.id);
fetchReleasesProgress.complete();
} catch (error) {
fetchReleasesProgress.fail('$error');
return ExitCode.software.code;
}
var release = releases.firstWhereOrNull((r) => r.version == releaseVersion);
if (release == null) {
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;
}
final createReleaseProgress = logger.progress('Creating release');
try {
release = await codePushClient.createRelease(
appId: app.id,
version: releaseVersion,
flutterRevision: shorebirdFlutterRevision,
);
createReleaseProgress.complete();
} catch (error) {
createReleaseProgress.fail('$error');
return ExitCode.software.code;
}
}
final createArtifactProgress = logger.progress('Creating artifacts');
for (final archMetadata in architectures.values) {
final artifactPath = p.join(
Directory.current.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
flavor != null ? '${flavor}Release' : 'release',
'out',
'lib',
archMetadata.path,
'libapp.so',
);
final artifact = File(artifactPath);
final hash = _hashFn(await artifact.readAsBytes());
try {
await codePushClient.createReleaseArtifact(
releaseId: release.id,
artifactPath: artifact.path,
arch: archMetadata.arch,
platform: platform,
hash: hash,
);
} catch (error) {
createArtifactProgress.fail('$error');
return ExitCode.software.code;
}
}
try {
await codePushClient.createReleaseArtifact(
releaseId: release.id,
artifactPath: bundlePath,
arch: 'aab',
platform: platform,
hash: _hashFn(await File(bundlePath).readAsBytes()),
);
} catch (error) {
createArtifactProgress.fail('$error');
return ExitCode.software.code;
}
createArtifactProgress.complete();
logger
..success('\n✅ Published Release!')
..info('''
Your next step is to upload the app bundle to the Play Store.
${lightCyan.wrap(bundlePath)}
See the following link for more information:
${link(uri: Uri.parse('https://support.google.com/googleplay/android-developer/answer/9859152?hl=en'))}
''');
return ExitCode.success.code;
}
}
@@ -6,6 +6,7 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/auth_logger_mixin.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/config/shorebird_yaml.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.dart';
import 'package:shorebird_cli/src/shorebird_config_mixin.dart';
@@ -37,6 +38,7 @@ class ReleaseCommand extends ShorebirdCommand
super.validators,
HashFunction? hashFn,
}) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()) {
addSubcommand(ReleaseAndroidCommand(logger: logger));
argParser
..addOption(
'platform',
@@ -63,11 +65,7 @@ class ReleaseCommand extends ShorebirdCommand
}
@override
String get description => '''
Builds and submits your app to Shorebird.
Shorebird saves the compiled Dart code from your application in order to
make smaller updates to your app.
''';
String get description => 'Manage your Shorebird app releases.';
@override
String get name => 'release';
@@ -76,6 +74,12 @@ make smaller updates to your app.
@override
Future<int> run() async {
logger.warn(
'''
"shorebird release" is deprecated.
Please use "shorebird release android" instead.''',
);
if (!isShorebirdInitialized) {
logger.err(
'Shorebird is not initialized. Did you run "shorebird init"?',
@@ -0,0 +1,647 @@
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:platform/platform.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/shorebird_build_mixin.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 _MockArgResults extends Mock implements ArgResults {}
class _MockHttpClient extends Mock implements http.Client {}
class _MockAuth extends Mock implements Auth {}
class _MockCache extends Mock implements Cache {}
class _MockLogger extends Mock implements Logger {}
class _MockPlatform extends Mock implements Platform {}
class _MockProgress extends Mock implements Progress {}
class _MockProcessResult extends Mock implements ShorebirdProcessResult {}
class _MockCodePushClient extends Mock implements CodePushClient {}
class _MockShorebirdFlutterValidator extends Mock
implements ShorebirdFlutterValidator {}
class _MockShorebirdProcess extends Mock implements ShorebirdProcess {}
void main() {
group(ReleaseAndroidCommand, () {
const appId = 'test-app-id';
const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713';
const versionName = '1.2.3';
const versionCode = '1';
const version = '$versionName+$versionCode';
const appDisplayName = 'Test App';
const arch = 'aarch64';
const platform = 'android';
const appMetadata = AppMetadata(appId: appId, displayName: appDisplayName);
const release = Release(
id: 0,
appId: appId,
version: version,
flutterRevision: flutterRevision,
displayName: '1.2.3+1',
);
const releaseArtifact = ReleaseArtifact(
id: 0,
releaseId: 0,
arch: arch,
platform: platform,
hash: '#',
size: 42,
url: 'https://example.com',
);
const pubspecYamlContent = '''
name: example
version: $version
environment:
sdk: ">=2.19.0 <3.0.0"
flutter:
assets:
- shorebird.yaml''';
late ArgResults argResults;
late http.Client httpClient;
late Directory shorebirdRoot;
late Platform environmentPlatform;
late Auth auth;
late Cache cache;
late Progress progress;
late Logger logger;
late ShorebirdProcessResult flutterBuildProcessResult;
late ShorebirdProcessResult flutterRevisionProcessResult;
late ShorebirdProcessResult releaseVersionNameProcessResult;
late ShorebirdProcessResult releaseVersionCodeProcessResult;
late CodePushClient codePushClient;
late ReleaseAndroidCommand command;
late Uri? capturedHostedUri;
late ShorebirdFlutterValidator flutterValidator;
late ShorebirdProcess shorebirdProcess;
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, {String? flavor}) {
for (final archMetadata
in ShorebirdBuildMixin.allAndroidArchitectures.values) {
final artifactPath = p.join(
dir.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
flavor != null ? '${flavor}Release' : 'release',
'out',
'lib',
archMetadata.path,
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
}
final bundleDirPath = p.join('build', 'app', 'outputs', 'bundle');
final bundlePath = flavor != null
? p.join(bundleDirPath, '${flavor}Release', 'app-$flavor-release.aab')
: p.join(bundleDirPath, 'release', 'app-release.aab');
File(bundlePath).createSync(recursive: true);
}
setUp(() {
argResults = _MockArgResults();
httpClient = _MockHttpClient();
environmentPlatform = _MockPlatform();
shorebirdRoot = Directory.systemTemp.createTempSync();
auth = _MockAuth();
cache = _MockCache();
progress = _MockProgress();
logger = _MockLogger();
flutterBuildProcessResult = _MockProcessResult();
flutterRevisionProcessResult = _MockProcessResult();
releaseVersionNameProcessResult = _MockProcessResult();
releaseVersionCodeProcessResult = _MockProcessResult();
codePushClient = _MockCodePushClient();
flutterValidator = _MockShorebirdFlutterValidator();
shorebirdProcess = _MockShorebirdProcess();
command = ReleaseAndroidCommand(
auth: auth,
buildCodePushClient: ({
required http.Client httpClient,
Uri? hostedUri,
}) {
capturedHostedUri = hostedUri;
return codePushClient;
},
cache: cache,
logger: logger,
validators: [flutterValidator],
)
..testArgResults = argResults
..testProcess = shorebirdProcess
..testEngineConfig = const EngineConfig.empty();
registerFallbackValue(shorebirdProcess);
ShorebirdEnvironment.platform = environmentPlatform;
when(() => environmentPlatform.script).thenReturn(
Uri.file(
p.join(
shorebirdRoot.path,
'bin',
'cache',
'shorebird.snapshot',
),
),
);
when(
() => shorebirdProcess.run(
'flutter',
any(),
runInShell: any(named: 'runInShell'),
),
).thenAnswer((_) async => flutterBuildProcessResult);
when(
() => shorebirdProcess.run(
'git',
any(),
runInShell: any(named: 'runInShell'),
workingDirectory: any(named: 'workingDirectory'),
),
).thenAnswer((_) async => flutterRevisionProcessResult);
when(
() => shorebirdProcess.run(
'java',
any(),
runInShell: any(named: 'runInShell'),
environment: any(named: 'environment'),
),
).thenAnswer((invocation) async {
final args = invocation.positionalArguments[1] as List<String>;
return args.last == '/manifest/@android:versionCode'
? releaseVersionCodeProcessResult
: releaseVersionNameProcessResult;
});
when(() => argResults.rest).thenReturn([]);
when(() => argResults['arch']).thenReturn(arch);
when(() => argResults['platform']).thenReturn(platform);
when(() => auth.isAuthenticated).thenReturn(true);
when(() => auth.client).thenReturn(httpClient);
when(() => cache.updateAll()).thenAnswer((_) async => {});
when(
() => cache.getArtifactDirectory(any()),
).thenReturn(Directory.systemTemp.createTempSync());
when(() => logger.progress(any())).thenReturn(progress);
when(() => logger.confirm(any())).thenReturn(true);
when(
() => logger.prompt(any(), defaultValue: any(named: 'defaultValue')),
).thenReturn(version);
when(
() => flutterBuildProcessResult.exitCode,
).thenReturn(ExitCode.success.code);
when(
() => flutterRevisionProcessResult.exitCode,
).thenReturn(ExitCode.success.code);
when(
() => releaseVersionNameProcessResult.exitCode,
).thenReturn(ExitCode.success.code);
when(
() => releaseVersionCodeProcessResult.exitCode,
).thenReturn(ExitCode.success.code);
when(
() => flutterRevisionProcessResult.stdout,
).thenReturn(flutterRevision);
when(
() => releaseVersionNameProcessResult.stdout,
).thenReturn(versionName);
when(
() => releaseVersionCodeProcessResult.stdout,
).thenReturn(versionCode);
when(
() => codePushClient.getApps(),
).thenAnswer((_) async => [appMetadata]);
when(
() => codePushClient.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => [release]);
when(
() => codePushClient.createRelease(
appId: any(named: 'appId'),
version: any(named: 'version'),
flutterRevision: any(named: 'flutterRevision'),
),
).thenAnswer((_) async => release);
when(
() => codePushClient.createReleaseArtifact(
artifactPath: any(named: 'artifactPath'),
releaseId: any(named: 'releaseId'),
arch: any(named: 'arch'),
platform: any(named: 'platform'),
hash: any(named: 'hash'),
),
).thenAnswer((_) async => releaseArtifact);
when(() => flutterValidator.validate(any())).thenAnswer((_) async => []);
});
test('has a description', () {
expect(command.description, isNotEmpty);
});
test('throws config error when shorebird is not initialized', () async {
final tempDir = Directory.systemTemp.createTempSync();
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(
() => logger.err(
'Shorebird is not initialized. Did you run "shorebird init"?',
),
).called(1);
expect(exitCode, ExitCode.config.code);
});
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('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.', () 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);
});
group('getJavaExecutable', () {
test('uses correct executable on windows', () async {
const javaHome = r'C:\Program Files\Java\jdk-11.0.1';
final platform = _MockPlatform();
when(() => platform.isWindows).thenReturn(true);
when(() => platform.environment).thenReturn({'JAVA_HOME': javaHome});
expect(
command.getJavaExecutable(platform),
equals(p.join(javaHome, 'bin', 'java.exe')),
);
});
test('uses correct executable on non-windows', () async {
final platform = _MockPlatform();
when(() => platform.isWindows).thenReturn(false);
expect(command.getJavaExecutable(platform), equals('java'));
});
});
test('errors when detecting release version name fails', () async {
const error = 'oops';
when(() => releaseVersionNameProcessResult.exitCode).thenReturn(1);
when(() => releaseVersionNameProcessResult.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: Failed to extract version name from app bundle: $error',
),
).called(1);
});
test('errors when detecting release version code fails', () async {
const error = 'oops';
when(() => releaseVersionCodeProcessResult.exitCode).thenReturn(1);
when(() => releaseVersionCodeProcessResult.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: Failed to extract version code from app bundle: $error',
),
).called(1);
});
test('aborts when user opts out', () async {
when(() => logger.confirm(any())).thenReturn(false);
when(
() => logger.prompt(
'What is the version of this release?',
defaultValue: any(named: 'defaultValue'),
),
).thenAnswer((_) => '1.0.0');
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('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 unable to detect flutter revision', () async {
const error = 'oops';
when(() => flutterRevisionProcessResult.exitCode).thenReturn(1);
when(() => flutterRevisionProcessResult.stderr).thenReturn(error);
when(
() => codePushClient.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => []);
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('throws error when creating release fails.', () async {
const error = 'something went wrong';
when(
() => codePushClient.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => []);
when(
() => codePushClient.createRelease(
appId: any(named: 'appId'),
version: any(named: 'version'),
flutterRevision: any(named: 'flutterRevision'),
displayName: any(named: 'displayName'),
),
).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 release artifact fails.', () async {
const error = 'something went wrong';
when(
() => codePushClient.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => []);
when(
() => codePushClient.createReleaseArtifact(
artifactPath: any(named: 'artifactPath'),
releaseId: any(named: 'releaseId'),
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 uploading aab fails', () async {
const error = 'something went wrong';
when(
() => codePushClient.getReleases(appId: any(named: 'appId')),
).thenAnswer((_) async => []);
when(
() => codePushClient.createReleaseArtifact(
artifactPath: any(named: 'artifactPath', that: endsWith('.aab')),
releaseId: any(named: 'releaseId'),
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(
'does not prompt for confirmation '
'when --release-version and --force are used', () async {
when(() => argResults['force']).thenReturn(true);
when(() => argResults['release-version']).thenReturn(version);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => logger.success('\n✅ Published Release!')).called(1);
expect(exitCode, ExitCode.success.code);
expect(capturedHostedUri, isNull);
verifyNever(
() => logger.prompt(any(), defaultValue: any(named: 'defaultValue')),
);
});
test('succeeds when release is successful', () async {
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => logger.success('\n✅ Published Release!')).called(1);
verify(
() => codePushClient.createReleaseArtifact(
artifactPath: any(named: 'artifactPath', that: endsWith('.aab')),
releaseId: any(named: 'releaseId'),
arch: 'aab',
platform: 'android',
hash: any(named: 'hash'),
),
).called(1);
expect(exitCode, ExitCode.success.code);
expect(capturedHostedUri, isNull);
});
test(
'succeeds when release is successful '
'with flavors and target', () async {
const flavor = 'development';
final target = p.join('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, flavor: flavor);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => logger.success('\n✅ Published Release!')).called(1);
verify(
() => codePushClient.createReleaseArtifact(
artifactPath: any(named: 'artifactPath', that: endsWith('.aab')),
releaseId: any(named: 'releaseId'),
arch: 'aab',
platform: 'android',
hash: any(named: 'hash'),
),
).called(1);
expect(exitCode, ExitCode.success.code);
expect(capturedHostedUri, isNull);
});
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',
),
],
);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(() => logger.success('\n✅ Published Release!')).called(1);
expect(exitCode, ExitCode.success.code);
expect(capturedHostedUri, isNull);
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 => [
const ValidationIssue(
severity: ValidationIssueSeverity.error,
message: 'There was an issue',
),
],
);
final tempDir = setUpTempDir();
setUpTempArtifacts(tempDir);
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);
});
});
}
@@ -265,6 +265,22 @@ flutter:
when(() => flutterValidator.validate(any())).thenAnswer((_) async => []);
});
test('logs deprecation warning', () async {
final tempDir = Directory.systemTemp.createTempSync();
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
verify(
() => logger.warn(
'''
"shorebird release" is deprecated.
Please use "shorebird release android" instead.''',
),
).called(1);
expect(exitCode, ExitCode.config.code);
});
test('throws config error when shorebird is not initialized', () async {
final tempDir = Directory.systemTemp.createTempSync();
final exitCode = await IOOverrides.runZoned(