diff --git a/packages/shorebird_cli/lib/src/commands/doctor_command.dart b/packages/shorebird_cli/lib/src/commands/doctor_command.dart index 2cf06b62..f1855bc3 100644 --- a/packages/shorebird_cli/lib/src/commands/doctor_command.dart +++ b/packages/shorebird_cli/lib/src/commands/doctor_command.dart @@ -73,6 +73,12 @@ Engine • revision ${shorebirdEnv.shorebirdEngineRevision}''', .join('${Platform.lineTerminator} '); } } + + String? gradlewVersion; + if (gradlew.exists(Directory.current.path)) { + gradlewVersion = await gradlew.version(Directory.current.path); + } + output.writeln(''' Logs: ${shorebirdEnv.logsDirectory.path} @@ -82,7 +88,8 @@ Android Toolchain • ADB: ${androidSdk.adbPath ?? notDetected} • JAVA_HOME: ${java.home ?? notDetected} • JAVA_EXECUTABLE: ${javaExe ?? notDetected} - • JAVA_VERSION: $javaVersion'''); + • JAVA_VERSION: $javaVersion + • Gradle: ${gradlewVersion ?? notDetected}'''); } logger.info(output.toString()); diff --git a/packages/shorebird_cli/lib/src/executables/gradlew.dart b/packages/shorebird_cli/lib/src/executables/gradlew.dart index 544dfabd..a871fba1 100644 --- a/packages/shorebird_cli/lib/src/executables/gradlew.dart +++ b/packages/shorebird_cli/lib/src/executables/gradlew.dart @@ -16,15 +16,15 @@ import 'package:shorebird_cli/src/shorebird_process.dart'; /// {@endtemplate} class MissingAndroidProjectException implements Exception { /// {@macro missing_android_project_exception} - const MissingAndroidProjectException(this.projectPath); + const MissingAndroidProjectException(this.projectRoot); /// Expected path for the Android project. - final String projectPath; + final String projectRoot; @override String toString() { return ''' -Could not find an android project in $projectPath. +Could not find an android project in $projectRoot. To add android, run "flutter create . --platforms android"'''; } } @@ -58,14 +58,12 @@ Gradlew get gradlew => read(gradlewRef); class Gradlew { String get executable => platform.isWindows ? 'gradlew.bat' : 'gradlew'; - /// Return the set of product flavors configured for the app at [projectPath]. - /// Returns an empty set for apps that do not use product flavors. - Future> productFlavors(String projectPath) async { + Future _run(List args, String projectRoot) { final javaHome = java.home; - final androidRoot = Directory(p.join(projectPath, 'android')); + final androidRoot = Directory(p.join(projectRoot, 'android')); if (!androidRoot.existsSync()) { - throw MissingAndroidProjectException(projectPath); + throw MissingAndroidProjectException(projectRoot); } final executableFile = File(p.join(androidRoot.path, executable)); @@ -75,15 +73,39 @@ class Gradlew { } final executablePath = executableFile.path; - final result = await process.run( + return process.run( executablePath, - ['app:tasks', '--all', '--console=auto'], + args, runInShell: true, workingDirectory: p.dirname(executablePath), environment: { if (!javaHome.isNullOrEmpty) 'JAVA_HOME': javaHome!, }, ); + } + + /// Returns whether the gradle wrapper exists at [projectRoot]. + bool exists(String projectRoot) => + File(p.join(projectRoot, 'android', executable)).existsSync(); + + /// Return the version of the gradle wrapper at [projectRoot]. + Future version(String projectRoot) async { + final result = await _run(['--version'], projectRoot); + + // Tries to match version string in the output (e.g. "Gradle 7.6.3") + final versionPattern = RegExp(r'Gradle (\d+\.\d+\.\d+)'); + final match = versionPattern.firstMatch(result.stdout.toString()); + + return match?.group(1) ?? 'unknown'; + } + + /// Return the set of product flavors configured for the app at [projectRoot]. + /// Returns an empty set for apps that do not use product flavors. + Future> productFlavors(String projectRoot) async { + final result = await _run( + ['app:tasks', '--all', '--console=auto'], + projectRoot, + ); if (result.exitCode != 0) { throw Exception('${result.stdout}\n${result.stderr}'); diff --git a/packages/shorebird_cli/test/src/commands/doctor_command_test.dart b/packages/shorebird_cli/test/src/commands/doctor_command_test.dart index cfa9afb8..f03614e7 100644 --- a/packages/shorebird_cli/test/src/commands/doctor_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/doctor_command_test.dart @@ -28,6 +28,7 @@ void main() { late AndroidSdk androidSdk; late Directory logsDirectory; late Doctor doctor; + late Gradlew gradlew; late Java java; late ShorebirdLogger logger; late ShorebirdEnv shorebirdEnv; @@ -42,6 +43,7 @@ void main() { androidStudioRef.overrideWith(() => androidStudio), androidSdkRef.overrideWith(() => androidSdk), doctorRef.overrideWith(() => doctor), + gradlewRef.overrideWith(() => gradlew), javaRef.overrideWith(() => java), loggerRef.overrideWith(() => logger), shorebirdEnvRef.overrideWith(() => shorebirdEnv), @@ -55,6 +57,7 @@ void main() { androidStudio = MockAndroidStudio(); androidSdk = MockAndroidSdk(); doctor = MockDoctor(); + gradlew = MockGradlew(); logsDirectory = Directory.systemTemp.createTempSync('shorebird_logs'); java = MockJava(); logger = MockShorebirdLogger(); @@ -67,6 +70,7 @@ void main() { when(() => androidStudio.path).thenReturn(null); when(() => androidSdk.path).thenReturn(null); when(() => androidSdk.adbPath).thenReturn(null); + when(() => gradlew.exists(any())).thenReturn(false); when(() => java.home).thenReturn(null); when( () => shorebirdEnv.shorebirdEngineRevision, @@ -142,6 +146,7 @@ Android Toolchain • JAVA_HOME: $notDetectedText • JAVA_EXECUTABLE: $notDetectedText • JAVA_VERSION: $notDetectedText + • Gradle: $notDetectedText '''), ); }); @@ -166,6 +171,7 @@ OpenJDK 64-Bit Server VM (build 17.0.9+0-17.0.9b1087.7-11185874, mixed mode)''' final msg = verify(() => logger.info(captureAny())).captured.first as String; + final notDetectedText = red.wrap('not detected'); expect( msg.replaceAll( Platform.lineTerminator, @@ -187,10 +193,65 @@ Android Toolchain • JAVA_VERSION: openjdk version "17.0.9" 2023-10-17 OpenJDK Runtime Environment (build 17.0.9+0-17.0.9b1087.7-11185874) OpenJDK 64-Bit Server VM (build 17.0.9+0-17.0.9b1087.7-11185874, mixed mode) + • Gradle: $notDetectedText ''', ), ); }); + + group('when a gradlew executable exists', () { + setUp(() { + when(() => gradlew.exists(any())).thenReturn(true); + when(() => gradlew.version(any())).thenAnswer((_) async => '7.6.3'); + when(() => argResults['verbose']).thenReturn(true); + when(() => androidStudio.path).thenReturn('test-studio-path'); + when(() => androidSdk.path).thenReturn('test-sdk-path'); + when(() => androidSdk.adbPath).thenReturn('test-adb-path'); + when(() => java.home).thenReturn('test-java-home'); + when(() => java.executable).thenReturn('test-java-executable'); + + when(() => java.version).thenReturn( + ''' +openjdk version "17.0.9" 2023-10-17 +OpenJDK Runtime Environment (build 17.0.9+0-17.0.9b1087.7-11185874) +OpenJDK 64-Bit Server VM (build 17.0.9+0-17.0.9b1087.7-11185874, mixed mode)''' + .replaceAll('\n', Platform.lineTerminator), + ); + }); + + test('prints the gradle version', () async { + await runWithOverrides(command.run); + + final msg = + verify(() => logger.info(captureAny())).captured.first as String; + + expect( + msg.replaceAll( + Platform.lineTerminator, + '\n', + ), + equals( + ''' +Shorebird $packageVersion • git@github.com:shorebirdtech/shorebird.git +Flutter • revision ${shorebirdEnv.flutterRevision} +Engine • revision $shorebirdEngineRevision + +Logs: ${logsDirectory.path} +Android Toolchain + • Android Studio: test-studio-path + • Android SDK: test-sdk-path + • ADB: test-adb-path + • JAVA_HOME: test-java-home + • JAVA_EXECUTABLE: test-java-executable + • JAVA_VERSION: openjdk version "17.0.9" 2023-10-17 + OpenJDK Runtime Environment (build 17.0.9+0-17.0.9b1087.7-11185874) + OpenJDK 64-Bit Server VM (build 17.0.9+0-17.0.9b1087.7-11185874, mixed mode) + • Gradle: 7.6.3 +''', + ), + ); + }); + }); }); test('runs validators without applying fixes if no fix flag exists', diff --git a/packages/shorebird_cli/test/src/executables/gradlew_test.dart b/packages/shorebird_cli/test/src/executables/gradlew_test.dart index c4ec0911..eb8aaf32 100644 --- a/packages/shorebird_cli/test/src/executables/gradlew_test.dart +++ b/packages/shorebird_cli/test/src/executables/gradlew_test.dart @@ -3,10 +3,8 @@ import 'dart:io' hide Platform; import 'package:mason_logger/mason_logger.dart'; import 'package:mocktail/mocktail.dart'; import 'package:path/path.dart' as p; -import 'package:platform/platform.dart'; import 'package:scoped_deps/scoped_deps.dart'; import 'package:shorebird_cli/src/executables/executables.dart'; -import 'package:shorebird_cli/src/platform.dart'; import 'package:shorebird_cli/src/shorebird_process.dart'; import 'package:test/test.dart'; @@ -17,7 +15,6 @@ void main() { const javaHome = 'test_java_home'; late Java java; - late Platform platform; late ShorebirdProcess process; late ShorebirdProcessResult result; late Gradlew gradlew; @@ -27,7 +24,6 @@ void main() { body, values: { javaRef.overrideWith(() => java), - platformRef.overrideWith(() => platform), processRef.overrideWith(() => process), }, ); @@ -35,7 +31,6 @@ void main() { setUp(() { java = MockJava(); - platform = MockPlatform(); process = MockShorebirdProcess(); result = MockProcessResult(); gradlew = runWithOverrides(Gradlew.new); @@ -76,206 +71,168 @@ Make sure you have run "flutter build apk" at least once.''', }); }); + Directory setUpAppTempDir() { + final tempDir = Directory.systemTemp.createTempSync(); + Directory(p.join(tempDir.path, 'android')).createSync(recursive: true); + return tempDir; + } + group('productFlavors', () { - Directory setUpAppTempDir() { - final tempDir = Directory.systemTemp.createTempSync(); - Directory(p.join(tempDir.path, 'android')).createSync(recursive: true); - return tempDir; - } - test( - 'throws MissingAndroidProjectException ' - 'when android root does not exist', () async { - when(() => platform.isLinux).thenReturn(true); - when(() => platform.isMacOS).thenReturn(false); - when(() => platform.isWindows).thenReturn(false); - final tempDir = Directory.systemTemp.createTempSync(); - await expectLater( - runWithOverrides(() => gradlew.productFlavors(tempDir.path)), - throwsA(isA()), - ); - verifyNever( - () => process.run( - p.join(tempDir.path, 'android', 'gradlew'), - ['app:tasks', '--all', '--console=auto'], - runInShell: true, - workingDirectory: p.join(tempDir.path, 'android'), - environment: {'JAVA_HOME': javaHome}, - ), - ); - }); - - test( - 'throws MissingGradleWrapperException ' - 'when gradlew does not exist', () async { - when(() => platform.isLinux).thenReturn(true); - when(() => platform.isMacOS).thenReturn(false); - when(() => platform.isWindows).thenReturn(false); - final tempDir = setUpAppTempDir(); - await expectLater( - runWithOverrides(() => gradlew.productFlavors(tempDir.path)), - throwsA(isA()), - ); - verifyNever( - () => process.run( - p.join(tempDir.path, 'android', 'gradlew'), - ['app:tasks', '--all', '--console=auto'], - runInShell: true, - workingDirectory: p.join(tempDir.path, 'android'), - environment: {'JAVA_HOME': javaHome}, - ), - ); - }); - - test('uses existing JAVA_HOME when set', () async { - when(() => platform.isLinux).thenReturn(true); - when(() => platform.isMacOS).thenReturn(false); - when(() => platform.isWindows).thenReturn(false); - final tempDir = setUpAppTempDir(); - File( - p.join(tempDir.path, 'android', 'gradlew'), - ).createSync(recursive: true); - await expectLater( - runWithOverrides(() => gradlew.productFlavors(tempDir.path)), - completes, - ); - verify( - () => process.run( - p.join(tempDir.path, 'android', 'gradlew'), - ['app:tasks', '--all', '--console=auto'], - runInShell: true, - workingDirectory: p.join(tempDir.path, 'android'), - environment: {'JAVA_HOME': javaHome}, - ), - ).called(1); - }); - - test( - 'throws Exception ' - 'when process exits with non-zero code', () async { - when(() => platform.isLinux).thenReturn(true); - when(() => platform.isMacOS).thenReturn(false); - when(() => platform.isWindows).thenReturn(false); - final tempDir = setUpAppTempDir(); - File( - p.join(tempDir.path, 'android', 'gradlew'), - ).createSync(recursive: true); - when(() => result.exitCode).thenReturn(1); - when(() => result.stderr).thenReturn('test error'); - await expectLater( - runWithOverrides(() => gradlew.productFlavors(tempDir.path)), - throwsA( - isA().having( - (e) => '$e', - 'message', - contains('test error'), + 'throws MissingAndroidProjectException ' + 'when android root does not exist', + () async { + final tempDir = Directory.systemTemp.createTempSync(); + await expectLater( + runWithOverrides(() => gradlew.productFlavors(tempDir.path)), + throwsA(isA()), + ); + verifyNever( + () => process.run( + p.join(tempDir.path, 'android', 'gradlew'), + ['app:tasks', '--all', '--console=auto'], + runInShell: true, + workingDirectory: p.join(tempDir.path, 'android'), + environment: {'JAVA_HOME': javaHome}, ), - ), - ); - verify( - () => process.run( - p.join(tempDir.path, 'android', 'gradlew'), - ['app:tasks', '--all', '--console=auto'], - runInShell: true, - workingDirectory: p.join(tempDir.path, 'android'), - environment: {'JAVA_HOME': javaHome}, - ), - ).called(1); - }); + ); + }, + testOn: 'linux', + ); - test('extracts flavors', () async { - when(() => platform.isLinux).thenReturn(true); - when(() => platform.isMacOS).thenReturn(false); - when(() => platform.isWindows).thenReturn(false); - final tempDir = setUpAppTempDir(); - File( - p.join(tempDir.path, 'android', 'gradlew'), - ).createSync(recursive: true); - const javaHome = 'test_java_home'; - when(() => platform.environment).thenReturn({'JAVA_HOME': javaHome}); - when(() => result.stdout).thenReturn( + test( + 'throws MissingGradleWrapperException ' + 'when gradlew does not exist', + () async { + final tempDir = setUpAppTempDir(); + await expectLater( + runWithOverrides(() => gradlew.productFlavors(tempDir.path)), + throwsA(isA()), + ); + verifyNever( + () => process.run( + p.join(tempDir.path, 'android', 'gradlew'), + ['app:tasks', '--all', '--console=auto'], + runInShell: true, + workingDirectory: p.join(tempDir.path, 'android'), + environment: {'JAVA_HOME': javaHome}, + ), + ); + }, + testOn: 'linux', + ); + + test( + 'uses existing JAVA_HOME when set', + () async { + final tempDir = setUpAppTempDir(); File( - p.join('test', 'fixtures', 'gradle', 'gradle_app_tasks.txt'), - ).readAsStringSync(), - ); - await expectLater( - runWithOverrides(() => gradlew.productFlavors(tempDir.path)), - completion( - equals({ - 'development', - 'developmentInternal', - 'staging', - 'stagingInternal', - 'production', - 'productionInternal', - }), - ), - ); - verify( - () => process.run( p.join(tempDir.path, 'android', 'gradlew'), - ['app:tasks', '--all', '--console=auto'], - runInShell: true, - workingDirectory: p.join(tempDir.path, 'android'), - environment: {'JAVA_HOME': javaHome}, - ), - ).called(1); - }); + ).createSync(recursive: true); + await expectLater( + runWithOverrides(() => gradlew.productFlavors(tempDir.path)), + completes, + ); + verify( + () => process.run( + p.join(tempDir.path, 'android', 'gradlew'), + ['app:tasks', '--all', '--console=auto'], + runInShell: true, + workingDirectory: p.join(tempDir.path, 'android'), + environment: {'JAVA_HOME': javaHome}, + ), + ).called(1); + }, + testOn: 'linux', + ); - group('when flavors are all upper case', () { - test('extracts flavors', () async { - when(() => platform.isLinux).thenReturn(true); - when(() => platform.isMacOS).thenReturn(false); - when(() => platform.isWindows).thenReturn(false); + test( + 'throws Exception ' + 'when process exits with non-zero code', + () async { + final tempDir = setUpAppTempDir(); + File( + p.join(tempDir.path, 'android', 'gradlew'), + ).createSync(recursive: true); + when(() => result.exitCode).thenReturn(1); + when(() => result.stderr).thenReturn('test error'); + await expectLater( + runWithOverrides(() => gradlew.productFlavors(tempDir.path)), + throwsA( + isA().having( + (e) => '$e', + 'message', + contains('test error'), + ), + ), + ); + verify( + () => process.run( + p.join(tempDir.path, 'android', 'gradlew'), + ['app:tasks', '--all', '--console=auto'], + runInShell: true, + workingDirectory: p.join(tempDir.path, 'android'), + environment: {'JAVA_HOME': javaHome}, + ), + ).called(1); + }, + testOn: 'linux', + ); + + test( + 'extracts flavors', + () async { final tempDir = setUpAppTempDir(); File( p.join(tempDir.path, 'android', 'gradlew'), ).createSync(recursive: true); const javaHome = 'test_java_home'; - when(() => platform.environment).thenReturn({'JAVA_HOME': javaHome}); when(() => result.stdout).thenReturn( File( - p.join( - 'test', - 'fixtures', - 'gradle', - 'gradle_app_tasks_upper_case_flavors.txt', - ), + p.join('test', 'fixtures', 'gradle', 'gradle_app_tasks.txt'), ).readAsStringSync(), ); await expectLater( runWithOverrides(() => gradlew.productFlavors(tempDir.path)), completion( equals({ - 'SP', - 'RJ', + 'development', + 'developmentInternal', + 'staging', + 'stagingInternal', + 'production', + 'productionInternal', }), ), ); - }); - }); + verify( + () => process.run( + p.join(tempDir.path, 'android', 'gradlew'), + ['app:tasks', '--all', '--console=auto'], + runInShell: true, + workingDirectory: p.join(tempDir.path, 'android'), + environment: {'JAVA_HOME': javaHome}, + ), + ).called(1); + }, + testOn: 'linux', + ); - group( - 'when flavors are mixed, starting with upper case, finishin with camel', - () { - test('extracts flavors', () async { - when(() => platform.isLinux).thenReturn(true); - when(() => platform.isMacOS).thenReturn(false); - when(() => platform.isWindows).thenReturn(false); + group('when flavors are all upper case', () { + test( + 'extracts flavors', + () async { final tempDir = setUpAppTempDir(); File( p.join(tempDir.path, 'android', 'gradlew'), ).createSync(recursive: true); - const javaHome = 'test_java_home'; - when(() => platform.environment) - .thenReturn({'JAVA_HOME': javaHome}); when(() => result.stdout).thenReturn( File( p.join( 'test', 'fixtures', 'gradle', - 'gradle_app_tasks_mixed_case_flavors.txt', + 'gradle_app_tasks_upper_case_flavors.txt', ), ).readAsStringSync(), ); @@ -283,51 +240,192 @@ Make sure you have run "flutter build apk" at least once.''', runWithOverrides(() => gradlew.productFlavors(tempDir.path)), completion( equals({ - 'SPaulo', - 'RJaneiro', + 'SP', + 'RJ', }), ), ); - }); + }, + testOn: 'linux', + ); + }); + + group( + 'when flavors are mixed, starting with upper case, finishin with camel', + () { + test( + 'extracts flavors', + () async { + final tempDir = setUpAppTempDir(); + File( + p.join(tempDir.path, 'android', 'gradlew'), + ).createSync(recursive: true); + when(() => result.stdout).thenReturn( + File( + p.join( + 'test', + 'fixtures', + 'gradle', + 'gradle_app_tasks_mixed_case_flavors.txt', + ), + ).readAsStringSync(), + ); + await expectLater( + runWithOverrides(() => gradlew.productFlavors(tempDir.path)), + completion( + equals({ + 'SPaulo', + 'RJaneiro', + }), + ), + ); + }, + testOn: 'linux', + ); }, ); group( 'when flavors starts with upper case, finishing with numbers', () { - test('extracts flavors', () async { - when(() => platform.isLinux).thenReturn(true); - when(() => platform.isMacOS).thenReturn(false); - when(() => platform.isWindows).thenReturn(false); - final tempDir = setUpAppTempDir(); - File( - p.join(tempDir.path, 'android', 'gradlew'), - ).createSync(recursive: true); - const javaHome = 'test_java_home'; - when(() => platform.environment) - .thenReturn({'JAVA_HOME': javaHome}); - when(() => result.stdout).thenReturn( + test( + 'extracts flavors', + () async { + final tempDir = setUpAppTempDir(); File( - p.join( - 'test', - 'fixtures', - 'gradle', - 'gradle_app_tasks_numbers_upper_case_flavors.txt', + p.join(tempDir.path, 'android', 'gradlew'), + ).createSync(recursive: true); + when(() => result.stdout).thenReturn( + File( + p.join( + 'test', + 'fixtures', + 'gradle', + 'gradle_app_tasks_numbers_upper_case_flavors.txt', + ), + ).readAsStringSync(), + ); + await expectLater( + runWithOverrides(() => gradlew.productFlavors(tempDir.path)), + completion( + equals({ + 'CB500', + 'NX700', + }), ), - ).readAsStringSync(), - ); - await expectLater( - runWithOverrides(() => gradlew.productFlavors(tempDir.path)), - completion( - equals({ - 'CB500', - 'NX700', - }), - ), - ); - }); + ); + }, + testOn: 'linux', + ); }, ); }); + + group('exists', () { + late Directory tempDir; + setUp(() { + tempDir = setUpAppTempDir(); + }); + + group('when gradlew does not exist', () { + test('returns false', () { + expect(gradlew.exists(tempDir.path), isFalse); + }); + }); + + group('when gradlew exists', () { + group( + 'when on unix based OSs', + () { + setUp(() { + File( + p.join(tempDir.path, 'android', 'gradlew'), + ).createSync(recursive: true); + }); + + test( + 'returns true', + () { + expect(gradlew.exists(tempDir.path), isTrue); + }, + testOn: 'linux || mac-os', + ); + }, + ); + + group( + 'when on windows', + () { + setUp(() { + File( + p.join(tempDir.path, 'android', 'gradlew.bat'), + ).createSync(recursive: true); + }); + + test( + 'returns true', + () { + expect(gradlew.exists(tempDir.path), isTrue); + }, + testOn: 'windows', + ); + }, + ); + }); + }); + + group('version', () { + late Directory tempDir; + + setUp(() { + tempDir = setUpAppTempDir(); + File( + p.join(tempDir.path, 'android', 'gradlew'), + ).createSync(recursive: true); + when(() => result.stdout).thenReturn(''' + +------------------------------------------------------------ +Gradle 7.6.3 +------------------------------------------------------------ + +Build time: 2023-10-04 15:59:47 UTC +Revision: 1694251d59e0d4752d547e1fd5b5020b798a7e71 + +Kotlin: 1.7.10 +Groovy: 3.0.13 +Ant: Apache Ant(TM) version 1.10.11 compiled on July 10 2021 +JVM: 11.0.23 (Azul Systems, Inc. 11.0.23+9-LTS) +OS: Mac OS X 14.4.1 aarch64 +'''); + }); + + test( + 'returns the correct version', + () async { + final version = await runWithOverrides( + () => gradlew.version(tempDir.path), + ); + expect(version, '7.6.3'); + }, + testOn: 'linux || mac-os', + ); + + group('when the output cannot be parsed', () { + setUp(() { + when(() => result.stdout).thenReturn('not a real version'); + }); + + test( + 'returns unknown', + () async { + final version = await runWithOverrides( + () => gradlew.version(tempDir.path), + ); + expect(version, 'unknown'); + }, + testOn: 'linux || mac-os', + ); + }); + }); }); }