Android could never find anybody: the relay answers with an array
Adding a contact was impossible on Android. Every lookup reported "No account
called X here" while the relay was returning the account.
RoutesAccount.lookup answers with an ARRAY — empty for a miss, one element for a
hit — deliberately, so a miss and a hit differ only in length and neither timing
nor shape is an oracle. MessengerApi.lookup sent that through send(), which does
JSONObject(text) and throws on an array, and then swallowed the throw with
runCatching { }.getOrNull(). A parse failure and a genuine miss became the same
thing.
Measured against relay.flamenet.io, before and after, in the running app:
relay: [{"name":"appreview2demo","id":30,"slug":"appreview2demo"}]
before: "No account called appreview2demo here."
after: the contact card, with Add contact enabled
⚠️ Three layers kept this quiet: the relay was correct, the failure was
swallowed, and a miss and a crash render identically. CrudSurfaceTest asserts
the lookup CONTROL exists — and it does. **A control being present says nothing
about the feature working**, which is the whole reason this was found by driving
the app rather than by reading the suite.
This is the ONLY array-returning route (grepped), so rawText() is deliberately
narrow: everything else keeps send()'s typed error mapping.
Guarded by MessengerApiLookupTest — hit, miss, and a 500 that must not be
mistaken for a miss. **Verified load-bearing**: restoring the old implementation
fails it. 182 Android unit tests, 0 failures.
Also adds FeatureUseTest, which USES voice notes, groups, stories and calls
rather than asserting their buttons exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
10ae4ec180
commit
2855711bc5
@@ -0,0 +1,268 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
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.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.printToString
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Voice notes, stories, groups and calls — **used**, not just reachable.
|
||||
*
|
||||
* ⚠️ **`CrudSurfaceTest` already asserts these controls EXIST**, and that was
|
||||
* the right thing to build first: features kept landing with a live test and no
|
||||
* door in the UI. But `post-story`, `record-voice` and `start-call` being
|
||||
* present says nothing about what happens when somebody presses them, and until
|
||||
* now nothing pressed them. Four features shipped in an APK on that basis.
|
||||
*
|
||||
* So this presses them and asserts the state that follows: the recorder shows a
|
||||
* timer, discarding puts the composer back, a group appears in the list under
|
||||
* the name it was given, the story viewer opens, the call banner comes up and
|
||||
* hanging up takes it away.
|
||||
*
|
||||
* ⚠️ **What this deliberately does NOT claim.** An emulator has no microphone
|
||||
* worth the name and no second handset, so a voice note is exercised as far as
|
||||
* the recorder and a call as far as signalling. Whether audio arrives at another
|
||||
* person is not something one device can answer, and pretending otherwise would
|
||||
* be worse than the gap.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class FeatureUseTest {
|
||||
|
||||
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
private val relay = "relay1.flamenet.io"
|
||||
private lateinit var handle: String
|
||||
|
||||
@Before
|
||||
fun startSignedOut() {
|
||||
handle = "andfeat" + (System.currentTimeMillis() % 100_000_000)
|
||||
signOutIfSignedIn()
|
||||
}
|
||||
|
||||
// -- Voice notes ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The recorder starts, runs, and can be thrown away.
|
||||
*
|
||||
* The discard half matters as much as the start: a recorder you cannot back
|
||||
* out of holds the microphone open and leaves the composer unusable.
|
||||
*/
|
||||
@Test
|
||||
fun aVoiceNoteRecordsAndCanBeDiscarded() {
|
||||
if (!relayReachable()) return
|
||||
signUpAndOpenAThread()
|
||||
|
||||
compose.onNodeWithTag("record-voice").performClick()
|
||||
|
||||
// A timer is the proof it actually started rather than just accepting
|
||||
// the tap — see the note on RECORD_AUDIO below if this is what fails.
|
||||
awaitTag("recording-timer", 20_000)
|
||||
compose.onNodeWithTag("send-voice").assertExists()
|
||||
compose.onNodeWithTag("cancel-voice").assertExists()
|
||||
|
||||
compose.onNodeWithTag("cancel-voice").performClick()
|
||||
awaitTag("draft", 20_000)
|
||||
assertTrue(
|
||||
"discarding a recording did not bring the composer back",
|
||||
nodeExists("draft"),
|
||||
)
|
||||
}
|
||||
|
||||
// -- Groups --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A group is created and appears in the list under the name it was given.
|
||||
*
|
||||
* ⚠️ Needs a contact: the member picker has nobody in it otherwise, and the
|
||||
* relay refuses a group whose members have no device (`requireReachable`).
|
||||
*/
|
||||
@Test
|
||||
fun aGroupIsCreatedAndListed() {
|
||||
if (!relayReachable()) return
|
||||
signUpAndAddPeer()
|
||||
|
||||
compose.onNodeWithTag("new-group").performClick()
|
||||
awaitTag("group-name", 20_000)
|
||||
compose.onNodeWithTag("group-name").performTextInput("Test Crew")
|
||||
|
||||
// Pick the one contact there is, by the tag the row carries.
|
||||
val memberTag = Regex("group-member-(\\d+)").find(visibleText())?.value
|
||||
?: throw AssertionError(
|
||||
"nobody offered as a group member, so the contact did not stick. " +
|
||||
"On screen: ${visibleText()}")
|
||||
compose.onNodeWithTag(memberTag).performClick()
|
||||
compose.onNodeWithTag("create-group").performClick()
|
||||
|
||||
// The group list carries a `group-<id>` row once the relay answers.
|
||||
awaitNodeMatching(Regex("group-\\d+"), 60_000,
|
||||
"the group never appeared in the list after Create")
|
||||
}
|
||||
|
||||
// -- Stories -------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The story surface opens and says what it is when there is nothing in it.
|
||||
*
|
||||
* ⚠️ Posting one needs an image from the system picker, which is a
|
||||
* different process and not something a Compose test drives. So this covers
|
||||
* the strip and the empty state — an unexplained empty strip is a feature
|
||||
* nobody finds — and stops short of claiming a story was published.
|
||||
*/
|
||||
@Test
|
||||
fun theStorySurfaceOpensAndExplainsItself() {
|
||||
if (!relayReachable()) return
|
||||
signUp()
|
||||
|
||||
compose.onNodeWithTag("stories").assertExists()
|
||||
assertTrue(
|
||||
"the stories strip is empty with no explanation, which is how a " +
|
||||
"feature goes undiscovered",
|
||||
nodeExists("stories-empty") || nodeExists("post-story"),
|
||||
)
|
||||
}
|
||||
|
||||
// -- Calls ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Placing a call raises the privacy notice, then the in-call banner, and
|
||||
* hanging up clears it.
|
||||
*
|
||||
* ⚠️ **Signalling only.** Nobody answers on an emulator, so this proves the
|
||||
* call is placed and can be ended — not that audio flows. Media needs two
|
||||
* real devices and is honestly out of reach here.
|
||||
*/
|
||||
@Test
|
||||
fun placingACallRaisesTheBannerAndHangingUpClearsIt() {
|
||||
if (!relayReachable()) return
|
||||
signUpAndOpenAThread()
|
||||
|
||||
compose.onNodeWithTag("start-call").performClick()
|
||||
|
||||
// First call shows the peer-to-peer privacy notice (spec §6.8): a
|
||||
// direct connection reveals an IP address to the other side, and the
|
||||
// app asks before doing that.
|
||||
if (awaitTagOrNull("call-privacy", 8_000)) {
|
||||
compose.onNodeWithTag("call-privacy-accept").performClick()
|
||||
}
|
||||
|
||||
awaitTag("call-banner", 30_000)
|
||||
compose.onNodeWithTag("end-call").performClick()
|
||||
|
||||
val gone = (1..40).any {
|
||||
Thread.sleep(250)
|
||||
!nodeExists("call-banner")
|
||||
}
|
||||
assertTrue("hanging up did not clear the call banner", gone)
|
||||
}
|
||||
|
||||
// -- Helpers -------------------------------------------------------------
|
||||
|
||||
private fun signUpAndAddPeer() {
|
||||
signUp()
|
||||
compose.onNodeWithTag("new-contact").performClick()
|
||||
awaitTag("lookup-contact", 20_000)
|
||||
compose.onNodeWithTag("lookup-contact").performTextInput(PEER)
|
||||
compose.onNodeWithTag("confirm-contact").performClick()
|
||||
awaitNodeMatching(Regex("buddy-\\d+"), 60_000,
|
||||
"'$PEER' never became a contact — does that account exist on $relay?")
|
||||
}
|
||||
|
||||
private fun signUpAndOpenAThread() {
|
||||
signUpAndAddPeer()
|
||||
val buddyTag = Regex("buddy-(\\d+)").find(visibleText())?.value
|
||||
?: throw AssertionError("no contact row to open")
|
||||
compose.onNodeWithTag(buddyTag).performClick()
|
||||
awaitTag("draft", 20_000)
|
||||
}
|
||||
|
||||
private fun signUp() {
|
||||
compose.onNodeWithTag("relay").performTextClearance()
|
||||
compose.onNodeWithTag("relay").performTextInput(relay)
|
||||
compose.onNodeWithTag("toggle-register").performClick()
|
||||
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
|
||||
compose.onNodeWithTag("handle").performTextInput(handle)
|
||||
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
|
||||
compose.onNodeWithTag("submit").performClick()
|
||||
awaitTag("nav-contacts", 120_000)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun nodeExists(tag: String) = runCatching {
|
||||
compose.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty()
|
||||
}.getOrDefault(false)
|
||||
|
||||
/** See ChatFlowTest: the busy spinner never lets the test clock go idle. */
|
||||
private fun awaitTag(tag: String, timeoutMillis: Long) {
|
||||
if (!awaitTagOrNull(tag, timeoutMillis)) {
|
||||
throw AssertionError(
|
||||
"'$tag' did not appear within ${timeoutMillis}ms. " +
|
||||
"On screen: ${visibleText()}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun awaitTagOrNull(tag: String, timeoutMillis: Long): Boolean {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
if (nodeExists(tag)) return true
|
||||
Thread.sleep(100)
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for any tag matching a pattern — ids are assigned by the relay. */
|
||||
private fun awaitNodeMatching(pattern: Regex, timeoutMillis: Long, whenMissing: String) {
|
||||
compose.mainClock.autoAdvance = false
|
||||
try {
|
||||
val deadline = System.currentTimeMillis() + timeoutMillis
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
compose.mainClock.advanceTimeBy(100)
|
||||
if (pattern.containsMatchIn(visibleText())) return
|
||||
Thread.sleep(200)
|
||||
}
|
||||
throw AssertionError("$whenMissing. On screen: ${visibleText()}")
|
||||
} finally {
|
||||
compose.mainClock.autoAdvance = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibleText(): String = runCatching {
|
||||
compose.onRoot().printToString(maxDepth = 40)
|
||||
}.getOrElse { "(could not read the screen: $it)" }
|
||||
|
||||
private fun relayReachable(): Boolean = runCatching {
|
||||
(java.net.URL("https://$relay/e2e/log/head").openConnection() as java.net.HttpURLConnection)
|
||||
.apply { connectTimeout = 8000; readTimeout = 8000 }
|
||||
.responseCode in 200..299
|
||||
}.getOrDefault(false)
|
||||
|
||||
private companion object {
|
||||
/** A standing account on relay1 that other on-device tests also use. */
|
||||
const val PEER = "peerjvmone"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user