diff --git a/packages/shorebird_cli/lib/src/commands/init_command.dart b/packages/shorebird_cli/lib/src/commands/init_command.dart index bec4fa12..c3cabe67 100644 --- a/packages/shorebird_cli/lib/src/commands/init_command.dart +++ b/packages/shorebird_cli/lib/src/commands/init_command.dart @@ -107,6 +107,28 @@ Please make sure you are running "shorebird init" from within your Flutter proje Set? macosFlavors; var productFlavors = {}; final projectRoot = shorebirdEnv.getFlutterProjectRoot()!; + final initializeGradleProgress = logger.progress('Initializing gradlew'); + final bool shouldStartGradleDaemon; + try { + shouldStartGradleDaemon = await _shouldStartGradleDaemon( + projectRoot.path, + ); + } on Exception { + initializeGradleProgress.fail(); + logger.err('Unable to initialize gradlew.'); + return ExitCode.software.code; + } + initializeGradleProgress.complete(); + + if (shouldStartGradleDaemon) { + try { + await gradlew.startDaemon(projectRoot.path); + } on Exception { + logger.err('Unable to start gradle daemon.'); + return ExitCode.software.code; + } + } + final detectFlavorsProgress = logger.progress('Detecting product flavors'); try { androidFlavors = await _maybeGetAndroidFlavors(projectRoot.path); @@ -287,6 +309,15 @@ For more information about Shorebird, visit ${link(uri: Uri.parse('https://shore return ExitCode.success.code; } + Future _shouldStartGradleDaemon(String projectPath) async { + try { + final isAvailable = await gradlew.isDaemonAvailable(projectPath); + return !isAvailable; + } on MissingAndroidProjectException { + return false; + } + } + Future?> _maybeGetAndroidFlavors(String projectPath) async { try { return await gradlew.productFlavors(projectPath); diff --git a/packages/shorebird_cli/lib/src/executables/gradlew.dart b/packages/shorebird_cli/lib/src/executables/gradlew.dart index 9d6c3fa5..5a67645f 100644 --- a/packages/shorebird_cli/lib/src/executables/gradlew.dart +++ b/packages/shorebird_cli/lib/src/executables/gradlew.dart @@ -120,6 +120,29 @@ class Gradlew { return result; } + Future _stream(List args, String projectRoot) async { + final javaHome = java.home; + final androidRoot = Directory(p.join(projectRoot, 'android')); + + if (!androidRoot.existsSync()) { + throw MissingAndroidProjectException(projectRoot); + } + + final executableFile = File(p.join(androidRoot.path, executable)); + + if (!executableFile.existsSync()) { + throw MissingGradleWrapperException(p.relative(executableFile.path)); + } + + final executablePath = executableFile.path; + return process.stream( + executablePath, + args, + 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(); @@ -136,6 +159,34 @@ class Gradlew { return match?.group(1) ?? 'unknown'; } + /// Whether the gradle daemon is available at [projectRoot]. + /// Command: `./gradlew --status` + Future isDaemonAvailable(String projectRoot) async { + // Sample output: + // PID STATUS INFO + // 30047 IDLE 8.11.1 + // 26397 STOPPED (after the daemon registry became unreadable) + // 23432 STOPPED (by user or operating system) + final status = await _run(['--status'], projectRoot); + if (status.exitCode != 0) { + throw Exception('Unable to determine gradle daemon status'); + } + + // If we have a daemon that is either IDLE or BUSY then subsequent + // gradle commands will be faster. + return status.stdout.toString().contains('IDLE') || + status.stdout.toString().contains('BUSY'); + } + + /// Starts the daemon if not running at [projectRoot]. + /// Command: `./gradlew --daemon` + Future startDaemon(String projectRoot) async { + final exitCode = await _stream(['--daemon'], projectRoot); + if (exitCode != 0) { + throw Exception('Unable to start gradle daemon'); + } + } + /// 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 { diff --git a/packages/shorebird_cli/test/src/commands/init_command_test.dart b/packages/shorebird_cli/test/src/commands/init_command_test.dart index 71ffb2bf..a26da306 100644 --- a/packages/shorebird_cli/test/src/commands/init_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/init_command_test.dart @@ -112,6 +112,9 @@ environment: () => doctor.runValidators(any(), applyFixes: any(named: 'applyFixes')), ).thenAnswer((_) async => {}); when(() => doctor.generalValidators).thenReturn([]); + when( + () => gradlew.isDaemonAvailable(any()), + ).thenAnswer((_) async => true); when( () => shorebirdEnv.getShorebirdYamlFile(cwd: any(named: 'cwd')), ).thenReturn(shorebirdYamlFile); @@ -251,6 +254,45 @@ Please make sure you are running "shorebird init" from within your Flutter proje ).called(1); }); + test('gracefully handles missing android project exceptions', () async { + when( + () => gradlew.isDaemonAvailable(any()), + ).thenThrow(const MissingAndroidProjectException('')); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.success.code); + verifyNever(() => gradlew.startDaemon(any())); + }); + + test('throws when unable to initialize gradle wrapper', () async { + when(() => gradlew.isDaemonAvailable(any())).thenThrow(Exception('oops')); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.software.code); + verifyNever(() => gradlew.startDaemon(any())); + verify(() => logger.err('Unable to initialize gradlew.')).called(1); + }); + + test('starts gradle daemon if needed and throws on error', () async { + when( + () => gradlew.isDaemonAvailable(any()), + ).thenAnswer((_) async => false); + when(() => gradlew.startDaemon(any())).thenThrow(Exception('oops')); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.software.code); + verify(() => gradlew.startDaemon(projectRoot.path)).called(1); + verify(() => logger.err('Unable to start gradle daemon.')).called(1); + }); + + test('starts gradle daemon if needed and streams logs', () async { + when( + () => gradlew.isDaemonAvailable(any()), + ).thenAnswer((_) async => false); + when(() => gradlew.startDaemon(any())).thenAnswer((_) async {}); + final exitCode = await runWithOverrides(command.run); + expect(exitCode, ExitCode.success.code); + verify(() => gradlew.startDaemon(projectRoot.path)).called(1); + verify(() => gradlew.isDaemonAvailable(projectRoot.path)).called(1); + }); + test('fails when an error occurs while extracting flavors', () async { final exception = Exception('oops'); when(() => gradlew.productFlavors(any())).thenThrow(exception); diff --git a/packages/shorebird_cli/test/src/executables/gradlew_test.dart b/packages/shorebird_cli/test/src/executables/gradlew_test.dart index 2d4aae65..b229671a 100644 --- a/packages/shorebird_cli/test/src/executables/gradlew_test.dart +++ b/packages/shorebird_cli/test/src/executables/gradlew_test.dart @@ -429,5 +429,162 @@ OS: Mac OS X 14.4.1 aarch64 }, testOn: 'linux || mac-os'); }); }); + + group('isDaemonAvailable', () { + late Directory projectRoot; + + setUp(() { + projectRoot = setUpAppTempDir(); + File( + p.join(projectRoot.path, 'android', 'gradlew'), + ).createSync(recursive: true); + }); + + test('returns true when status is IDLE', () async { + when(() => result.stdout).thenReturn(''' +PID STATUS INFO +30047 IDLE 8.11.1 +'''); + expect( + await runWithOverrides( + () => gradlew.isDaemonAvailable(projectRoot.path), + ), + isTrue, + ); + }); + + test('returns true when status is BUSY', () async { + when(() => result.stdout).thenReturn(''' +PID STATUS INFO +30047 BUSY 8.11.1 +'''); + expect( + await runWithOverrides( + () => gradlew.isDaemonAvailable(projectRoot.path), + ), + isTrue, + ); + }); + + test('returns false when status is STOPPED', () async { + when(() => result.stdout).thenReturn(''' +PID STATUS INFO +26397 STOPPED (after the daemon registry became unreadable) +'''); + expect( + await runWithOverrides( + () => gradlew.isDaemonAvailable(projectRoot.path), + ), + isFalse, + ); + }); + + group('when there are no daemons', () { + test('returns false when no daemons are running', () async { + when(() => result.stdout).thenReturn(''' +No daemons are running. +'''); + expect( + await runWithOverrides( + () => gradlew.isDaemonAvailable(projectRoot.path), + ), + isFalse, + ); + }); + }); + + test('throws when the process exits with non-zero exit code', () async { + when(() => result.exitCode).thenReturn(1); + await expectLater( + runWithOverrides(() => gradlew.isDaemonAvailable(projectRoot.path)), + throwsA(isA()), + ); + }); + }, testOn: 'linux || mac-os'); + + group('startDaemon', () { + late Directory projectRoot; + + setUp(() { + projectRoot = setUpAppTempDir(); + File( + p.join(projectRoot.path, 'android', 'gradlew'), + ).createSync(recursive: true); + }); + + test('throws MissingAndroidProjectException ' + 'when android root does not exist', () async { + final tempDir = Directory.systemTemp.createTempSync(); + await expectLater( + runWithOverrides(() => gradlew.startDaemon(tempDir.path)), + throwsA(isA()), + ); + verifyNever( + () => process.stream( + any(), + any(), + workingDirectory: any(named: 'workingDirectory'), + environment: any(named: 'environment'), + ), + ); + }); + + test('throws MissingGradleWrapperException ' + 'when gradlew does not exist', () async { + final tempDir = setUpAppTempDir(); + await expectLater( + runWithOverrides(() => gradlew.startDaemon(tempDir.path)), + throwsA(isA()), + ); + verifyNever( + () => process.stream( + any(), + any(), + workingDirectory: any(named: 'workingDirectory'), + environment: any(named: 'environment'), + ), + ); + }); + + test('throws when the process exits with non-zero exit code', () async { + final exitCode = ExitCode.software.code; + when( + () => process.stream( + any(), + any(), + workingDirectory: any(named: 'workingDirectory'), + environment: any(named: 'environment'), + ), + ).thenAnswer((_) async => exitCode); + await expectLater( + runWithOverrides(() => gradlew.startDaemon(projectRoot.path)), + throwsA(isA()), + ); + }); + + test('calls process.stream with correct args', () async { + final exitCode = ExitCode.success.code; + when( + () => process.stream( + any(), + any(), + workingDirectory: any(named: 'workingDirectory'), + environment: any(named: 'environment'), + ), + ).thenAnswer((_) async => exitCode); + await expectLater( + runWithOverrides(() => gradlew.startDaemon(projectRoot.path)), + completes, + ); + verify( + () => process.stream( + p.join(projectRoot.path, 'android', 'gradlew'), + ['--daemon'], + workingDirectory: p.join(projectRoot.path, 'android'), + environment: {'JAVA_HOME': javaHome}, + ), + ).called(1); + }); + }, testOn: 'linux || mac-os'); }); }