feat(shorebird_cli): add release android_archive command (#573)
This commit is contained in:
@@ -15,6 +15,9 @@ import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
|
||||
/// Signature for a function which takes a list of bytes and returns a hash.
|
||||
typedef HashFunction = String Function(List<int> bytes);
|
||||
|
||||
/// Signature for a function which takes a path to a zip file.
|
||||
typedef UnzipFn = Future<void> Function(String zipFilePath, String outputDir);
|
||||
|
||||
typedef CodePushClientBuilder = CodePushClient Function({
|
||||
required http.Client httpClient,
|
||||
Uri? hostedUri,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export 'release_android_archive_command.dart';
|
||||
export 'release_android_command.dart';
|
||||
export 'release_command.dart';
|
||||
export 'release_ios_command.dart';
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive_io.dart';
|
||||
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/config.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_archive_command}
|
||||
/// `shorebird release android_archive`
|
||||
/// Create new Android archive releases.
|
||||
/// {@endtemplate}
|
||||
class ReleaseAndroidArchiveCommand extends ShorebirdCommand
|
||||
with
|
||||
AuthLoggerMixin,
|
||||
ShorebirdValidationMixin,
|
||||
ShorebirdConfigMixin,
|
||||
ShorebirdBuildMixin,
|
||||
ShorebirdCreateAppMixin,
|
||||
ShorebirdJavaMixin,
|
||||
ShorebirdReleaseVersionMixin {
|
||||
/// {@macro release_android_archive_command}
|
||||
ReleaseAndroidArchiveCommand({
|
||||
required super.logger,
|
||||
super.auth,
|
||||
super.buildCodePushClient,
|
||||
super.validators,
|
||||
HashFunction? hashFn,
|
||||
UnzipFn? unzipFn,
|
||||
}) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()),
|
||||
_unzipFn = unzipFn ?? extractFileToDisk {
|
||||
argParser
|
||||
..addOption(
|
||||
'flavor',
|
||||
help: 'The product flavor to use when building the app.',
|
||||
)
|
||||
// `flutter build aar` defaults to a build number of 1.0, so we do the
|
||||
// same.
|
||||
..addOption(
|
||||
'build-number',
|
||||
help: 'The build number of the aar',
|
||||
defaultsTo: '1.0',
|
||||
)
|
||||
..addFlag(
|
||||
'force',
|
||||
abbr: 'f',
|
||||
help: 'Release without confirmation if there are no errors.',
|
||||
negatable: false,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String get name => 'android_archive';
|
||||
|
||||
@override
|
||||
String get description => '''
|
||||
Builds and submits your Android archive to Shorebird.
|
||||
Shorebird saves the compiled Dart code from your application in order to
|
||||
make smaller updates to your app.
|
||||
''';
|
||||
|
||||
final HashFunction _hashFn;
|
||||
final UnzipFn _unzipFn;
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
// We know the pubspec exists due to the call to isShorebirdInitialized
|
||||
// above.
|
||||
final pubspec = getPubspecYaml()!;
|
||||
final module = pubspec.flutter?['module'] as Map?;
|
||||
final androidPackageName = module?['androidPackage'] as String?;
|
||||
if (androidPackageName == null) {
|
||||
logger.err('Could not find androidPackage in pubspec.yaml.');
|
||||
return ExitCode.config.code;
|
||||
}
|
||||
|
||||
final flavor = results['flavor'] as String?;
|
||||
final buildNumber = results['build-number'] as String;
|
||||
final buildProgress = logger.progress('Building aar');
|
||||
try {
|
||||
await buildAar(buildNumber: buildNumber, flavor: flavor);
|
||||
} on ProcessException catch (error) {
|
||||
buildProgress.fail('Failed to build: ${error.message}');
|
||||
return ExitCode.software.code;
|
||||
}
|
||||
|
||||
buildProgress.complete();
|
||||
|
||||
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 releaseVersion = buildNumber;
|
||||
const platform = 'android';
|
||||
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');
|
||||
final aarDir = p.joinAll([
|
||||
'build',
|
||||
'host',
|
||||
'outputs',
|
||||
'repo',
|
||||
...androidPackageName.split('.'),
|
||||
'flutter_release',
|
||||
buildNumber,
|
||||
]);
|
||||
final aarPath = p.join(
|
||||
aarDir,
|
||||
'flutter_release-$buildNumber.aar',
|
||||
);
|
||||
final zipPath = p.join(
|
||||
aarDir,
|
||||
'flutter_release-$buildNumber.zip',
|
||||
);
|
||||
|
||||
// Copy the .aar file to a .zip file so package:archive knows how to read it
|
||||
File(aarPath).copySync(zipPath);
|
||||
final extractedAarDir = p.join(aarDir, 'flutter_release-$buildNumber');
|
||||
// Unzip the .zip file to a directory so we can read the .so files
|
||||
await _unzipFn(zipPath, extractedAarDir);
|
||||
|
||||
for (final archMetadata in architectures.values) {
|
||||
final artifactPath = p.join(
|
||||
extractedAarDir,
|
||||
'jni',
|
||||
archMetadata.path,
|
||||
'libapp.so',
|
||||
);
|
||||
final artifact = File(artifactPath);
|
||||
final hash = _hashFn(await artifact.readAsBytes());
|
||||
logger.detail('Creating artifact for $artifactPath');
|
||||
|
||||
try {
|
||||
await codePushClient.createReleaseArtifact(
|
||||
releaseId: release.id,
|
||||
artifactPath: artifact.path,
|
||||
arch: archMetadata.arch,
|
||||
platform: platform,
|
||||
hash: hash,
|
||||
);
|
||||
} on CodePushConflictException catch (_) {
|
||||
// Newlines are due to how logger.info interacts with logger.progress.
|
||||
logger.info(
|
||||
'''
|
||||
|
||||
${archMetadata.arch} artifact already exists, continuing...''',
|
||||
);
|
||||
} catch (error) {
|
||||
createArtifactProgress.fail('Error uploading ${artifact.path}: $error');
|
||||
return ExitCode.software.code;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
logger.detail('Creating artifact for $aarPath');
|
||||
await codePushClient.createReleaseArtifact(
|
||||
releaseId: release.id,
|
||||
artifactPath: aarPath,
|
||||
arch: 'aar',
|
||||
platform: platform,
|
||||
hash: _hashFn(await File(aarPath).readAsBytes()),
|
||||
);
|
||||
} on CodePushConflictException catch (_) {
|
||||
// Newlines are due to how logger.info interacts with logger.progress.
|
||||
logger.info(
|
||||
'''
|
||||
|
||||
aar artifact already exists, continuing...''',
|
||||
);
|
||||
} catch (error) {
|
||||
createArtifactProgress.fail('Error uploading $aarPath: $error');
|
||||
return ExitCode.software.code;
|
||||
}
|
||||
|
||||
createArtifactProgress.complete();
|
||||
|
||||
logger
|
||||
..success('\n✅ Published Release!')
|
||||
..info('''
|
||||
|
||||
Your next step is to add this module as a dependency in your app's build.gradle:
|
||||
${lightCyan.wrap('''
|
||||
dependencies {
|
||||
// ...
|
||||
releaseImplementation '$androidPackageName:flutter_release:$buildNumber'
|
||||
// ...
|
||||
}''')}
|
||||
''');
|
||||
|
||||
return ExitCode.success.code;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:shorebird_cli/src/commands/commands.dart';
|
||||
class ReleaseCommand extends ShorebirdCommand {
|
||||
/// {@macro release_command}
|
||||
ReleaseCommand({required super.logger}) {
|
||||
addSubcommand(ReleaseAndroidArchiveCommand(logger: logger));
|
||||
addSubcommand(ReleaseAndroidCommand(logger: logger));
|
||||
addSubcommand(ReleaseIosCommand(logger: logger));
|
||||
}
|
||||
|
||||
+10
-3
@@ -1,5 +1,6 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:shorebird_cli/src/shorebird_process.dart';
|
||||
import 'package:shorebird_cli/src/validators/validators.dart';
|
||||
@@ -27,16 +28,22 @@ class AndroidInternetPermissionValidator extends Validator {
|
||||
@override
|
||||
Future<List<ValidationIssue>> validate(ShorebirdProcess process) async {
|
||||
const manifestFileName = 'AndroidManifest.xml';
|
||||
final androidSrcDir = Directory(
|
||||
final androidSrcDir = [
|
||||
p.join(
|
||||
Directory.current.path,
|
||||
'android',
|
||||
'app',
|
||||
'src',
|
||||
),
|
||||
);
|
||||
p.join(
|
||||
Directory.current.path,
|
||||
'.android',
|
||||
'Flutter',
|
||||
'src',
|
||||
),
|
||||
].map(Directory.new).firstWhereOrNull((dir) => dir.existsSync());
|
||||
|
||||
if (!androidSrcDir.existsSync()) {
|
||||
if (androidSrcDir == null) {
|
||||
return [
|
||||
const ValidationIssue(
|
||||
severity: ValidationIssueSeverity.error,
|
||||
|
||||
+677
@@ -0,0 +1,677 @@
|
||||
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:path/path.dart' as p;
|
||||
import 'package:shorebird_cli/src/auth/auth.dart';
|
||||
import 'package:shorebird_cli/src/commands/commands.dart';
|
||||
import 'package:shorebird_cli/src/shorebird_build_mixin.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 _MockLogger extends Mock implements Logger {}
|
||||
|
||||
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(ReleaseAndroidArchiveCommand, () {
|
||||
const appDisplayName = 'Test App';
|
||||
const appId = 'test-app-id';
|
||||
const appMetadata = AppMetadata(appId: appId, displayName: appDisplayName);
|
||||
|
||||
const flutterRevision = '83305b5088e6fe327fb3334a73ff190828d85713';
|
||||
|
||||
const versionName = '1.2.3';
|
||||
const versionCode = '1';
|
||||
const version = '$versionName+$versionCode';
|
||||
const release = Release(
|
||||
id: 0,
|
||||
appId: appId,
|
||||
version: version,
|
||||
flutterRevision: flutterRevision,
|
||||
displayName: '1.2.3+1',
|
||||
);
|
||||
|
||||
const arch = 'aarch64';
|
||||
const platform = 'android';
|
||||
const releaseArtifact = ReleaseArtifact(
|
||||
id: 0,
|
||||
releaseId: 0,
|
||||
arch: arch,
|
||||
platform: platform,
|
||||
hash: '#',
|
||||
size: 42,
|
||||
url: 'https://example.com',
|
||||
);
|
||||
|
||||
const buildNumber = '1.0';
|
||||
const noModulePubspecYamlContent = '''
|
||||
name: example
|
||||
version: 1.0.0
|
||||
environment:
|
||||
sdk: ">=2.19.0 <3.0.0"
|
||||
|
||||
flutter:
|
||||
assets:
|
||||
- shorebird.yaml''';
|
||||
|
||||
const pubspecYamlContent = '''
|
||||
name: example
|
||||
version: 1.0.0
|
||||
environment:
|
||||
sdk: ">=2.19.0 <3.0.0"
|
||||
|
||||
flutter:
|
||||
module:
|
||||
androidX: true
|
||||
androidPackage: com.example.my_flutter_module
|
||||
iosBundleIdentifier: com.example.myFlutterModule
|
||||
assets:
|
||||
- shorebird.yaml''';
|
||||
|
||||
late ArgResults argResults;
|
||||
late http.Client httpClient;
|
||||
late Auth auth;
|
||||
late Progress progress;
|
||||
late Logger logger;
|
||||
late ShorebirdProcessResult flutterBuildProcessResult;
|
||||
late ShorebirdProcessResult flutterRevisionProcessResult;
|
||||
late CodePushClient codePushClient;
|
||||
late ReleaseAndroidArchiveCommand command;
|
||||
late Uri? capturedHostedUri;
|
||||
late ShorebirdFlutterValidator flutterValidator;
|
||||
late ShorebirdProcess shorebirdProcess;
|
||||
|
||||
Directory setUpTempDir({bool includeModule = true}) {
|
||||
final tempDir = Directory.systemTemp.createTempSync();
|
||||
File(
|
||||
p.join(tempDir.path, 'pubspec.yaml'),
|
||||
).writeAsStringSync(
|
||||
includeModule ? pubspecYamlContent : noModulePubspecYamlContent,
|
||||
);
|
||||
File(
|
||||
p.join(tempDir.path, 'shorebird.yaml'),
|
||||
).writeAsStringSync('app_id: $appId');
|
||||
return tempDir;
|
||||
}
|
||||
|
||||
void setUpTempArtifacts(Directory dir) {
|
||||
final aarDir = p.join(
|
||||
'build',
|
||||
'host',
|
||||
'outputs',
|
||||
'repo',
|
||||
'com',
|
||||
'example',
|
||||
'my_flutter_module',
|
||||
'flutter_release',
|
||||
buildNumber,
|
||||
);
|
||||
final aarPath = p.join(aarDir, 'flutter_release-$buildNumber.aar');
|
||||
for (final archMetadata
|
||||
in ShorebirdBuildMixin.allAndroidArchitectures.values) {
|
||||
final artifactPath = p.join(
|
||||
aarDir,
|
||||
'flutter_release-$buildNumber',
|
||||
'jni',
|
||||
archMetadata.path,
|
||||
'libapp.so',
|
||||
);
|
||||
File(artifactPath).createSync(recursive: true);
|
||||
}
|
||||
File(aarPath).createSync(recursive: true);
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
argResults = _MockArgResults();
|
||||
httpClient = _MockHttpClient();
|
||||
auth = _MockAuth();
|
||||
progress = _MockProgress();
|
||||
logger = _MockLogger();
|
||||
flutterBuildProcessResult = _MockProcessResult();
|
||||
flutterRevisionProcessResult = _MockProcessResult();
|
||||
codePushClient = _MockCodePushClient();
|
||||
flutterValidator = _MockShorebirdFlutterValidator();
|
||||
shorebirdProcess = _MockShorebirdProcess();
|
||||
command = ReleaseAndroidArchiveCommand(
|
||||
auth: auth,
|
||||
buildCodePushClient: ({
|
||||
required http.Client httpClient,
|
||||
Uri? hostedUri,
|
||||
}) {
|
||||
capturedHostedUri = hostedUri;
|
||||
return codePushClient;
|
||||
},
|
||||
unzipFn: (_, __) async {},
|
||||
logger: logger,
|
||||
validators: [flutterValidator],
|
||||
)
|
||||
..testArgResults = argResults
|
||||
..testProcess = shorebirdProcess
|
||||
..testEngineConfig = const EngineConfig.empty();
|
||||
|
||||
registerFallbackValue(shorebirdProcess);
|
||||
|
||||
when(() => auth.client).thenReturn(httpClient);
|
||||
when(() => argResults['build-number']).thenReturn(buildNumber);
|
||||
when(() => argResults.rest).thenReturn([]);
|
||||
when(() => auth.isAuthenticated).thenReturn(true);
|
||||
when(() => logger.confirm(any())).thenReturn(true);
|
||||
when(() => logger.progress(any())).thenReturn(progress);
|
||||
|
||||
when(() => flutterBuildProcessResult.exitCode).thenReturn(0);
|
||||
|
||||
when(
|
||||
() => flutterRevisionProcessResult.exitCode,
|
||||
).thenReturn(ExitCode.success.code);
|
||||
when(
|
||||
() => flutterRevisionProcessResult.stdout,
|
||||
).thenReturn(flutterRevision);
|
||||
|
||||
when(
|
||||
() => shorebirdProcess.run(
|
||||
any(),
|
||||
any(that: containsAll(['build', 'aar'])),
|
||||
runInShell: any(named: 'runInShell'),
|
||||
),
|
||||
).thenAnswer((invocation) async {
|
||||
return flutterBuildProcessResult;
|
||||
});
|
||||
when(
|
||||
() => shorebirdProcess.run(
|
||||
'git',
|
||||
any(),
|
||||
runInShell: any(named: 'runInShell'),
|
||||
workingDirectory: any(named: 'workingDirectory'),
|
||||
),
|
||||
).thenAnswer((_) async => flutterRevisionProcessResult);
|
||||
|
||||
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 correct 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('exits with no user when not logged in', () async {
|
||||
when(() => auth.isAuthenticated).thenReturn(false);
|
||||
final tempDir = setUpTempDir(includeModule: false);
|
||||
|
||||
final result = await IOOverrides.runZoned(
|
||||
() async => command.run(),
|
||||
getCurrentDirectory: () => tempDir,
|
||||
);
|
||||
|
||||
expect(result, equals(ExitCode.noUser.code));
|
||||
verify(
|
||||
() => logger.err(any(that: contains('You must be logged in to run'))),
|
||||
).called(1);
|
||||
});
|
||||
|
||||
test('exits with 78 if no pubspec.yaml exists', () async {
|
||||
final tempDir = Directory.systemTemp.createTempSync();
|
||||
|
||||
final result = await IOOverrides.runZoned(
|
||||
() async => command.run(),
|
||||
getCurrentDirectory: () => tempDir,
|
||||
);
|
||||
|
||||
expect(result, ExitCode.config.code);
|
||||
});
|
||||
|
||||
test('exits with 78 if no module entry exists in pubspec.yaml', () async {
|
||||
final tempDir = setUpTempDir(includeModule: false);
|
||||
|
||||
final result = await IOOverrides.runZoned(
|
||||
() async => command.run(),
|
||||
getCurrentDirectory: () => tempDir,
|
||||
);
|
||||
|
||||
expect(result, ExitCode.config.code);
|
||||
});
|
||||
|
||||
test('exits with code 70 when building aar fails', () async {
|
||||
when(() => flutterBuildProcessResult.exitCode).thenReturn(1);
|
||||
when(() => flutterBuildProcessResult.stderr).thenReturn('oops');
|
||||
final tempDir = setUpTempDir();
|
||||
|
||||
final result = await IOOverrides.runZoned(
|
||||
() async => command.run(),
|
||||
getCurrentDirectory: () => tempDir,
|
||||
);
|
||||
|
||||
expect(result, equals(ExitCode.software.code));
|
||||
verify(
|
||||
() => shorebirdProcess.run(
|
||||
'flutter',
|
||||
[
|
||||
'build',
|
||||
'aar',
|
||||
'--no-debug',
|
||||
'--no-profile',
|
||||
'--build-number=$buildNumber',
|
||||
],
|
||||
runInShell: any(named: 'runInShell'),
|
||||
),
|
||||
).called(1);
|
||||
verify(() => progress.fail(any(that: contains('Failed to build'))))
|
||||
.called(1);
|
||||
});
|
||||
|
||||
test('throws error when fetching apps fails.', () async {
|
||||
const error = 'something went wrong';
|
||||
when(() => codePushClient.getApps()).thenThrow(error);
|
||||
final tempDir = setUpTempDir();
|
||||
|
||||
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();
|
||||
final exitCode = await IOOverrides.runZoned(
|
||||
command.run,
|
||||
getCurrentDirectory: () => tempDir,
|
||||
);
|
||||
verify(
|
||||
() => logger.err(
|
||||
'''
|
||||
Could not find app with id: "$appId".
|
||||
Did you forget to run "shorebird init"?''',
|
||||
),
|
||||
).called(1);
|
||||
expect(exitCode, ExitCode.software.code);
|
||||
});
|
||||
|
||||
test('aborts when user opts out', () async {
|
||||
when(() => logger.confirm(any())).thenReturn(false);
|
||||
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();
|
||||
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('logs message when uploading release artifact that already exists.',
|
||||
() 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(const CodePushConflictException(message: error));
|
||||
final tempDir = setUpTempDir();
|
||||
setUpTempArtifacts(tempDir);
|
||||
|
||||
final exitCode = await IOOverrides.runZoned(
|
||||
command.run,
|
||||
getCurrentDirectory: () => tempDir,
|
||||
);
|
||||
|
||||
// 1 for each arch, 1 for the aar
|
||||
final numArtifactsUploaded = Arch.values.length + 1;
|
||||
verify(
|
||||
() => logger.info(any(that: contains('already exists'))),
|
||||
).called(numArtifactsUploaded);
|
||||
verifyNever(() => progress.fail(error));
|
||||
expect(exitCode, ExitCode.success.code);
|
||||
});
|
||||
|
||||
test('logs message when uploading aar that already exists.', () async {
|
||||
const error = 'something went wrong';
|
||||
when(
|
||||
() => codePushClient.getReleases(appId: any(named: 'appId')),
|
||||
).thenAnswer((_) async => []);
|
||||
when(
|
||||
() => codePushClient.createReleaseArtifact(
|
||||
artifactPath: any(named: 'artifactPath', that: endsWith('.aar')),
|
||||
releaseId: any(named: 'releaseId'),
|
||||
arch: any(named: 'arch'),
|
||||
platform: any(named: 'platform'),
|
||||
hash: any(named: 'hash'),
|
||||
),
|
||||
).thenThrow(const CodePushConflictException(message: error));
|
||||
final tempDir = setUpTempDir();
|
||||
setUpTempArtifacts(tempDir);
|
||||
|
||||
final exitCode = await IOOverrides.runZoned(
|
||||
command.run,
|
||||
getCurrentDirectory: () => tempDir,
|
||||
);
|
||||
|
||||
verify(
|
||||
() => logger.info(
|
||||
any(that: contains('aar artifact already exists, continuing...')),
|
||||
),
|
||||
).called(1);
|
||||
verifyNever(() => progress.fail(error));
|
||||
expect(exitCode, ExitCode.success.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(any(that: stringContainsInOrder(['libapp.so', error]))),
|
||||
).called(1);
|
||||
expect(exitCode, ExitCode.software.code);
|
||||
});
|
||||
|
||||
test('throws error when uploading aar 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('.aar')),
|
||||
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(
|
||||
any(
|
||||
that: stringContainsInOrder(
|
||||
['flutter_release-$buildNumber.aar', error],
|
||||
),
|
||||
),
|
||||
),
|
||||
).called(1);
|
||||
expect(exitCode, ExitCode.software.code);
|
||||
});
|
||||
|
||||
test('does not prompt for confirmation when --force is used', () async {
|
||||
when(() => argResults['force']).thenReturn(true);
|
||||
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('.aar')),
|
||||
releaseId: any(named: 'releaseId'),
|
||||
arch: 'aar',
|
||||
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';
|
||||
when(() => argResults['flavor']).thenReturn(flavor);
|
||||
final tempDir = setUpTempDir();
|
||||
File(
|
||||
p.join(tempDir.path, 'shorebird.yaml'),
|
||||
).writeAsStringSync('''
|
||||
app_id: productionAppId
|
||||
flavors:
|
||||
development: $appId''');
|
||||
setUpTempArtifacts(tempDir);
|
||||
final exitCode = await IOOverrides.runZoned(
|
||||
command.run,
|
||||
getCurrentDirectory: () => tempDir,
|
||||
);
|
||||
verify(() => logger.success('\n✅ Published Release!')).called(1);
|
||||
verify(
|
||||
() => codePushClient.createReleaseArtifact(
|
||||
artifactPath: any(named: 'artifactPath', that: endsWith('.aar')),
|
||||
releaseId: any(named: 'releaseId'),
|
||||
arch: 'aar',
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user