Files
Yuvomi-App/android/app/src/androidTestNativeIntegration/java/community/yuvomi/mobile/NativeUpstreamTest.kt
T
Yuvomi Mobile Contributors 8c480ddca3 feat(android): replace WebView with tested native Compose API slice
Add real native onboarding/auth/dashboard/tasks/notes/shopping, origin-bound HTTPS and encrypted AndroidKeyStore session persistence. Archive the superseded browser renderer and tests. Verify 10 JVM tests, 6 native API-35 emulator tests, lintDebug/lintRelease and debug/unsigned release APK/AAB builds; the real upstream suite passes 7 tests and exports a nonblank native fixture screenshot. Tink host-tool update removes the old generated-protobuf warning. Full module/visual parity remains explicitly incomplete.
2026-10-21 09:12:47 +02:00

201 lines
9.5 KiB
Kotlin

package community.yuvomi.mobile
import android.content.Context
import android.graphics.Bitmap
import android.view.WindowManager
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextInput
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModelProvider
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.platform.io.PlatformTestStorageRegistry
import java.time.LocalDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.ExternalResource
/** Drives native semantics and real upstream API calls; never evaluates JavaScript. */
class NativeUpstreamTest {
@get:Rule(order = 0)
val reset = object : ExternalResource() {
override fun before() {
val context = ApplicationProvider.getApplicationContext<Context>()
context.getSharedPreferences("native-server", Context.MODE_PRIVATE).edit().clear().commit()
context.getSharedPreferences("native-session", Context.MODE_PRIVATE).edit().clear().commit()
if (android.os.Build.VERSION.SDK_INT >= 37) {
InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission(
context.packageName,
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
)
}
}
}
@get:Rule(order = 1)
val compose = createAndroidComposeRule<MainActivity>()
@Test fun nativeLoginDashboardTaskNoteShoppingAndLogoutAgainstRealServer() {
val arguments = InstrumentationRegistry.getArguments()
val origin = requireNotNull(arguments.getString("server"))
val password = requireNotNull(arguments.getString("password"))
val seed = NativeApi(ServerAddress.parse(origin), NativeCookies(ServerAddress.parse(origin)), {})
seed.request("/auth/login", "POST", org.json.JSONObject().put("username", "mobiletest").put("password", password))
seed.request(
"/tasks",
"POST",
org.json.JSONObject().put(
"title",
"Gemeinsam den Alltag planen",
).put("priority", "high").put("due_date", LocalDate.now().toString()),
)
seed.request(
"/notes",
"POST",
org.json.JSONObject().put("title", "Willkommen zuhause").put("content", "Native Oberfläche, eigener Server.").put("pinned", 1),
)
val seedList = seed.request(
"/shopping",
"POST",
org.json.JSONObject().put("name", "Wochenmarkt"),
).getJSONObject("data").getLong("id")
seed.request("/shopping/$seedList/items", "POST", org.json.JSONObject().put("name", "Äpfel").put("quantity", "4"))
seed.request("/auth/logout", "POST")
compose.onNodeWithTag("server-url").performTextInput(origin)
compose.onNodeWithTag("connect").performClick()
awaitTag("username")
compose.onNodeWithTag("username").performTextInput("mobiletest")
compose.onNodeWithTag("password").performTextInput(password)
compose.onNodeWithTag("login").performScrollTo().performClick()
awaitTag("native-dashboard")
captureFixtureDashboard()
compose.activityRule.scenario.moveToState(Lifecycle.State.CREATED)
compose.activityRule.scenario.moveToState(Lifecycle.State.RESUMED)
compose.activityRule.scenario.recreate()
awaitTag("native-dashboard")
compose.onNodeWithTag("nav-TASKS").performClick()
awaitText("Hinzufügen")
compose.onNodeWithText("Hinzufügen").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native task regression")
compose.onNodeWithTag("entry-detail").performTextInput("Created with native controls")
compose.onNodeWithTag("entry-save").performClick()
awaitText("Native task regression")
assertTrue(readApi(origin).request("/tasks").rows().any { it.text("title") == "Native task regression" })
compose.onNodeWithTag("nav-NOTES").performClick()
awaitText("Hinzufügen")
compose.onNodeWithText("Hinzufügen").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native note regression")
compose.onNodeWithTag("entry-detail").performTextInput("Not an embedded web page")
compose.onNodeWithTag("entry-save").performClick()
awaitText("Native note regression")
assertTrue(readApi(origin).request("/notes").rows().any { it.text("content") == "Not an embedded web page" })
compose.onNodeWithTag("nav-SHOPPING").performClick()
awaitText("Neue Liste")
compose.onNodeWithText("Neue Liste").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native shopping regression")
compose.onNodeWithTag("entry-save").performClick()
awaitText("Native shopping regression")
compose.onNodeWithText("Native shopping regression").performClick()
awaitText("Hinzufügen")
compose.onNodeWithText("Hinzufügen").performClick()
compose.onNodeWithTag("entry-title").performTextInput("Native apples")
compose.onNodeWithTag("entry-detail").performTextInput("3")
compose.onNodeWithTag("entry-save").performClick()
awaitText("Native apples")
val api = readApi(origin)
val list = api.request("/shopping").rows().first { it.text("name") == "Native shopping regression" }
assertEquals(
"3",
api.request("/shopping/${list.getLong("id")}/items").rows().first {
it.text("name") == "Native apples"
}.text("quantity"),
)
// Verify the same native transport rejects a cross-origin OIDC redirect.
try {
api.request("/auth/oidc/start")
throw AssertionError("Native API followed an authentication redirect")
} catch (error: NativeApiError) {
assertEquals(302, error.status)
}
compose.onNodeWithTag("nav-MORE").performClick()
awaitText("Abmelden")
compose.onNodeWithText("Abmelden").performScrollTo().performClick()
awaitTag("username")
val context = ApplicationProvider.getApplicationContext<Context>()
assertEquals(null, NativeSession(context, origin).load())
}
private fun readApi(origin: String): NativeApi {
val context = ApplicationProvider.getApplicationContext<Context>()
val vault = NativeSession(context, origin)
val api = NativeApi(ServerAddress.parse(origin), NativeCookies(ServerAddress.parse(origin), vault.load()), {})
api.request("/auth/me")
return api
}
private fun captureFixtureDashboard() {
// Only this temporary-fixture test clears the screenshot restriction.
// Ordinary builds always retain FLAG_SECURE, including native login.
compose.activityRule.scenario.onActivity { it.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) }
try {
compose.waitForIdle()
// Capture the native window through PixelCopy, not a potentially stale
// secure compositor frame returned by UiAutomation immediately after clearing.
val image = compose.onRoot().captureToImage().asAndroidBitmap()
try {
val first = image.getPixel(0, 0)
assertTrue(
"Native fixture screenshot is blank",
(0 until image.height step 8).any { y ->
(0 until image.width step 8).any { x -> image.getPixel(x, y) != first }
},
)
// AGP collects this test output before uninstalling the fixture APK.
PlatformTestStorageRegistry.getInstance().openOutputFile("native-dashboard.png").use {
check(image.compress(Bitmap.CompressFormat.PNG, 100, it))
}
} finally {
image.recycle()
}
} finally {
compose.activityRule.scenario.onActivity { it.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) }
}
}
private fun awaitTag(tag: String) {
try {
compose.waitUntil(30_000) { compose.onAllNodes(androidx.compose.ui.test.hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() }
} catch (error: Exception) {
var diagnostic = ""
compose.activityRule.scenario.onActivity {
val state = ViewModelProvider(it)[NativeController::class.java].state
diagnostic = "busy=${state.busy}, signedIn=${state.user != null}, error=${state.error}"
}
throw AssertionError("Native screen '$tag' not reached: $diagnostic", error)
}
compose.onNodeWithTag(tag).assertIsDisplayed()
}
private fun awaitText(text: String) {
compose.waitUntil(30_000) { compose.onAllNodes(hasText(text) and isEnabled()).fetchSemanticsNodes().isNotEmpty() }
compose.onNodeWithText(text).assertIsDisplayed()
}
}