Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package com.itsaky.androidide

import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiSelector
import com.itsaky.androidide.activities.SplashActivity
import com.itsaky.androidide.helper.advancePastWelcomeScreen
import com.itsaky.androidide.helper.clickFirstAccessibilityNodeByText
import com.itsaky.androidide.helper.ensureOnHomeScreenBeforeCreateProject
import com.itsaky.androidide.helper.grantAllRequiredPermissionsThroughOnboardingUi
import com.itsaky.androidide.helper.initializeProjectRunAssembleTasksAndCancelBuild
import com.itsaky.androidide.helper.selectProjectTemplate
import com.itsaky.androidide.helper.waitForMainHomeOrEditorUi
import com.itsaky.androidide.resources.R as ResourcesR
import com.itsaky.androidide.screens.HomeScreen.clickCreateProjectHomeScreen
import com.itsaky.androidide.screens.OnboardingScreen
import com.itsaky.androidide.screens.ProjectSettingsScreen.clickCreateProjectProjectSettings
import com.itsaky.androidide.screens.ProjectSettingsScreen.selectKotlinLanguage
import com.itsaky.androidide.screens.ProjectSettingsScreen.setProjectName
import com.itsaky.androidide.screens.PermissionScreen
import com.itsaky.androidide.utils.PermissionsHelper
import com.kaspersky.kaspresso.testcases.api.testcase.TestCase
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

private const val LAUNCH_SETTLE_DELAY_MS = 1_000L
private const val PRIVACY_DIALOG_TIMEOUT_MS = 2_000L
private const val PERMISSIONS_ASSERTION_TIMEOUT_MS = 3_000L
private const val KOTLIN_LANGUAGE_TEMPLATE_COUNT = 7

