The on-device suite catches up with the app #103

Merged
paulhitt merged 1 commits from fix/instrumented-suite-catches-up into main 2026-09-06 18:09:17 +00:00
@@ -4,7 +4,10 @@ import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.printToString
@@ -55,6 +58,12 @@ class FeatureUseTest {
@get:Rule(order = 0)
val permissions: GrantPermissionRule = GrantPermissionRule.grant(
android.Manifest.permission.POST_NOTIFICATIONS,
// 🔴 **RECORD_AUDIO, or the recorder test cannot see the app.** Tapping
// record raises the system microphone dialog, which is not part of the
// app's Compose tree — so the failure is "No compose hierarchies
// found", which reads as the app having died rather than as a dialog
// sitting on top of it.
android.Manifest.permission.RECORD_AUDIO,
)
@get:Rule(order = 1)
@@ -129,6 +138,12 @@ class FeatureUseTest {
assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable())
signUpAndAddPeer()
// ⚠️ Groups live on the MESSAGES tab, not Home. Home is the roster and
// the stories strip; the conversation list and the groups moved when
// the tabs were split.
compose.onNodeWithTag("nav-messages").performClick()
awaitTag("new-group", 20_000)
compose.onNodeWithTag("new-group").performScrollTo()
compose.onNodeWithTag("new-group").performClick()
awaitTag("group-name", 20_000)
compose.onNodeWithTag("group-name").performTextInput("Test Crew")
@@ -142,7 +157,7 @@ class FeatureUseTest {
compose.onNodeWithTag("create-group").performClick()
// The group list carries a `group-<id>` row once the relay answers.
awaitNodeMatching(Regex("group-\\d+"), 60_000,
awaitNodeMatching(Regex("group-\\d+"), 150_000,
"the group never appeared in the list after Create")
}
@@ -161,11 +176,23 @@ class FeatureUseTest {
assumeTrue("the relay is unreachable - skipped, and a skip is not a pass", relayReachable())
signUp()
compose.onNodeWithTag("stories").assertExists()
// ⚠️ The stories surface is its own tab. Asserting from Home found
// nothing and reported it as the strip being absent.
compose.onNodeWithTag("nav-stories").performClick()
// 🔴 **`new-story`, not `stories`.** The `stories` tag belongs to the
// STRIP on Home; the Stories TAB is its own screen and carries the
// compose button instead. Asserting the strip's tag from the tab found
// nothing and reported it as the surface being absent — while the
// screen was right there, headed "Stories", saying "Nothing here yet".
awaitTag("new-story", 20_000)
compose.onNodeWithTag("new-story").assertExists()
// ⚠️ The point of the test is the EXPLANATION, not the strip: an empty
// surface with nothing telling you what it is for is how a feature goes
// undiscovered.
assertTrue(
"the stories strip is empty with no explanation, which is how a " +
"feature goes undiscovered",
nodeExists("stories-empty") || nodeExists("post-story"),
"the stories surface is empty with no explanation of what it is for",
visibleText().contains("Nothing here yet") &&
visibleText().contains("disappears"),
)
}
@@ -207,17 +234,37 @@ class FeatureUseTest {
private fun signUpAndAddPeer() {
signUp()
compose.onNodeWithTag("new-contact").performClick()
awaitTag("lookup-contact", 20_000)
compose.onNodeWithTag("lookup-contact").performTextInput(PEER)
// 🔴 **The add-contact flow, as it exists.** `new-contact` is the text
// FIELD, not a button that opens one, and `lookup-contact` is not in
// the app at all — the form is opened by `add-contact` and submitted
// with `confirm-contact`.
compose.onNodeWithTag("add-contact").performScrollTo()
compose.onNodeWithTag("add-contact").performClick()
awaitTag("new-contact", 20_000)
compose.onNodeWithTag("new-contact").performTextInput(PEER)
// ⚠️ **Look up, THEN add.** `confirm-contact` only exists once the
// lookup has found somebody — the app refuses to turn a typed string
// straight into a contact, because a typo would silently become a
// stranger and the first thing sent to them is a message meant for
// someone else.
compose.onNodeWithTag("lookup-contact").performScrollTo()
compose.onNodeWithTag("lookup-contact").performClick()
awaitTag("confirm-contact", 30_000)
compose.onNodeWithTag("confirm-contact").performScrollTo()
compose.onNodeWithTag("confirm-contact").performClick()
awaitNodeMatching(Regex("buddy-\\d+"), 60_000,
// ⚠️ `buddy-circle-N` as well as `buddy-N`. Home shows the Recent
// circles row, whose tags are `buddy-circle-<id>`; the plain
// `buddy-<id>` belongs to the list further in. Matching only the
// latter reported "never became a contact" about a contact that was
// right there on screen — the roster had the person, under the other
// name for the same thing.
awaitNodeMatching(Regex("buddy-(circle-)?\\d+"), 150_000,
"'$PEER' never became a contact — does that account exist on $relay?")
}
private fun signUpAndOpenAThread() {
signUpAndAddPeer()
val buddyTag = Regex("buddy-(\\d+)").find(visibleText())?.value
val buddyTag = Regex("buddy-(circle-)?\\d+").find(visibleText())?.value
?: throw AssertionError("no contact row to open")
compose.onNodeWithTag(buddyTag).performClick()
awaitTag("draft", 20_000)
@@ -238,19 +285,60 @@ class FeatureUseTest {
// surfaced two minutes later as "nav-contacts did not appear" with no
// error anywhere on screen.
compose.onNodeWithTag("birth-year").performTextInput("1990")
// 🔴 **Scroll to it first.** The sign-up form grew a "Year of birth"
// field and its explanation, and `submit` went below the fold on a
// 1080x2400 screen. An off-screen node still reports ENABLED — that is
// a state check, not a visibility one — and the gesture reached
// nothing. The app's own logging proved it: `signIn` was never called,
// through 450 seconds of waiting for a screen nobody had asked for.
compose.onNodeWithTag("submit").performScrollTo()
compose.onNodeWithTag("submit").performClick()
awaitTag("nav-contacts", 120_000)
val began = System.currentTimeMillis()
// ⏱ How long sign-up actually takes here, logged so the answer is a
// number rather than a guess.
awaitTag("nav-contacts", 420_000)
android.util.Log.e("FNTEST", "sign-up completed in " +
((System.currentTimeMillis() - began) / 1000) + "s")
}
/**
* Get back to the signed-out screen, or establish that we are already there.
*
* 🔴 **Two things made this a no-op.** `sign-out` sits at the bottom of the
* Account screen and needs scrolling to — an off-screen node accepts a
* click and does nothing. And the destructive rows **confirm in place**:
* the first tap changes the row to say what it is about to do, and a second
* tap performs it. One tap armed it and left the session intact, so every
* test after the first opened signed in and could not find the sign-up
* form, failing on `TestTag = 'relay'` as though the screen were missing.
*
* ⚠️ Not wrapped in `runCatching` any more. Swallowing a failure here made
* the next test's failure the visible one, three screens away from the
* cause.
*/
private fun signOutIfSignedIn() {
runCatching {
if (!awaitTagOrNull("nav-account", 8_000)) return
compose.onNodeWithTag("nav-account").performClick()
if (awaitTagOrNull("sign-out", 10_000)) {
compose.onNodeWithTag("sign-out").performClick()
}
awaitTagOrNull("submit", 20_000)
if (!awaitTagOrNull("nav-account", 8_000)) return
compose.onNodeWithTag("nav-account").performClick()
// 🔴 **`sign-out-erase`, not `sign-out`.** The two doors do different
// things on purpose: a plain sign-out KEEPS the device and the stored
// credential, so signing back in picks up where you left off — correct
// for a person, wrong for a suite whose next test wants a DIFFERENT
// account. The old session was still in the vault, the app kept trying
// to use it, and the next sign-up sat behind "Your session expired.
// Sign out and back in to keep going."
if (!awaitTagOrNull("sign-out-erase", 10_000)) return
compose.onNodeWithTag("sign-out-erase").performScrollTo()
compose.onNodeWithTag("sign-out-erase").performClick()
// ⚠️ Confirm in place: the first tap changes the row to "Really erase —
// this cannot be undone", and the second performs it. One tap only
// armed it.
if (awaitTagOrNull("sign-out-erase", 3_000)) {
compose.onNodeWithTag("sign-out-erase").performClick()
}
assertTrue(
"signed out, but the sign-up form never came back",
awaitTagOrNull("relay", 30_000),
)
}
private fun nodeExists(tag: String) = runCatching {