feat(shorebird_cli): improve gradlew logs during shorebird init (#2991)

This commit is contained in:
Felix Angelov
2025-03-18 17:08:16 -05:00
committed by GitHub
parent 00f9bc94d3
commit 77012b1761
4 changed files with 281 additions and 0 deletions
@@ -107,6 +107,28 @@ Please make sure you are running "shorebird init" from within your Flutter proje
Set<String>? macosFlavors;
var productFlavors = <String>{};
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<bool> _shouldStartGradleDaemon(String projectPath) async {
try {
final isAvailable = await gradlew.isDaemonAvailable(projectPath);
return !isAvailable;
} on MissingAndroidProjectException {
return false;
}
}
Future<Set<String>?> _maybeGetAndroidFlavors(String projectPath) async {
try {
return await gradlew.productFlavors(projectPath);
@@ -120,6 +120,29 @@ class Gradlew {
return result;
}
Future<int> _stream(List<String> 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<bool> 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<void> 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<Set<String>> productFlavors(String projectRoot) async {
@@ -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);
@@ -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<Exception>()),
);
});
}, 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<MissingAndroidProjectException>()),
);
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<MissingGradleWrapperException>()),
);
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<Exception>()),
);
});
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');
});
}