/**
* Single continuous E2E test that drives the app from first launch through
* onboarding, project creation, builds, and beyond.
*
* The activity launches once and stays alive. Each stage is a Kaspresso
* `step()` so failures report exactly which stage broke.
*/
@RunWith(AndroidJUnit4::class)
class AutomationEndToEndTest : TestCase() {

private val targetContext
get() = InstrumentationRegistry.getInstrumentation().targetContext

private val acceptText: String
get() = targetContext.getString(ResourcesR.string.privacy_disclosure_accept)

private val learnMoreText: String
get() = targetContext.getString(ResourcesR.string.privacy_disclosure_learn_more)

private val dialogTitle: String
get() = targetContext.getString(ResourcesR.string.privacy_disclosure_title)

@Test
fun test_endToEnd() = run {

// ── Launch ──

step("Launch app") {
ActivityScenario.launch(SplashActivity::class.java)
Thread.sleep(LAUNCH_SETTLE_DELAY_MS)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// ── Welcome Screen ──

step("Verify welcome screen") {
OnboardingScreen {
greetingTitle.isVisible()
greetingSubtitle.isVisible()
nextButton {
isVisible()
isClickable()
}
}
}

advancePastWelcomeScreen()

// ── Permissions Screen (with privacy disclosure dialog overlay) ──

step("Verify privacy disclosure dialog") {
val d = device.uiDevice
val title = d.findObject(UiSelector().text(dialogTitle))
assertTrue("Dialog title missing", title.waitForExists(PRIVACY_DIALOG_TIMEOUT_MS))
assertTrue("Accept button missing", d.findObject(UiSelector().text(acceptText)).exists())
assertTrue("Learn more button missing", d.findObject(UiSelector().text(learnMoreText)).exists())
}

step("Accept privacy disclosure") {
clickFirstAccessibilityNodeByText(acceptText)
device.uiDevice.waitForIdle()
}

step("Verify privacy dialog does not reappear") {
assertFalse(
"Dialog should not reappear",
device.uiDevice.findObject(UiSelector().text(dialogTitle)).exists(),
)
}

val required = PermissionsHelper.getRequiredPermissions(targetContext)

step("Verify all permission items") {
flakySafely(timeoutMs = PERMISSIONS_ASSERTION_TIMEOUT_MS) {
PermissionScreen {
title { isVisible() }
subTitle { isVisible() }
rvPermissions {
isVisible()
isDisplayed()
}
assertEquals(required.size, rvPermissions.getSize())

rvPermissions {
required.forEachIndexed { index, item ->
childAt<PermissionScreen.PermissionItem>(index) {
title {
isVisible()
hasText(item.title)
}
description {
isVisible()
hasText(item.description)
}
grantButton {
isVisible()
isClickable()
hasText(R.string.title_grant)
}
}
}
}
}
}
}

grantAllRequiredPermissionsThroughOnboardingUi()

step("Confirm all permissions granted") {
flakySafely(timeoutMs = PERMISSIONS_ASSERTION_TIMEOUT_MS) {
assertTrue(PermissionsHelper.areAllPermissionsGranted(targetContext))
}
}

step("Confirm all grant buttons disabled") {
device.uiDevice.waitForIdle()
PermissionScreen {
rvPermissions {
required.indices.forEach { index ->
childAt<PermissionScreen.PermissionItem>(index) {
grantButton {
isNotEnabled()
}
}
}
}
}
}

step("Tap Finish installation") {
// The button is in the gesture exclusion zone — use accessibility click
clickFirstAccessibilityNodeByText("Finish installation")
}

step("Wait for IDE setup to complete") {
waitForMainHomeOrEditorUi(device.uiDevice)
}

// ── Phase 2: Project creation + build across default and Kotlin template variants ──

ensureOnHomeScreenBeforeCreateProject()

data class TemplateConfig(
val label: String,
val templateResId: Int,
val projectName: String,
val visibleLabelOverride: String? = null,
val useKotlinLanguage: Boolean = false,
)

val defaultLanguageTemplates = listOf(
TemplateConfig("No Activity", R.string.template_no_activity, "TestNoActivity"),
TemplateConfig("Empty Activity", R.string.template_empty, "TestEmptyActivity"),
TemplateConfig("Basic Activity", R.string.template_basic, "TestBasicActivity"),
TemplateConfig(
"Navigation Drawer",
R.string.template_navigation_drawer,
"TestNavigationDrawer",
visibleLabelOverride = "Navigation Drawer",
),
TemplateConfig("Bottom Nav Activity", R.string.template_navigation_tabs, "TestBottomNavActivity"),
TemplateConfig(
"No AndroidX",
R.string.template_no_AndroidX,
"TestNoAndroidX",
visibleLabelOverride = "No AndroidX",
),
TemplateConfig("Tabbed Activity", R.string.template_tabs, "TestTabbedActivity"),
TemplateConfig("Compose Activity", R.string.template_compose, "TestComposeActivity"),
)

val kotlinLanguageTemplates = defaultLanguageTemplates.take(KOTLIN_LANGUAGE_TEMPLATE_COUNT).map { config ->
config.copy(
label = "Kotlin ${config.label}",
projectName = "Kt${config.projectName}",
useKotlinLanguage = true,
)
}

val templates = defaultLanguageTemplates + kotlinLanguageTemplates

for ((index, config) in templates.withIndex()) {
step("Create+build template ${index + 1}/${templates.size}: ${config.label}") {
clickCreateProjectHomeScreen()
}
selectProjectTemplate(
"Select ${config.label} template",
config.templateResId,
config.visibleLabelOverride,
)
if (config.useKotlinLanguage) {
selectKotlinLanguage()
}
setProjectName(config.projectName)
clickCreateProjectProjectSettings()
initializeProjectRunAssembleTasksAndCancelBuild()

if (index < templates.lastIndex) {
ensureOnHomeScreenBeforeCreateProject()
}
}

// ── Future phases (preferences, more templates, etc.) go here ──
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.itsaky.androidide

import org.junit.runner.RunWith
import org.junit.runners.Suite

@RunWith(Suite::class)
@Suite.SuiteClasses(
CleanupTest::class,
AutomationEndToEndTest::class,
ExportCacheDirectoryTest::class,
)
class AutomationTestSuite
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package com.itsaky.androidide

import android.os.Environment
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assume.assumeTrue
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File

@RunWith(AndroidJUnit4::class)
class ExportCacheDirectoryTest {

@Test
fun exportGradleModuleCacheBeforeConnectedTestCleanup() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val args = InstrumentationRegistry.getArguments()

val destinationRelativePath =
args.getString(ARG_DESTINATION_RELATIVE_PATH) ?: DEFAULT_DESTINATION_RELATIVE_PATH

val source = File(context.filesDir, SOURCE_RELATIVE_PATH)
val externalBase = File(Environment.getExternalStorageDirectory(), EXPORT_BASE_DIRECTORY).canonicalFile
val destination = resolveSafeDestination(externalBase, destinationRelativePath)

assumeTrue("Source does not exist: ${source.absolutePath}", source.exists())
assumeTrue("Source is not a directory: ${source.absolutePath}", source.isDirectory)

destination.deleteRecursively()
destination.parentFile?.mkdirs()

assertTrue(
"Failed to copy ${source.absolutePath} to ${destination.absolutePath}",
source.copyRecursively(target = destination, overwrite = true),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
assertTrue("Destination does not exist: ${destination.absolutePath}", destination.exists())
}

@Test
fun exportGeneratedGradleApiJarWhenRequested() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val args = InstrumentationRegistry.getArguments()
val version = args.getString(ARG_GRADLE_API_VERSION)

assumeTrue("No Gradle API version requested", !version.isNullOrBlank())
require(!version!!.contains(File.separatorChar)) {
"Gradle API version must not contain path separators: $version"
}

val destinationRelativePath =
args.getString(ARG_GRADLE_API_DESTINATION_RELATIVE_PATH)
?: DEFAULT_GRADLE_API_DESTINATION_RELATIVE_PATH

val source =
File(
context.filesDir,
"$GRADLE_CACHE_RELATIVE_PATH/$version/generated-gradle-jars/gradle-api-$version.jar",
)
val externalBase = File(Environment.getExternalStorageDirectory(), EXPORT_BASE_DIRECTORY).canonicalFile
val destinationDir = resolveSafeDestination(externalBase, destinationRelativePath)
val destination = File(destinationDir, source.name)

assertTrue("Source does not exist: ${source.absolutePath}", source.exists())
assertTrue("Source is not a file: ${source.absolutePath}", source.isFile)

destination.parentFile?.mkdirs()
source.copyTo(target = destination, overwrite = true)

assertTrue("Destination does not exist: ${destination.absolutePath}", destination.exists())
}

private fun resolveSafeDestination(base: File, relativePath: String): File {
require(relativePath.isNotBlank()) {
"Destination path must be a non-blank relative path"
}
require(!File(relativePath).isAbsolute) {
"Destination path must be relative"
}

val destination = File(base, relativePath.removePrefix("$EXPORT_BASE_DIRECTORY/")).canonicalFile
require(destination.toPath().startsWith(base.toPath())) {
"Destination must stay under ${base.absolutePath}: ${destination.absolutePath}"
}
return destination
}

private companion object {
const val SOURCE_RELATIVE_PATH = "home/.gradle/caches/modules-2/files-2.1"
const val GRADLE_CACHE_RELATIVE_PATH = "home/.gradle/caches"
const val ARG_DESTINATION_RELATIVE_PATH = "androidide.exportCache.destination"
const val ARG_GRADLE_API_VERSION = "androidide.exportGradleApi.version"
const val ARG_GRADLE_API_DESTINATION_RELATIVE_PATH = "androidide.exportGradleApi.destination"
const val EXPORT_BASE_DIRECTORY = "CodeOnTheGoProjects"
const val DEFAULT_DESTINATION_RELATIVE_PATH = "CodeOnTheGoProjects/gradle-cache/modules-2/files-2.1"
const val DEFAULT_GRADLE_API_DESTINATION_RELATIVE_PATH = "CodeOnTheGoProjects/gradle-api"
}
}
Loading
Loading