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.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performSemanticsAction import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performImeAction import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.text.AnnotatedString import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import java.io.File import kotlinx.coroutines.runBlocking 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 val runtime = GitRepositoryRuntime(context) val level = initLevel() assertTrue(runtime.unavailableMessage(), runtime.isNativeGitAvailable()) val repo = runtime.prepareLevel(level) val (updatedRepo, _) = runtime.execute(level, repo, "git init") assertTrue(updatedRepo.initialized) assertTrue(level.validator(updatedRepo, "git init")) } @Test fun allLevelsCompleteThroughAppTerminalUi() { launchFreshApp().use { allGithugLevels().forEachIndexed { index, level -> waitForExercise(level) if (level.id == renameCommitLevel().id) { completeRenameCommitWithEditor() } else { level.testCases.first().commands.forEach(::submitTerminalCommand) } waitForLevelCompletion(nextLevel = allGithugLevels().getOrNull(index + 1)) } } } @Test fun renameCommitLevelCompletesThroughUiEditor() { launchFreshApp().use { selectLevel(renameCommitLevel()) completeRenameCommitWithEditor() waitForLevelCompletion(nextLevel = initLevel()) } } @Test fun renameCommitLevelCompletesThroughMessageOptionAfterReword() { launchFreshApp().use { selectLevel(renameCommitLevel()) startRenameCommitReword() composeRule.onNodeWithTag("git-message-editor-dismiss").performClick() submitTerminalCommand("git commit --amend -m \"First commit\"") submitTerminalCommand("git rebase --continue") waitForLevelCompletion(nextLevel = initLevel()) } } @Test fun squashLevelWaitsForFinalEditorBeforeCompleting() { launchFreshApp().use { selectLevel(squashLevel()) submitTerminalCommand("git rebase -i HEAD~4") waitForGitEditorPath(".git/rebase-merge/git-rebase-todo") replaceGitEditorContent(squashRebaseTodo(currentGitEditorContent())) composeRule.onNodeWithTag("git-message-editor-save").performClick() waitForGitMessageEditor() composeRule.onNodeWithTag("exercise-title-${squashLevel().id}").assertIsDisplayed() composeRule.onNodeWithTag("git-message-editor-save").performClick() waitForLevelCompletion(nextLevel = initLevel()) } } @Test fun configLevelCompletesThroughUiWithArbitraryValues() { launchFreshApp().use { submitTerminalCommand("git init") waitForExercise(configLevel()) submitTerminalCommand("git config user.name githug") submitTerminalCommand("git config user.email xxx@yy.com") waitForLevelCompletion(nextLevel = addLevel()) } } @Test fun visualFileEditorStartsWithActionsVisible() { launchFreshApp().use { submitTerminalCommand("edit notes.txt") waitForTextEditorActions() } } @Test fun selectingLevelFromLevelsPaneUpdatesExerciseImmediately() { launchFreshApp().use { composeRule.onNodeWithTag("level-submodule") .performScrollTo() .performClick() composeRule.waitUntil(timeoutMillis = 1_500) { composeRule.onAllNodes(hasText(submoduleLevel().title, substring = true)) .fetchSemanticsNodes() .isNotEmpty() } } } private fun launchFreshApp(): ActivityScenario { val context = InstrumentationRegistry.getInstrumentation().targetContext File(context.filesDir, "githug-sandboxes").deleteRecursively() File(context.applicationInfo.dataDir, "cache").deleteRecursively() runBlocking { GameProgressStore(context.applicationContext).saveProgress(emptySet(), activeLevelId = null) GameProgressStore(context.applicationContext).saveShowHelpOnStart(showHelpOnStart = true) } val scenario = ActivityScenario.launch(MainActivity::class.java) waitForExercise(initLevel()) return scenario } 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() } 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() } private fun selectLevel(level: Level) { composeRule.onNodeWithTag("level-${level.id}") .performScrollTo() .performClick() waitForExercise(level) composeRule.waitUntil(timeoutMillis = 30_000) { composeRule.onAllNodes(hasText("Loaded level: ${level.title}", substring = true)) .fetchSemanticsNodes() .isNotEmpty() } } private fun completeRenameCommitWithEditor() { startRenameCommitReword() waitForGitEditorPath(".git/COMMIT_EDITMSG") replaceGitEditorContent( currentGitEditorContent().replaceFirst("First coommit", "First commit"), ) composeRule.onNodeWithTag("git-message-editor-save").performClick() } private fun startRenameCommitReword() { submitTerminalCommand("git rebase -i HEAD~2") waitForGitEditorPath(".git/rebase-merge/git-rebase-todo") replaceGitEditorContent( currentGitEditorContent().replaceFirst(Regex("(?m)^pick "), "reword "), ) composeRule.onNodeWithTag("git-message-editor-save").performClick() } private fun waitForGitEditorPath(path: String) { composeRule.waitUntil(timeoutMillis = 30_000) { composeRule.onAllNodes(hasText(path, substring = true)).fetchSemanticsNodes().isNotEmpty() } composeRule.onNodeWithTag("git-message-editor-path").assertIsDisplayed() waitForGitEditorActions() } private fun waitForGitMessageEditor() { composeRule.waitUntil(timeoutMillis = 30_000) { composeRule.onAllNodesWithTag("git-message-editor-content").fetchSemanticsNodes().isNotEmpty() } composeRule.onNodeWithTag("git-message-editor-content").assertIsDisplayed() waitForGitEditorActions() } private fun waitForGitEditorActions() { composeRule.onNodeWithTag("git-message-editor-save").assertIsDisplayed() composeRule.onNodeWithTag("git-message-editor-dismiss").assertIsDisplayed() } private fun waitForTextEditorActions() { composeRule.waitUntil(timeoutMillis = 30_000) { composeRule.onAllNodesWithTag("text-editor-content").fetchSemanticsNodes().isNotEmpty() } composeRule.onNodeWithTag("text-editor-save").assertIsDisplayed() composeRule.onNodeWithTag("text-editor-dismiss").assertIsDisplayed() } private fun currentGitEditorContent(): String { val node = composeRule.onNodeWithTag("git-message-editor-content").fetchSemanticsNode() return node.config.getOrNull(SemanticsProperties.EditableText)?.text ?: error("Expected Git editor content semantics") } private fun squashRebaseTodo(content: String): String { return content .lineSequence() .mapIndexed { index, line -> if (index > 0 && line.startsWith("pick ")) line.replaceFirst("pick ", "squash ") else line } .joinToString("\n") } private fun replaceGitEditorContent(content: String) { composeRule.onNodeWithTag("git-message-editor-content") .performSemanticsAction(SemanticsActions.SetText) { setText -> setText(AnnotatedString(content)) } } private fun submitTerminalCommand(command: String) { val input = composeRule.onNodeWithTag("terminal-command-input") input.performClick() input.performTextClearance() input.performTextInput(command) input.performImeAction() } }