Drive all levels through UI instrumentation

This commit is contained in:
Joe Tretter
2026-06-24 19:44:09 -05:00
parent 1c15297ae6
commit 1e87bd9aa7
8 changed files with 171 additions and 44 deletions

View File

@@ -36,6 +36,7 @@ ANDROID_NDK_DIR="$SDK_DIR/ndk/27.2.12479018"
ANDROID_CMAKE_DIR="$SDK_DIR/cmake/3.22.1" ANDROID_CMAKE_DIR="$SDK_DIR/cmake/3.22.1"
ANDROID_TOOLBIN="$ANDROID_NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64/bin" ANDROID_TOOLBIN="$ANDROID_NDK_DIR/toolchains/llvm/prebuilt/linux-x86_64/bin"
STATE_TTL_SECONDS=86400 STATE_TTL_SECONDS=86400
HIDE_EMULATOR_WINDOW="false"
log() { log() {
printf '\n[%s] %s\n' "tooling" "$1" printf '\n[%s] %s\n' "tooling" "$1"
@@ -642,11 +643,16 @@ start_emulator_if_needed() {
fi fi
local emulator_log="$PROJECT_DIR/build/reports/android-emulator.log" local emulator_log="$PROJECT_DIR/build/reports/android-emulator.log"
local emulator_window_args=()
if [ "$HIDE_EMULATOR_WINDOW" = "true" ]; then
emulator_window_args=(-no-window)
fi
mkdir -p "$(dirname "$emulator_log")" mkdir -p "$(dirname "$emulator_log")"
log "Starting Android emulator: $ANDROID_TEST_AVD_NAME" log "Starting Android emulator: $ANDROID_TEST_AVD_NAME"
"$SDK_DIR/emulator/emulator" \ "$SDK_DIR/emulator/emulator" \
-avd "$ANDROID_TEST_AVD_NAME" \ -avd "$ANDROID_TEST_AVD_NAME" \
-no-window \ "${emulator_window_args[@]}" \
-no-audio \ -no-audio \
-no-boot-anim \ -no-boot-anim \
-gpu swiftshader_indirect \ -gpu swiftshader_indirect \
@@ -788,31 +794,54 @@ maybe_run_operation() {
print_usage() { print_usage() {
cat <<EOF_USAGE cat <<EOF_USAGE
Usage: bash ./AndroidProjectTooling.sh [--build | --build-release-aab | --test | --clean-test | --test-emulator | --compile-git] Usage: bash ./AndroidProjectTooling.sh [--build | --build-release-aab | --test | --clean-test | --test-emulator [--hide-emulator-window] | --compile-git]
--build Set up the environment and build the debug APK --build Set up the environment and build the debug APK
--build-release-aab Set up the environment and build a release Android App Bundle (AAB) --build-release-aab Set up the environment and build a release Android App Bundle (AAB)
--test Set up the environment, compile host Git, and run the debug JVM unit tests with it --test Set up the environment, compile host Git, and run the debug JVM unit tests with it
--clean-test Set up the environment, clean Gradle outputs, and run the debug JVM unit tests --clean-test Set up the environment, clean Gradle outputs, and run the debug JVM unit tests
--test-emulator Set up an Android emulator and run debug instrumentation tests on it --test-emulator Set up a visible Android emulator and run debug instrumentation tests on it
--hide-emulator-window Run --test-emulator with the emulator window hidden
--compile-git Compile Git for the development host and all Android target ABIs --compile-git Compile Git for the development host and all Android target ABIs
EOF_USAGE EOF_USAGE
} }
validate_args() { validate_args() {
case "${1:-}" in local mode="${1:-}"
shift || true
case "$mode" in
""|--build|--build-release-aab|--test|--clean-test|--test-emulator|--compile-git) ""|--build|--build-release-aab|--test|--clean-test|--test-emulator|--compile-git)
;; ;;
*)
echo "Unknown argument: $mode" >&2
print_usage >&2
exit 1
;;
esac
while [ "$#" -gt 0 ]; do
case "$1" in
--hide-emulator-window)
if [ "$mode" != "--test-emulator" ]; then
echo "--hide-emulator-window can only be used with --test-emulator" >&2
print_usage >&2
exit 1
fi
HIDE_EMULATOR_WINDOW="true"
;;
*) *)
echo "Unknown argument: $1" >&2 echo "Unknown argument: $1" >&2
print_usage >&2 print_usage >&2
exit 1 exit 1
;; ;;
esac esac
shift
done
} }
main() { main() {
validate_args "${1:-}" validate_args "$@"
require_tool curl require_tool curl
require_tool unzip require_tool unzip

View File

@@ -48,7 +48,8 @@ Available commands:
| `bash ./AndroidProjectTooling.sh` | Provision or refresh the local Android/JDK toolchain only. | Toolchain under `./jdk` and `./android-sdk` | | `bash ./AndroidProjectTooling.sh` | Provision or refresh the local Android/JDK toolchain only. | Toolchain under `./jdk` and `./android-sdk` |
| `bash ./AndroidProjectTooling.sh --test` | Compile host Git, set `GITHUG_TEST_GIT_BINARY`, and run JVM unit tests. | Test reports under `app/build/reports/` | | `bash ./AndroidProjectTooling.sh --test` | Compile host Git, set `GITHUG_TEST_GIT_BINARY`, and run JVM unit tests. | Test reports under `app/build/reports/` |
| `bash ./AndroidProjectTooling.sh --clean-test` | Compile host Git, clean Gradle outputs, set `GITHUG_TEST_GIT_BINARY`, and run JVM unit tests. | Fresh test reports under `app/build/reports/` | | `bash ./AndroidProjectTooling.sh --clean-test` | Compile host Git, clean Gradle outputs, set `GITHUG_TEST_GIT_BINARY`, and run JVM unit tests. | Fresh test reports under `app/build/reports/` |
| `bash ./AndroidProjectTooling.sh --test-emulator` | Install emulator packages if needed, create/start the project test AVD, compile Android Git, and run debug instrumentation tests. | Instrumentation reports under `app/build/reports/androidTests/` | | `bash ./AndroidProjectTooling.sh --test-emulator` | Install emulator packages if needed, create/start the visible project test AVD, compile Android Git, and run debug instrumentation tests. | Instrumentation reports under `app/build/reports/androidTests/` |
| `bash ./AndroidProjectTooling.sh --test-emulator --hide-emulator-window` | Run the same emulator instrumentation tests without showing the emulator window. | Instrumentation reports under `app/build/reports/androidTests/` |
| `bash ./AndroidProjectTooling.sh --build` | Build the debug APK. | `app/build/outputs/apk/debug/githug-android-debug-v<versionCode>.apk` | | `bash ./AndroidProjectTooling.sh --build` | Build the debug APK. | `app/build/outputs/apk/debug/githug-android-debug-v<versionCode>.apk` |
| `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v<versionCode>.aab` | | `bash ./AndroidProjectTooling.sh --build-release-aab` | Build the release Android App Bundle. | `app/build/outputs/bundle/release/githug-android-release-v<versionCode>.aab` |
| `bash ./AndroidProjectTooling.sh --compile-git` | Compile Git for the development host and all Android target ABIs. | Host and Android `libgit.so` binaries | | `bash ./AndroidProjectTooling.sh --compile-git` | Compile Git for the development host and all Android target ABIs. | Host and Android `libgit.so` binaries |
@@ -65,6 +66,21 @@ To run instrumentation tests on an Android emulator:
bash ./AndroidProjectTooling.sh --test-emulator bash ./AndroidProjectTooling.sh --test-emulator
``` ```
The emulator window is shown by default so UI instrumentation can be followed visually. Add `--hide-emulator-window` for headless runs:
```bash
bash ./AndroidProjectTooling.sh --test-emulator --hide-emulator-window
```
Test outputs and logs are written under project-local build directories:
- JVM test HTML report: `app/build/reports/tests/testDebugUnitTest/index.html`
- JVM test XML results: `app/build/test-results/testDebugUnitTest/`
- Emulator instrumentation HTML report: `app/build/reports/androidTests/connected/debug/index.html`
- Emulator instrumentation XML/results: `app/build/outputs/androidTest-results/connected/debug/`
- Per-test emulator logcat files: `app/build/outputs/androidTest-results/connected/debug/<device-name>/logcat-*.txt`
- Emulator startup log: `build/reports/android-emulator.log`
To build installable/debuggable artifacts: To build installable/debuggable artifacts:
```bash ```bash
@@ -114,7 +130,7 @@ Options:
| --- | --- | --- | | --- | --- | --- |
| `bash ./AndroidProjectTooling.sh --test` | Ensure the host Git binary is current, then run tests with it. | `build/host-git/libgit.so` and test reports | | `bash ./AndroidProjectTooling.sh --test` | Ensure the host Git binary is current, then run tests with it. | `build/host-git/libgit.so` and test reports |
| `bash ./AndroidProjectTooling.sh --clean-test` | Ensure the host Git binary is current, clean Gradle outputs, then run tests with it. | `build/host-git/libgit.so` and fresh test reports | | `bash ./AndroidProjectTooling.sh --clean-test` | Ensure the host Git binary is current, clean Gradle outputs, then run tests with it. | `build/host-git/libgit.so` and fresh test reports |
| `bash ./AndroidProjectTooling.sh --test-emulator` | Ensure Android ABI Git binaries are current, then run instrumentation tests on the project AVD. | Android `libgit.so` binaries and instrumentation reports | | `bash ./AndroidProjectTooling.sh --test-emulator` | Ensure Android ABI Git binaries are current, then run instrumentation tests on the visible project AVD. | Android `libgit.so` binaries and instrumentation reports |
| `bash ./AndroidProjectTooling.sh --compile-git` | Ensure host Git and Android ABI Git binaries are current. | Host and Android outputs | | `bash ./AndroidProjectTooling.sh --compile-git` | Ensure host Git and Android ABI Git binaries are current. | Host and Android outputs |
Android ABI outputs are generated files and are ignored by git: Android ABI outputs are generated files and are ignored by git:

View File

@@ -20,8 +20,8 @@ android {
applicationId = "solutions.tretter.githugandroid" applicationId = "solutions.tretter.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 173 versionCode = 174
versionName = "0.1.172" versionName = "0.1.173"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
@@ -116,8 +116,10 @@ dependencies {
implementation("com.google.android.material:material:1.12.0") implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test:core:1.6.1")
androidTestImplementation("androidx.test:runner:1.6.1") androidTestImplementation("androidx.test:runner:1.6.1")
androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")

View File

@@ -1,13 +1,27 @@
package solutions.tretter.githugandroid package solutions.tretter.githugandroid
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performImeAction
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class GitRepositoryRuntimeInstrumentedTest { class GitRepositoryRuntimeInstrumentedTest {
@get:Rule
val composeRule = createEmptyComposeRule()
@Test @Test
fun nativeGitRuntimeCompletesInitLevelOnDevice() { fun nativeGitRuntimeCompletesInitLevelOnDevice() {
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
@@ -24,19 +38,50 @@ class GitRepositoryRuntimeInstrumentedTest {
} }
@Test @Test
fun nativeGitRuntimeCompletesConfigLevelOnDevice() { fun allLevelsCompleteThroughAppTerminalUi() {
launchFreshApp().use {
allGithugLevels().forEachIndexed { index, level ->
waitForExercise(level)
level.testCases.first().commands.forEach(::submitTerminalCommand)
waitForLevelCompletion(nextLevel = allGithugLevels().getOrNull(index + 1))
}
}
}
private fun launchFreshApp(): ActivityScenario<MainActivity> {
val context = InstrumentationRegistry.getInstrumentation().targetContext val context = InstrumentationRegistry.getInstrumentation().targetContext
val runtime = GitRepositoryRuntime(context) File(context.applicationInfo.dataDir, "files").deleteRecursively()
val level = configLevel() File(context.applicationInfo.dataDir, "cache").deleteRecursively()
val scenario = ActivityScenario.launch(MainActivity::class.java)
waitForExercise(initLevel())
return scenario
}
assertTrue(runtime.unavailableMessage(), runtime.isNativeGitAvailable()) private fun waitForExercise(level: Level) {
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText(level.title, substring = true)).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("exercise-title-${level.id}").assertIsDisplayed()
}
val repo = runtime.prepareLevel(level) private fun waitForLevelCompletion(nextLevel: Level?) {
val (namedRepo, _) = runtime.execute(level, repo, "git config user.name GitHug") if (nextLevel == null) {
val (configuredRepo, _) = runtime.execute(level, namedRepo, "git config user.email githug@example.com") composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText("All Githug levels completed", substring = true)).fetchSemanticsNodes().isNotEmpty()
}
return
}
composeRule.waitUntil(timeoutMillis = 30_000) {
composeRule.onAllNodes(hasText(nextLevel.title, substring = true)).fetchSemanticsNodes().isNotEmpty()
}
composeRule.onNodeWithTag("exercise-title-${nextLevel.id}").assertIsDisplayed()
}
assertTrue(configuredRepo.config["user.name"].orEmpty().isNotBlank()) private fun submitTerminalCommand(command: String) {
assertTrue(configuredRepo.config["user.email"].orEmpty().isNotBlank()) val input = composeRule.onNodeWithTag("terminal-command-input")
assertTrue(level.validator(configuredRepo, "git config user.email githug@example.com")) input.performClick()
input.performTextClearance()
input.performTextInput(command)
input.performImeAction()
} }
} }

View File

@@ -21,11 +21,6 @@ struct output_buffer {
size_t capacity; size_t capacity;
}; };
struct reader_state {
int fd;
struct output_buffer output;
};
static pthread_mutex_t git_mutex = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t git_mutex = PTHREAD_MUTEX_INITIALIZER;
static void *git_handle = NULL; static void *git_handle = NULL;
static git_main_fn git_main = NULL; static git_main_fn git_main = NULL;
@@ -54,23 +49,28 @@ static int append_output(struct output_buffer *buffer, const char *data, size_t
return 0; return 0;
} }
static void *read_output(void *arg) { static int drain_available_output(int fd, struct output_buffer *output, int *saw_eof) {
struct reader_state *state = (struct reader_state *)arg;
char chunk[4096]; char chunk[4096];
for (;;) { for (;;) {
ssize_t count = read(state->fd, chunk, sizeof(chunk)); ssize_t count = read(fd, chunk, sizeof(chunk));
if (count > 0) { if (count > 0) {
if (append_output(&state->output, chunk, (size_t)count) != 0) { if (append_output(output, chunk, (size_t)count) != 0) {
break; return -1;
} }
} else if (count == 0) { continue;
break;
} else if (errno != EINTR) {
break;
} }
if (count == 0) {
*saw_eof = 1;
return 0;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
return 0;
}
return -1;
} }
close(state->fd);
return NULL;
} }
static jobjectArray make_result(JNIEnv *env, int exit_code, const char *output) { static jobjectArray make_result(JNIEnv *env, int exit_code, const char *output) {
@@ -267,12 +267,36 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
} }
close(pipe_fds[1]); close(pipe_fds[1]);
struct reader_state reader = { .fd = pipe_fds[0], .output = {0} }; struct output_buffer output = {0};
read_output(&reader); int flags = fcntl(pipe_fds[0], F_GETFL, 0);
if (flags >= 0) {
fcntl(pipe_fds[0], F_SETFL, flags | O_NONBLOCK);
}
int child_status = 0; int child_status = 0;
while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) { int child_done = 0;
int saw_eof = 0;
while (!child_done || !saw_eof) {
if (drain_available_output(pipe_fds[0], &output, &saw_eof) != 0) {
break;
} }
if (!child_done) {
pid_t wait_result = waitpid(child, &child_status, WNOHANG);
if (wait_result == child) {
child_done = 1;
} else if (wait_result < 0 && errno != EINTR) {
child_done = 1;
}
}
if (child_done) {
if (!saw_eof) {
drain_available_output(pipe_fds[0], &output, &saw_eof);
}
break;
}
usleep(10000);
}
close(pipe_fds[0]);
pthread_mutex_unlock(&git_mutex); pthread_mutex_unlock(&git_mutex);
int exit_code = -1; int exit_code = -1;
@@ -282,9 +306,9 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
exit_code = 128 + WTERMSIG(child_status); exit_code = 128 + WTERMSIG(child_status);
} }
jobjectArray result = make_result(env, exit_code, reader.output.data); jobjectArray result = make_result(env, exit_code, output.data);
free(reader.output.data); free(output.data);
free_string_array(argv, argc); free_string_array(argv, argc);
free_string_array(env_entries, envc); free_string_array(env_entries, envc);
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars); (*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);

