Drive all levels through UI instrumentation
This commit is contained in:
@@ -20,8 +20,8 @@ android {
|
||||
applicationId = "solutions.tretter.githugandroid"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 173
|
||||
versionName = "0.1.172"
|
||||
versionCode = 174
|
||||
versionName = "0.1.173"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
@@ -116,8 +116,10 @@ dependencies {
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test:core:1.6.1")
|
||||
androidTestImplementation("androidx.test:runner:1.6.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-test-manifest")
|
||||
|
||||
@@ -1,13 +1,27 @@
|
||||
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.platform.app.InstrumentationRegistry
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class GitRepositoryRuntimeInstrumentedTest {
|
||||
@get:Rule
|
||||
val composeRule = createEmptyComposeRule()
|
||||
|
||||
@Test
|
||||
fun nativeGitRuntimeCompletesInitLevelOnDevice() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
@@ -24,19 +38,50 @@ class GitRepositoryRuntimeInstrumentedTest {
|
||||
}
|
||||
|
||||
@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 runtime = GitRepositoryRuntime(context)
|
||||
val level = configLevel()
|
||||
File(context.applicationInfo.dataDir, "files").deleteRecursively()
|
||||
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)
|
||||
val (namedRepo, _) = runtime.execute(level, repo, "git config user.name GitHug")
|
||||
val (configuredRepo, _) = runtime.execute(level, namedRepo, "git config user.email githug@example.com")
|
||||
private fun waitForLevelCompletion(nextLevel: Level?) {
|
||||
if (nextLevel == null) {
|
||||
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())
|
||||
assertTrue(configuredRepo.config["user.email"].orEmpty().isNotBlank())
|
||||
assertTrue(level.validator(configuredRepo, "git config user.email githug@example.com"))
|
||||
private fun submitTerminalCommand(command: String) {
|
||||
val input = composeRule.onNodeWithTag("terminal-command-input")
|
||||
input.performClick()
|
||||
input.performTextClearance()
|
||||
input.performTextInput(command)
|
||||
input.performImeAction()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,6 @@ struct output_buffer {
|
||||
size_t capacity;
|
||||
};
|
||||
|
||||
struct reader_state {
|
||||
int fd;
|
||||
struct output_buffer output;
|
||||
};
|
||||
|
||||
static pthread_mutex_t git_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
static void *git_handle = 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;
|
||||
}
|
||||
|
||||
static void *read_output(void *arg) {
|
||||
struct reader_state *state = (struct reader_state *)arg;
|
||||
static int drain_available_output(int fd, struct output_buffer *output, int *saw_eof) {
|
||||
char chunk[4096];
|
||||
for (;;) {
|
||||
ssize_t count = read(state->fd, chunk, sizeof(chunk));
|
||||
ssize_t count = read(fd, chunk, sizeof(chunk));
|
||||
if (count > 0) {
|
||||
if (append_output(&state->output, chunk, (size_t)count) != 0) {
|
||||
break;
|
||||
if (append_output(output, chunk, (size_t)count) != 0) {
|
||||
return -1;
|
||||
}
|
||||
} else if (count == 0) {
|
||||
break;
|
||||
} else if (errno != EINTR) {
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
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) {
|
||||
@@ -267,12 +267,36 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
|
||||
}
|
||||
|
||||
close(pipe_fds[1]);
|
||||
struct reader_state reader = { .fd = pipe_fds[0], .output = {0} };
|
||||
read_output(&reader);
|
||||
struct output_buffer output = {0};
|
||||
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;
|
||||
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);
|
||||
|
||||
int exit_code = -1;
|
||||
@@ -282,9 +306,9 @@ Java_solutions_tretter_githugandroid_NativeGitBridge_runGitMainNative(
|
||||
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(env_entries, envc);
|
||||
(*env)->ReleaseStringUTFChars(env, library_path, library_path_chars);
|
||||
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -51,7 +52,12 @@ fun ExercisePane(
|
||||
}
|
||||
SelectionContainer {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
@@ -33,7 +34,9 @@ fun LevelsPane(
|
||||
) {
|
||||
TextButton(
|
||||
onClick = { onSelect(index) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag("level-${level.id}"),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = if (index == currentLevelIndex) Accent else TextSecondary,
|
||||
),
|
||||
@@ -49,4 +52,4 @@ fun LevelsPane(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -232,6 +233,7 @@ private fun RowScope.TerminalInputField(
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag("terminal-command-input")
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { state ->
|
||||
if (state.isFocused) {
|
||||
|
||||
Reference in New Issue
Block a user