feat: Set FLUTTER_STORAGE_BASE_URL when using Shorebird-vended Flutter, warn user when path flutter version does not match shorebird flutter version (#243)

Co-authored-by: Eric Seidel <eric@shorebird.dev>
Co-authored-by: Felix Angelov <felix@shorebird.dev>
This commit is contained in:
Bryan Oltman
2023-04-06 17:58:06 -04:00
committed by GitHub
parent b7738d663c
commit dbfdf29073
25 changed files with 694 additions and 104 deletions
+7 -1
View File
@@ -7,6 +7,7 @@ import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
import 'package:shorebird_cli/src/auth/auth.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';
/// Signature for a function which takes a list of bytes and returns a hash.
@@ -30,16 +31,21 @@ abstract class ShorebirdCommand extends Command<int> {
CodePushClientBuilder? buildCodePushClient,
RunProcess? runProcess,
StartProcess? startProcess,
ShorebirdFlutterValidator? flutterValidator,
}) : auth = auth ?? Auth(),
buildCodePushClient = buildCodePushClient ?? CodePushClient.new,
runProcess = runProcess ?? ShorebirdProcess.run,
startProcess = startProcess ?? ShorebirdProcess.start;
startProcess = startProcess ?? ShorebirdProcess.start {
this.flutterValidator = flutterValidator ??
ShorebirdFlutterValidator(runProcess: this.runProcess);
}
final Auth auth;
final CodePushClientBuilder buildCodePushClient;
final Logger logger;
final RunProcess runProcess;
final StartProcess startProcess;
late final ShorebirdFlutterValidator flutterValidator;
/// [ArgResults] used for testing purposes only.
@visibleForTesting
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/flutter_validation_mixin.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_engine_mixin.dart';
@@ -12,13 +13,18 @@ import 'package:shorebird_cli/src/shorebird_engine_mixin.dart';
/// Build a new release of your application.
/// {@endtemplate}
class BuildCommand extends ShorebirdCommand
with ShorebirdConfigMixin, ShorebirdEngineMixin, ShorebirdBuildMixin {
with
FlutterValidationMixin,
ShorebirdConfigMixin,
ShorebirdEngineMixin,
ShorebirdBuildMixin {
/// {@macro build_command}
BuildCommand({
required super.logger,
super.auth,
super.buildCodePushClient,
super.runProcess,
super.flutterValidator,
});
@override
@@ -43,6 +49,8 @@ class BuildCommand extends ShorebirdCommand
return ExitCode.software.code;
}
await logFlutterValidationIssues();
final buildProgress = logger.progress('Building release ');
try {
await buildRelease();
@@ -1,8 +1,7 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/doctor/doctor_validator.dart';
import 'package:shorebird_cli/src/doctor/validators/validators.dart';
import 'package:shorebird_cli/src/shorebird_version_mixin.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:shorebird_cli/src/version.dart';
/// {@template doctor_command}
@@ -15,11 +14,11 @@ class DoctorCommand extends ShorebirdCommand with ShorebirdVersionMixin {
/// {@macro doctor_command}
DoctorCommand({
required super.logger,
List<DoctorValidator>? validators,
List<Validator>? validators,
super.runProcess,
}) {
this.validators = validators ??
<DoctorValidator>[
<Validator>[
ShorebirdVersionValidator(
isShorebirdVersionCurrent: isShorebirdVersionCurrent,
),
@@ -28,7 +27,7 @@ class DoctorCommand extends ShorebirdCommand with ShorebirdVersionMixin {
];
}
late final List<DoctorValidator> validators;
late final List<Validator> validators;
@override
String get name => 'doctor';
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/flutter_validation_mixin.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';
@@ -18,6 +19,7 @@ import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// {@endtemplate}
class PatchCommand extends ShorebirdCommand
with
FlutterValidationMixin,
ShorebirdConfigMixin,
ShorebirdEngineMixin,
ShorebirdBuildMixin,
@@ -28,6 +30,7 @@ class PatchCommand extends ShorebirdCommand
super.auth,
super.buildCodePushClient,
super.runProcess,
super.flutterValidator,
HashFunction? hashFn,
http.Client? httpClient,
}) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()),
@@ -105,6 +108,8 @@ class PatchCommand extends ShorebirdCommand
return ExitCode.software.code;
}
await logFlutterValidationIssues();
final force = results['force'] == true;
final dryRun = results['dry-run'] == true;
@@ -5,6 +5,7 @@ import 'package:crypto/crypto.dart';
import 'package:mason_logger/mason_logger.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/flutter_validation_mixin.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';
@@ -17,6 +18,7 @@ import 'package:shorebird_code_push_client/shorebird_code_push_client.dart';
/// {@endtemplate}
class ReleaseCommand extends ShorebirdCommand
with
FlutterValidationMixin,
ShorebirdConfigMixin,
ShorebirdEngineMixin,
ShorebirdBuildMixin,
@@ -27,6 +29,7 @@ class ReleaseCommand extends ShorebirdCommand
super.auth,
super.buildCodePushClient,
super.runProcess,
super.flutterValidator,
HashFunction? hashFn,
}) : _hashFn = hashFn ?? ((m) => sha256.convert(m).toString()) {
argParser
@@ -83,6 +86,8 @@ make smaller updates to your app.
return ExitCode.software.code;
}
await logFlutterValidationIssues();
final buildProgress = logger.progress('Building release');
try {
await buildRelease();
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:mason_logger/mason_logger.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/flutter_validation_mixin.dart';
import 'package:shorebird_cli/src/shorebird_config_mixin.dart';
import 'package:shorebird_cli/src/shorebird_engine_mixin.dart';
@@ -10,13 +11,14 @@ import 'package:shorebird_cli/src/shorebird_engine_mixin.dart';
/// Run the Flutter application.
/// {@endtemplate}
class RunCommand extends ShorebirdCommand
with ShorebirdConfigMixin, ShorebirdEngineMixin {
with FlutterValidationMixin, ShorebirdConfigMixin, ShorebirdEngineMixin {
/// {@macro run_command}
RunCommand({
required super.logger,
super.auth,
super.buildCodePushClient,
super.startProcess,
super.flutterValidator,
});
@override
@@ -41,6 +43,8 @@ class RunCommand extends ShorebirdCommand
return ExitCode.software.code;
}
await logFlutterValidationIssues();
logger.info('Running app...');
final process = await startProcess(
'flutter',
@@ -1,70 +0,0 @@
import 'package:shorebird_cli/src/doctor/doctor_validator.dart';
import 'package:shorebird_cli/src/shorebird_paths.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
class ShorebirdFlutterValidator extends DoctorValidator {
ShorebirdFlutterValidator({required this.runProcess});
final RunProcess runProcess;
// coverage:ignore-start
@override
String get description => 'Flutter install is correct';
// coverage:ignore-end
@override
Future<List<ValidationIssue>> validate() async {
if (!ShorebirdPaths.flutterDirectory.existsSync()) {
final message =
'No Flutter directory found at ${ShorebirdPaths.flutterDirectory}';
return [
ValidationIssue(
severity: ValidationIssueSeverity.error,
message: message,
),
];
}
if (!await _flutterDirectoryIsClean()) {
return [
ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: '${ShorebirdPaths.flutterDirectory} has local modifications',
),
];
}
if (!await _flutterDirectoryTracksStable()) {
final message =
'${ShorebirdPaths.flutterDirectory} is not on the "stable" branch';
return [
ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: message,
),
];
}
return [];
}
Future<bool> _flutterDirectoryIsClean() async {
final result = await runProcess(
'git',
['status'],
workingDirectory: ShorebirdPaths.flutterDirectory.path,
);
return result.stdout
.toString()
.contains('nothing to commit, working tree clean');
}
Future<bool> _flutterDirectoryTracksStable() async {
final result = await runProcess(
'git',
['--no-pager', 'branch'],
workingDirectory: ShorebirdPaths.flutterDirectory.path,
);
return result.stdout.toString().contains('* stable');
}
}
@@ -1,3 +0,0 @@
export 'android_internet_permission_validator.dart';
export 'shorebird_flutter_validator.dart';
export 'shorebird_version_validator.dart';
@@ -0,0 +1,15 @@
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/validators/shorebird_flutter_validator.dart';
mixin FlutterValidationMixin on ShorebirdCommand {
/// Runs [ShorebirdFlutterValidator.validate] and writes validation issues to
/// stdout.
Future<void> logFlutterValidationIssues() async {
final flutterValidationIssues = await flutterValidator.validate();
if (flutterValidationIssues.isNotEmpty) {
for (final issue in flutterValidationIssues) {
logger.info(issue.displayMessage);
}
}
}
}
@@ -4,10 +4,13 @@ import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
abstract class ShorebirdPaths {
abstract class ShorebirdEnvironment {
@visibleForTesting
static Platform platform = const LocalPlatform();
/// Environment variables from [Platform.environment].
static Map<String, String> get environment => platform.environment;
/// The root directory of the Shorebird install.
///
/// Assumes we are running from $ROOT/bin/cache.
@@ -1,19 +1,23 @@
import 'dart:io';
import 'package:meta/meta.dart';
import 'package:shorebird_cli/src/shorebird_paths.dart';
import 'package:shorebird_cli/src/shorebird_environment.dart';
typedef RunProcess = Future<ProcessResult> Function(
String executable,
List<String> arguments, {
bool runInShell,
Map<String, String>? environment,
String? workingDirectory,
bool useVendedFlutter,
});
typedef StartProcess = Future<Process> Function(
String executable,
List<String> arguments, {
bool runInShell,
Map<String, String>? environment,
bool useVendedFlutter,
});
/// A wrapper around [Process] that replaces executables to Shorebird-vended
@@ -26,42 +30,100 @@ abstract class ShorebirdProcess {
String executable,
List<String> arguments, {
bool runInShell = false,
Map<String, String>? environment,
String? workingDirectory,
bool useVendedFlutter = true,
}) {
final resolvedEnvironment = environment ?? {};
if (useVendedFlutter) {
// Note: this will overwrite existing environment values.
resolvedEnvironment.addAll(
_environmentOverrides(executable: executable),
);
}
return processWrapper.run(
_resolveExecutable(executable),
useVendedFlutter ? _resolveExecutable(executable) : executable,
arguments,
runInShell: runInShell,
workingDirectory: workingDirectory,
environment: resolvedEnvironment,
);
}
static Future<Process> start(
String executable,
List<String> argument, {
Map<String, String>? environment,
bool runInShell = false,
bool useVendedFlutter = true,
}) {
final resolvedEnvironment = environment ?? {};
if (useVendedFlutter) {
// Note: this will overwrite existing environment values.
resolvedEnvironment.addAll(
_environmentOverrides(executable: executable),
);
}
return processWrapper.start(
_resolveExecutable(executable),
useVendedFlutter ? _resolveExecutable(executable) : executable,
argument,
runInShell: runInShell,
environment: resolvedEnvironment,
);
}
static String _resolveExecutable(String executable) {
if (executable == 'flutter') {
return ShorebirdPaths.flutterBinaryFile.path;
return ShorebirdEnvironment.flutterBinaryFile.path;
}
return executable;
}
static Map<String, String> _environmentOverrides({
required String executable,
}) {
if (executable == 'flutter') {
return {'FLUTTER_STORAGE_BASE_URL': 'https://download.shorebird.dev/'};
}
return {};
}
}
// coverage:ignore-start
@visibleForTesting
class ProcessWrapper {
RunProcess get run => Process.run;
RunProcess get run => (
String executable,
List<String> arguments, {
bool runInShell = false,
Map<String, String>? environment,
String? workingDirectory,
bool useVendedFlutter = true,
}) =>
Process.run(
executable,
arguments,
environment: environment,
runInShell: runInShell,
workingDirectory: workingDirectory,
);
StartProcess get start => Process.start;
StartProcess get start => (
String executable,
List<String> arguments, {
bool runInShell = false,
Map<String, String>? environment,
bool useVendedFlutter = true,
}) =>
Process.start(
executable,
arguments,
runInShell: runInShell,
environment: environment,
);
}
// coverage:ignore-end
@@ -1,14 +1,14 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/doctor/doctor_validator.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:xml/xml.dart';
/// Checks that all AndroidManifest.xml files in android/app/src/{flavor}/
/// contain the INTERNET permission, which is required for Shorebird to work.
///
/// See https://github.com/shorebirdtech/shorebird/issues/160.
class AndroidInternetPermissionValidator extends DoctorValidator {
class AndroidInternetPermissionValidator extends Validator {
// coverage:ignore-start
@override
String get description =>
@@ -0,0 +1,132 @@
import 'package:shorebird_cli/src/shorebird_environment.dart';
import 'package:shorebird_cli/src/shorebird_process.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
class ShorebirdFlutterValidator extends Validator {
ShorebirdFlutterValidator({required this.runProcess});
final RunProcess runProcess;
final _flutterVersionRegex = RegExp(r'Flutter (\d+.\d+.\d+)');
// coverage:ignore-start
@override
String get description => 'Flutter install is correct';
// coverage:ignore-end
@override
Future<List<ValidationIssue>> validate() async {
final issues = <ValidationIssue>[];
if (!ShorebirdEnvironment.flutterDirectory.existsSync()) {
final message = 'No Flutter directory found at '
'${ShorebirdEnvironment.flutterDirectory}';
issues.add(
ValidationIssue(
severity: ValidationIssueSeverity.error,
message: message,
),
);
}
if (!await _flutterDirectoryIsClean()) {
issues.add(
ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: '${ShorebirdEnvironment.flutterDirectory} has local '
'modifications',
),
);
}
if (!await _flutterDirectoryTracksStable()) {
final message =
'${ShorebirdEnvironment.flutterDirectory} is not on the "stable" '
'branch';
issues.add(
ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: message,
),
);
}
final shorebirdFlutterVersion = await _shorebirdFlutterVersion();
final pathFlutterVersion = await _pathFlutterVersion();
if (shorebirdFlutterVersion != pathFlutterVersion) {
final message = """
Shorebird Flutter and the Flutter on your path are different versions.
\tShorebird Flutter: $shorebirdFlutterVersion
\tSystem Flutter: $pathFlutterVersion'''
This can cause unexpected behavior if the version gap is wide. If you're seeing this unexpectedly, please let us know on Shorebird discord!""";
issues.add(
ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: message,
),
);
}
final flutterStorageEnvironmentValue =
ShorebirdEnvironment.environment['FLUTTER_STORAGE_BASE_URL'];
if (flutterStorageEnvironmentValue != null &&
flutterStorageEnvironmentValue.isNotEmpty) {
issues.add(
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Shorebird does not respect the FLUTTER_STORAGE_BASE_URL '
'environment variable at this time',
),
);
}
return issues;
}
Future<bool> _flutterDirectoryIsClean() async {
final result = await runProcess(
'git',
['status'],
workingDirectory: ShorebirdEnvironment.flutterDirectory.path,
);
return result.stdout
.toString()
.contains('nothing to commit, working tree clean');
}
Future<bool> _flutterDirectoryTracksStable() async {
final result = await runProcess(
'git',
['--no-pager', 'branch'],
workingDirectory: ShorebirdEnvironment.flutterDirectory.path,
);
return result.stdout.toString().contains('* stable');
}
Future<String> _shorebirdFlutterVersion() => _getFlutterVersion(
checkPathFlutter: false,
);
Future<String> _pathFlutterVersion() => _getFlutterVersion(
checkPathFlutter: true,
);
Future<String> _getFlutterVersion({
required bool checkPathFlutter,
}) async {
final result = await runProcess(
'flutter',
['--version'],
useVendedFlutter: !checkPathFlutter,
);
final output = result.stdout.toString();
final match = _flutterVersionRegex.firstMatch(output);
if (match == null) {
throw Exception('Could not find version match in $output');
}
return match.group(1)!;
}
}
@@ -1,10 +1,10 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/doctor/doctor_validator.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
/// Verifies that the currently installed version of Shorebird is the latest.
class ShorebirdVersionValidator extends DoctorValidator {
class ShorebirdVersionValidator extends Validator {
ShorebirdVersionValidator({required this.isShorebirdVersionCurrent});
final Future<bool> Function({required String workingDirectory})
@@ -1,6 +1,10 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:meta/meta.dart';
export 'android_internet_permission_validator.dart';
export 'shorebird_flutter_validator.dart';
export 'shorebird_version_validator.dart';
/// Severity level of a [ValidationIssue].
///
/// [error]s should be fixed before continuing development.
@@ -60,7 +64,7 @@ class ValidationIssue {
/// Checks for a specific issue with either the Shorebird installation or the
/// current Shorebird project.
abstract class DoctorValidator {
abstract class Validator {
/// A one-sentence explanation of what this validator is checking.
String get description;
@@ -9,6 +9,7 @@ import 'package:mocktail/mocktail.dart';
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/commands/build_command.dart';
import 'package:shorebird_cli/src/config/config.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';
@@ -26,6 +27,9 @@ class _MockProcessResult extends Mock implements ProcessResult {}
class _MockCodePushClient extends Mock implements CodePushClient {}
class _MockShorebirdFlutterValidator extends Mock
implements ShorebirdFlutterValidator {}
void main() {
group('build', () {
late ArgResults argResults;
@@ -36,6 +40,7 @@ void main() {
late Logger logger;
late ProcessResult processResult;
late BuildCommand buildCommand;
late ShorebirdFlutterValidator flutterValidator;
setUp(() {
applicationConfigHome = Directory.systemTemp.createTempSync();
@@ -45,6 +50,7 @@ void main() {
codePushClient = _MockCodePushClient();
logger = _MockLogger();
processResult = _MockProcessResult();
flutterValidator = _MockShorebirdFlutterValidator();
buildCommand = BuildCommand(
auth: auth,
buildCodePushClient: ({
@@ -58,10 +64,13 @@ void main() {
executable,
arguments, {
bool runInShell = false,
Map<String, String>? environment,
String? workingDirectory,
bool useVendedFlutter = true,
}) async {
return processResult;
},
flutterValidator: flutterValidator,
)..testArgResults = argResults;
testApplicationConfigHome = (_) => applicationConfigHome.path;
@@ -72,6 +81,8 @@ void main() {
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenAnswer((_) async => Uint8List.fromList([]));
when(() => logger.progress(any())).thenReturn(_MockProgress());
when(() => logger.info(any())).thenReturn(null);
when(() => flutterValidator.validate()).thenAnswer((_) async => []);
});
test('exits with no user when not logged in', () async {
@@ -130,5 +141,36 @@ void main() {
expect(result, equals(ExitCode.success.code));
});
test('prints flutter validation warnings', () async {
when(() => flutterValidator.validate()).thenAnswer(
(_) async => [
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Flutter issue 1',
),
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Flutter issue 2',
),
],
);
when(() => processResult.exitCode).thenReturn(ExitCode.success.code);
when(
() => codePushClient.downloadEngine(revision: any(named: 'revision')),
).thenAnswer(
(_) async => Uint8List.fromList(ZipEncoder().encode(Archive())!),
);
final result = await buildCommand.run();
expect(result, equals(ExitCode.success.code));
verify(
() => logger.info(any(that: contains('Flutter issue 1'))),
).called(1);
verify(
() => logger.info(any(that: contains('Flutter issue 2'))),
).called(1);
});
});
}
@@ -1,8 +1,7 @@
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shorebird_cli/src/commands/commands.dart';
import 'package:shorebird_cli/src/doctor/doctor_validator.dart';
import 'package:shorebird_cli/src/doctor/validators/validators.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:test/test.dart';
class _MockShorebirdVersionValidator extends Mock
@@ -10,6 +10,7 @@ import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/commands/patch_command.dart';
import 'package:shorebird_cli/src/config/config.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';
@@ -29,6 +30,9 @@ class _MockHttpClient extends Mock implements http.Client {}
class _MockCodePushClient extends Mock implements CodePushClient {}
class _MockShorebirdFlutterValidator extends Mock
implements ShorebirdFlutterValidator {}
void main() {
group('patch', () {
const appId = 'test-app-id';
@@ -85,6 +89,7 @@ flutter:
late CodePushClient codePushClient;
late PatchCommand command;
late Uri? capturedHostedUri;
late ShorebirdFlutterValidator flutterValidator;
Directory setUpTempDir() {
final tempDir = Directory.systemTemp.createTempSync();
@@ -111,6 +116,7 @@ flutter:
patchProcessResult = _MockProcessResult();
httpClient = _MockHttpClient();
codePushClient = _MockCodePushClient();
flutterValidator = _MockShorebirdFlutterValidator();
command = PatchCommand(
auth: auth,
buildCodePushClient: ({
@@ -124,7 +130,9 @@ flutter:
executable,
arguments, {
bool runInShell = false,
Map<String, String>? environment,
String? workingDirectory,
bool useVendedFlutter = true,
}) async {
if (executable == 'flutter') return flutterBuildProcessResult;
if (executable.endsWith('patch')) return patchProcessResult;
@@ -132,6 +140,7 @@ flutter:
},
logger: logger,
httpClient: httpClient,
flutterValidator: flutterValidator,
)..testArgResults = argResults;
testApplicationConfigHome = (_) => applicationConfigHome.path;
@@ -198,6 +207,7 @@ flutter:
channelId: any(named: 'channelId'),
),
).thenAnswer((_) async {});
when(() => flutterValidator.validate()).thenAnswer((_) async => []);
});
test('throws config error when shorebird is not initialized', () async {
@@ -838,5 +848,50 @@ base_url: $baseUrl''',
);
expect(capturedHostedUri, equals(Uri.parse(baseUrl)));
});
test('prints flutter validation warnings', () async {
final tempDir = setUpTempDir();
Directory(
p.join(command.shorebirdEnginePath, 'engine'),
).createSync(recursive: true);
final artifactPath = p.join(
tempDir.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
when(() => flutterValidator.validate()).thenAnswer(
(_) async => [
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Flutter issue 1',
),
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Flutter issue 2',
),
],
);
final exitCode = await IOOverrides.runZoned(
command.run,
getCurrentDirectory: () => tempDir,
);
expect(exitCode, equals(ExitCode.success.code));
verify(
() => logger.info(any(that: contains('Flutter issue 1'))),
).called(1);
verify(
() => logger.info(any(that: contains('Flutter issue 2'))),
).called(1);
});
});
}
@@ -10,6 +10,7 @@ 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/config/config.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';
@@ -27,6 +28,9 @@ class _MockProcessResult extends Mock implements ProcessResult {}
class _MockCodePushClient extends Mock implements CodePushClient {}
class _MockShorebirdFlutterValidator extends Mock
implements ShorebirdFlutterValidator {}
void main() {
group('release', () {
const appId = 'test-app-id';
@@ -71,6 +75,7 @@ flutter:
late CodePushClient codePushClient;
late ReleaseCommand command;
late Uri? capturedHostedUri;
late ShorebirdFlutterValidator flutterValidator;
Directory setUpTempDir() {
final tempDir = Directory.systemTemp.createTempSync();
@@ -92,6 +97,7 @@ flutter:
logger = _MockLogger();
processResult = _MockProcessResult();
codePushClient = _MockCodePushClient();
flutterValidator = _MockShorebirdFlutterValidator();
command = ReleaseCommand(
auth: auth,
buildCodePushClient: ({
@@ -105,11 +111,14 @@ flutter:
executable,
arguments, {
bool runInShell = false,
Map<String, String>? environment,
String? workingDirectory,
bool useVendedFlutter = true,
}) async {
return processResult;
},
logger: logger,
flutterValidator: flutterValidator,
)..testArgResults = argResults;
testApplicationConfigHome = (_) => applicationConfigHome.path;
@@ -148,6 +157,7 @@ flutter:
hash: any(named: 'hash'),
),
).thenAnswer((_) async => releaseArtifact);
when(() => flutterValidator.validate()).thenAnswer((_) async => []);
});
test('throws config error when shorebird is not initialized', () async {
@@ -446,5 +456,50 @@ Did you forget to run "shorebird init"?''',
expect(exitCode, ExitCode.success.code);
expect(capturedHostedUri, isNull);
});
test('prints flutter validation warnings', () async {
when(() => flutterValidator.validate()).thenAnswer(
(_) async => [
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Flutter issue 1',
),
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Flutter issue 2',
),
],
);
final tempDir = setUpTempDir();
Directory(
p.join(command.shorebirdEnginePath, 'engine'),
).createSync(recursive: true);
final artifactPath = p.join(
tempDir.path,
'build',
'app',
'intermediates',
'stripped_native_libs',
'release',
'out',
'lib',
'arm64-v8a',
'libapp.so',
);
File(artifactPath).createSync(recursive: true);
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);
});
});
}
@@ -12,6 +12,7 @@ import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/auth/auth.dart';
import 'package:shorebird_cli/src/commands/run_command.dart';
import 'package:shorebird_cli/src/config/config.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';
@@ -29,6 +30,9 @@ class _MockProcess extends Mock implements Process {}
class _MockCodePushClient extends Mock implements CodePushClient {}
class _MockShorebirdFlutterValidator extends Mock
implements ShorebirdFlutterValidator {}
void main() {
group('run', () {
late ArgResults argResults;
@@ -39,6 +43,7 @@ void main() {
late Process process;
late CodePushClient codePushClient;
late RunCommand runCommand;
late ShorebirdFlutterValidator flutterValidator;
setUp(() {
argResults = _MockArgResults();
@@ -48,6 +53,7 @@ void main() {
logger = _MockLogger();
process = _MockProcess();
codePushClient = _MockCodePushClient();
flutterValidator = _MockShorebirdFlutterValidator();
runCommand = RunCommand(
auth: auth,
logger: logger,
@@ -60,6 +66,7 @@ void main() {
startProcess: (executable, arguments, {bool runInShell = false}) async {
return process;
},
flutterValidator: flutterValidator,
)..testArgResults = argResults;
testApplicationConfigHome = (_) => applicationConfigHome.path;
@@ -68,6 +75,7 @@ void main() {
when(() => auth.isAuthenticated).thenReturn(true);
when(() => auth.client).thenReturn(httpClient);
when(() => logger.progress(any())).thenReturn(_MockProgress());
when(() => flutterValidator.validate()).thenAnswer((_) async => []);
});
test('exits with no user when not logged in', () async {
@@ -184,5 +192,50 @@ void main() {
await expectLater(result, equals(ExitCode.success.code));
verify(() => logger.info(output)).called(1);
});
test('prints flutter validation warnings', () async {
when(() => flutterValidator.validate()).thenAnswer(
(_) async => [
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Flutter issue 1',
),
const ValidationIssue(
severity: ValidationIssueSeverity.warning,
message: 'Flutter issue 2',
),
],
);
final tempDir = Directory.systemTemp.createTempSync();
Directory(
p.join(runCommand.shorebirdEnginePath, 'engine'),
).createSync(recursive: true);
final progress = _MockProgress();
when(() => logger.progress(any())).thenReturn(progress);
const output = 'some output';
when(
() => process.stdout,
).thenAnswer((_) => Stream.value(utf8.encode(output)));
when(() => process.stderr).thenAnswer((_) => const Stream.empty());
when(
() => process.exitCode,
).thenAnswer((_) async => ExitCode.success.code);
final result = await IOOverrides.runZoned(
() => runCommand.run(),
getCurrentDirectory: () => tempDir,
);
await expectLater(result, equals(ExitCode.success.code));
verify(() => logger.info(output)).called(1);
verify(
() => logger.info(any(that: contains('Flutter issue 1'))),
).called(1);
verify(
() => logger.info(any(that: contains('Flutter issue 2'))),
).called(1);
});
});
}
@@ -38,7 +38,9 @@ void main() {
executable,
arguments, {
bool runInShell = false,
Map<String, String>? environment,
workingDirectory,
bool useVendedFlutter = true,
}) async {
if (executable == 'git') {
const revParseHead = ['rev-parse', '--verify', 'HEAD'];
@@ -23,12 +23,15 @@ void main() {
ShorebirdProcess.processWrapper = processWrapper;
// TODO(bryanoltman): this method of mocking processWrappper is not correctly matching arguments
when(() => processWrapper.run).thenReturn(
(
executable,
arguments, {
bool runInShell = false,
Map<String, String>? environment,
String? workingDirectory,
bool useVendedFlutter = true,
}) async {
return runProcessResult;
},
@@ -39,6 +42,8 @@ void main() {
executable,
arguments, {
bool runInShell = false,
Map<String, String>? environment,
bool useVendedFlutter = true,
}) async {
return startProcess;
},
@@ -81,6 +86,75 @@ void main() {
),
).called(1);
});
test(
'does not replace flutter with our local flutter if'
' useVendedFlutter is false', () async {
await ShorebirdProcess.run(
'flutter',
['--version'],
runInShell: true,
workingDirectory: '~',
useVendedFlutter: false,
);
verify(
() => processWrapper.run(
'flutter',
['--version'],
runInShell: true,
workingDirectory: '~',
),
).called(1);
});
test('Updates environment if useVendedFlutter is true', () async {
await ShorebirdProcess.run(
'flutter',
['--version'],
runInShell: true,
workingDirectory: '~',
useVendedFlutter: false,
environment: {'ENV_VAR': 'asdfasdf'},
);
verify(
() => processWrapper.run(
'flutter',
['--version'],
runInShell: true,
workingDirectory: '~',
environment: {
'ENV_VAR': 'asdfasdf',
'FLUTTER_STORAGE_BASE_URL': 'https://download.shorebird.dev/',
},
),
).called(1);
});
test(
'Makes no changes to environment if useVendedFlutter is false',
() async {
await ShorebirdProcess.run(
'flutter',
['--version'],
runInShell: true,
workingDirectory: '~',
useVendedFlutter: false,
environment: {'ENV_VAR': 'asdfasdf'},
);
verify(
() => processWrapper.run(
'flutter',
['--version'],
runInShell: true,
workingDirectory: '~',
environment: {'ENV_VAR': 'asdfasdf'},
),
).called(1);
},
);
});
group('start', () {
@@ -102,6 +176,66 @@ void main() {
),
).called(1);
});
test(
'does not replace flutter with our local flutter if'
' useVendedFlutter is false', () async {
await ShorebirdProcess.start(
'flutter',
['--version'],
runInShell: true,
useVendedFlutter: false,
);
verify(
() => processWrapper.start(
'flutter',
['--version'],
runInShell: true,
),
).called(1);
});
});
test('Updates environment if useVendedFlutter is true', () async {
await ShorebirdProcess.start(
'flutter',
['--version'],
runInShell: true,
);
verify(
() => processWrapper.start(
'flutter',
['--version'],
runInShell: true,
environment: {
'ENV_VAR': 'asdfasdf',
'FLUTTER_STORAGE_BASE_URL': 'https://download.shorebird.dev/',
},
),
).called(1);
});
test(
'Makes no changes to environment if useVendedFlutter is false',
() async {
await ShorebirdProcess.start(
'flutter',
['--version'],
runInShell: true,
useVendedFlutter: false,
);
verify(
() => processWrapper.start(
'flutter',
['--version'],
runInShell: true,
environment: null,
),
).called(1);
},
);
});
}
@@ -2,8 +2,7 @@ import 'dart:io';
import 'package:collection/collection.dart';
import 'package:path/path.dart' as p;
import 'package:shorebird_cli/src/doctor/doctor_validator.dart';
import 'package:shorebird_cli/src/doctor/validators/validators.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:test/test.dart';
void main() {
@@ -4,9 +4,8 @@ import 'package:collection/collection.dart';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as p;
import 'package:platform/platform.dart';
import 'package:shorebird_cli/src/doctor/doctor_validator.dart';
import 'package:shorebird_cli/src/doctor/validators/validators.dart';
import 'package:shorebird_cli/src/shorebird_paths.dart';
import 'package:shorebird_cli/src/shorebird_environment.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:test/test.dart';
class _MockProcessResult extends Mock implements ProcessResult {}
@@ -25,10 +24,26 @@ nothing to commit, working tree clean
const gitBranchMessage = '''
main
* stable
''';
const pathFlutterVersionMessage = '''
Flutter 3.7.9 channel unknown unknown source
Framework revision 62bd79521d (7 days ago) 2023-03-30 10:59:36 -0700
Engine revision ec975089ac
Tools Dart 2.19.6 DevTools 2.20.1
''';
const shorebirdFlutterVersionMessage = '''
Flutter 3.7.9 channel stable https://github.com/shorebirdtech/flutter.git
Framework revision 62bd79521d (7 days ago) 2023-03-30 10:59:36 -0700
Engine revision ec975089ac
Tools Dart 2.19.6 DevTools 2.20.1
''';
late ShorebirdFlutterValidator validator;
late Directory tempDir;
late ProcessResult pathFlutterVersionProcessResult;
late ProcessResult shorebirdFlutterVersionProcessResult;
late ProcessResult gitBranchProcessResult;
late ProcessResult gitStatusProcessResult;
@@ -48,10 +63,13 @@ nothing to commit, working tree clean
setUp(() {
tempDir = setupTempDirectory();
ShorebirdPaths.platform = _MockPlatform();
when(() => ShorebirdPaths.platform.script)
ShorebirdEnvironment.platform = _MockPlatform();
when(() => ShorebirdEnvironment.platform.script)
.thenReturn(shorebirdScriptFile(tempDir).uri);
when(() => ShorebirdEnvironment.platform.environment).thenReturn({});
pathFlutterVersionProcessResult = _MockProcessResult();
shorebirdFlutterVersionProcessResult = _MockProcessResult();
gitBranchProcessResult = _MockProcessResult();
gitStatusProcessResult = _MockProcessResult();
@@ -60,7 +78,9 @@ nothing to commit, working tree clean
executable,
arguments, {
bool runInShell = false,
Map<String, String>? environment,
workingDirectory,
bool useVendedFlutter = true,
}) async {
if (executable == 'git') {
if (arguments.equals(['status'])) {
@@ -68,11 +88,23 @@ nothing to commit, working tree clean
} else if (arguments.equals(['--no-pager', 'branch'])) {
return gitBranchProcessResult;
}
} else if (executable == 'flutter') {
if (arguments.equals(['--version'])) {
if (useVendedFlutter) {
return shorebirdFlutterVersionProcessResult;
} else {
return pathFlutterVersionProcessResult;
}
}
}
return _MockProcessResult();
},
);
when(() => pathFlutterVersionProcessResult.stdout)
.thenReturn(pathFlutterVersionMessage);
when(() => shorebirdFlutterVersionProcessResult.stdout)
.thenReturn(shorebirdFlutterVersionMessage);
when(() => gitBranchProcessResult.stdout).thenReturn(gitBranchMessage);
when(() => gitStatusProcessResult.stdout).thenReturn(gitStatusMessage);
});
@@ -116,5 +148,53 @@ nothing to commit, working tree clean
expect(results.first.severity, ValidationIssueSeverity.warning);
expect(results.first.message, contains('is not on the "stable" branch'));
});
test(
'warns when path flutter version does not match shorebird flutter'
' version',
() async {
when(() => pathFlutterVersionProcessResult.stdout).thenReturn(
pathFlutterVersionMessage.replaceAll('3.7.9', '3.7.10'),
);
final results = await validator.validate();
expect(results, hasLength(1));
expect(results.first.severity, ValidationIssueSeverity.warning);
expect(
results.first.message,
contains('Shorebird Flutter and the Flutter on your path are'
' different versions'),
);
},
);
test(
'warns if FLUTTER_STORAGE_BASE_URL has a non-empty value',
() async {
when(() => ShorebirdEnvironment.platform.environment).thenReturn(
{'FLUTTER_STORAGE_BASE_URL': 'https://storage.flutter-io.cn'},
);
final results = await validator.validate();
expect(results, hasLength(1));
expect(results.first.severity, ValidationIssueSeverity.warning);
expect(
results.first.message,
contains(
'Shorebird does not respect the FLUTTER_STORAGE_BASE_URL '
'environment variable',
),
);
},
);
test('throws exception if flutter version output is malformed', () async {
when(() => pathFlutterVersionProcessResult.stdout)
.thenReturn('OH NO THERE IS NO FLUTTER VERSION HERE');
expect(() async => validator.validate(), throwsException);
});
});
}
@@ -3,8 +3,7 @@ import 'dart:io';
import 'package:mason_logger/mason_logger.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shorebird_cli/src/commands/doctor_command.dart';
import 'package:shorebird_cli/src/doctor/doctor_validator.dart';
import 'package:shorebird_cli/src/doctor/validators/shorebird_version_validator.dart';
import 'package:shorebird_cli/src/validators/validators.dart';
import 'package:test/test.dart';
class _MockLogger extends Mock implements Logger {}
@@ -33,7 +32,9 @@ void main() {
executable,
arguments, {
bool runInShell = false,
Map<String, String>? environment,
workingDirectory,
bool useVendedFlutter = true,
}) async {
if (executable == 'git') {
const revParseHead = ['rev-parse', '--verify', 'HEAD'];