View File

@@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -51,7 +52,12 @@ fun ExercisePane(
} }
SelectionContainer { SelectionContainer {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary) Text(
text = level.title,
modifier = Modifier.testTag("exercise-title-${level.id}"),
style = MaterialTheme.typography.titleLarge,
color = TextPrimary,
)
Text(level.description, color = TextSecondary) Text(level.description, color = TextSecondary)
} }
} }

View File

@@ -11,6 +11,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@Composable @Composable
@@ -33,7 +34,9 @@ fun LevelsPane(
) { ) {
TextButton( TextButton(
onClick = { onSelect(index) }, onClick = { onSelect(index) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.testTag("level-${level.id}"),
colors = ButtonDefaults.textButtonColors( colors = ButtonDefaults.textButtonColors(
contentColor = if (index == currentLevelIndex) Accent else TextSecondary, contentColor = if (index == currentLevelIndex) Accent else TextSecondary,
), ),

View File

@@ -45,6 +45,7 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -232,6 +233,7 @@ private fun RowScope.TerminalInputField(
onValueChange = onValueChange, onValueChange = onValueChange,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.testTag("terminal-command-input")
.focusRequester(focusRequester) .focusRequester(focusRequester)
.onFocusChanged { state -> .onFocusChanged { state ->
if (state.isFocused) { if (state.isFocused) {