"Checking what this server uses…" was also the failure state #102

Merged
paulhitt merged 3 commits from fix/relay-probe-says-when-it-fails into main 2026-09-05 22:25:12 +00:00
7 changed files with 173 additions and 2 deletions
@@ -177,6 +177,14 @@ class ChatFlowTest {
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
// 🔴 **The age gate, which nothing here filled.** `submit` is
// `enabled = … && (!registering || birthYear.length == 4)`, so without
// this the button is DISABLED — and `performClick()` on a disabled node
// does not throw, it does nothing. Every sign-up in this suite has been
// silently tapping a dead button since the gate landed, and the failure
// surfaced two minutes later as "nav-contacts did not appear" with no
// error anywhere on screen.
compose.onNodeWithTag("birth-year").performTextInput("1990")
compose.onNodeWithTag("submit").performClick()
awaitTag("device-id", 120_000)
}
@@ -68,6 +68,14 @@ class CrossPlatformTest {
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
// 🔴 **The age gate, which nothing here filled.** `submit` is
// `enabled = … && (!registering || birthYear.length == 4)`, so without
// this the button is DISABLED — and `performClick()` on a disabled node
// does not throw, it does nothing. Every sign-up in this suite has been
// silently tapping a dead button since the gate landed, and the failure
// surfaced two minutes later as "nav-contacts did not appear" with no
// error anywhere on screen.
compose.onNodeWithTag("birth-year").performTextInput("1990")
compose.onNodeWithTag("submit").performClick()
awaitTag("nav-contacts", 150_000)
android.util.Log.i("fnxplat", "ANDROID_HANDLE=$handle")
@@ -231,6 +231,14 @@ class CrudSurfaceTest {
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
// 🔴 **The age gate, which nothing here filled.** `submit` is
// `enabled = … && (!registering || birthYear.length == 4)`, so without
// this the button is DISABLED — and `performClick()` on a disabled node
// does not throw, it does nothing. Every sign-up in this suite has been
// silently tapping a dead button since the gate landed, and the failure
// surfaced two minutes later as "nav-contacts did not appear" with no
// error anywhere on screen.
compose.onNodeWithTag("birth-year").performTextInput("1990")
compose.onNodeWithTag("submit").performClick()
awaitTag("nav-contacts", 120_000)
}
@@ -230,6 +230,14 @@ class FeatureUseTest {
compose.onNodeWithTag("email").performTextInput("$handle@example.invalid")
compose.onNodeWithTag("handle").performTextInput(handle)
compose.onNodeWithTag("passphrase").performTextInput("a-long-enough-passphrase")
// 🔴 **The age gate, which nothing here filled.** `submit` is
// `enabled = … && (!registering || birthYear.length == 4)`, so without
// this the button is DISABLED — and `performClick()` on a disabled node
// does not throw, it does nothing. Every sign-up in this suite has been
// silently tapping a dead button since the gate landed, and the failure
// surfaced two minutes later as "nav-contacts did not appear" with no
// error anywhere on screen.
compose.onNodeWithTag("birth-year").performTextInput("1990")
compose.onNodeWithTag("submit").performClick()
awaitTag("nav-contacts", 120_000)
}
@@ -339,10 +339,23 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
describeJob = viewModelScope.launch {
kotlinx.coroutines.delay(400)
val config = SiteConfig.from(value) ?: return@launch
val found = withContext(Dispatchers.IO) {
io.flamenet.client.RelayDiscovery.describe(config.baseUrl)
// ⚠️ **Bounded.** `describe` has no deadline of its own, and a
// host that accepts a connection and never answers would leave the
// sign-up form on "Checking what this server uses…" indefinitely.
// Fifteen seconds is far longer than any healthy relay needs.
val found = withTimeoutOrNull(RELAY_PROBE_TIMEOUT_MS) {
withContext(Dispatchers.IO) {
io.flamenet.client.RelayDiscovery.describe(config.baseUrl)
}
}
if (_relayInput.value != value) return@launch
if (found == null) {
// Say so rather than leaving the spinner-in-words up. The
// commonest cause is an http:// address, which Android refuses
// outright, so the message names it.
_site.value = config.copy(probeFailed = true)
return@launch
}
// copy(), not a mutation: SiteConfig is a data class and the flow
// conflates on equality, so an in-place write is indistinguishable
// from the value already there and never reaches the UI.
@@ -3570,6 +3583,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
*/
private const val SIGN_IN_TIMEOUT_MS = 60_000L
/** How long to wait for a relay to describe itself before saying it did not. */
private const val RELAY_PROBE_TIMEOUT_MS = 15_000L
/**
* The presence chips, in order.
*
@@ -29,6 +29,18 @@ data class SiteConfig(
* it with `copy(...)`.
*/
val discovered: io.flamenet.client.RelayDiscovery.Descriptor? = null,
/**
* The probe was made and came back with nothing.
*
* 🔴 **Without this, "still asking" and "asked and could not reach it" are
* the same state.** `discovered` is null in both, so a relay that never
* answers leaves "Checking what this server uses…" on screen forever and
* the form permanently unusable — no error, no retry, nothing to act on.
* Seen 2026-09-05 pointing the app at a plain-HTTP relay, which Android
* blocks outright from API 28: the request never left, and the screen said
* it was still thinking about it.
*/
val probeFailed: Boolean = false,
) {
enum class AccountProvider {
@@ -95,6 +107,9 @@ data class SiteConfig(
/** What to tell the user this relay does about identity, in their words. */
val identityDescription: String
get() = when {
probeFailed ->
"Couldn't reach that server. Check the address — and note it has " +
"to be https, not http."
discovered == null -> "Checking what this server uses…"
accountProvider == AccountProvider.SHARED ->
"One Flamenet account, shared with Flamenet Files."
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Run the on-device suites against a relay you named, on an emulator with no
# history.
#
# 🔴 **Two ways an instrumented run silently points at PRODUCTION, and this
# script closes both.**
#
# 1. The suites DEFAULT to a live host when no relay argument is passed —
# `FeatureUseTest` to relay1.flamenet.io, `LiveSignUpFleetTest` to
# relay.flamenet.io. A plain `./gradlew connectedAndroidTest` therefore
# signs accounts up on somebody's real server, and nothing in the output
# says so.
# 2. A SAVED EMULATOR SNAPSHOT restores whatever the AVD was last doing.
# Observed 2026-09-05: a snapshot signed in as `fnreviewbeta` against
# relay.flamenet.io, restored under a test run that had been told to use a
# local relay. Every one of the ten tests failed — they could not find the
# sign-up screen, because the app was already signed in — and the reason
# was invisible until the failure's screen dump was read.
#
# Usage: scripts/instrumented.sh 10.0.2.2:18810 [ClassName ...]
set -euo pipefail
RELAY="${1:-}"
[ -n "$RELAY" ] || {
cat >&2 <<'MSG'
usage: scripts/instrumented.sh <relay-host:port> [TestClass ...]
Name the relay. There is deliberately no default: the suites have their own,
and theirs are live servers.
10.0.2.2:18810 the host machine's local relay, as seen from the emulator
MSG
exit 2
}
shift || true
# 🔴 Production is refused, not warned about. A run that creates accounts on
# relay.flamenet.io cannot be undone by noticing afterwards.
#
# ⚠️ relay1/relay2 are the TEST FLEET and are allowed — that is what they are
# for, and the suites need https because Android blocks cleartext, so a local
# http relay cannot serve. Loud rather than silent: accounts really are created.
case "$RELAY" in
*//relay.flamenet.io*|relay.flamenet.io*)
echo "REFUSED: $RELAY is the PRODUCTION relay. These suites create accounts." >&2
echo "Use a test-fleet relay (relay1/relay2) or a local one." >&2
exit 1;;
*relay1.flamenet.io*|*relay2.flamenet.io*)
echo "⚠️ $RELAY is a shared test relay — this run WILL create accounts on it." >&2;;
esac
CLASSES="${*:-io.flamenet.messenger.app.FeatureUseTest,io.flamenet.messenger.app.CrudSurfaceTest,io.flamenet.messenger.app.ChatFlowTest}"
CLASSES="${CLASSES// /,}"
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@21}"
ADB="$ANDROID_HOME/platform-tools/adb"
AVD="${FN_AVD:-fnmsg-android-test}"
# ⚠️ `-no-snapshot-load` is the point of this block, and `-no-snapshot-save`
# keeps the next run clean too. A warm boot is faster and carries the last
# session's account with it.
if ! "$ADB" shell getprop sys.boot_completed 2>/dev/null | grep -q 1; then
echo "### cold-booting $AVD (no snapshot)"
nohup "$ANDROID_HOME/emulator/emulator" -avd "$AVD" \
-no-snapshot-load -no-snapshot-save -no-boot-anim \
-netdelay none -netspeed full >/tmp/fnmsg-emu.log 2>&1 &
until "$ADB" shell getprop sys.boot_completed 2>/dev/null | grep -q 1; do sleep 5; done
else
echo "### emulator already up — clearing the app rather than trusting it"
"$ADB" uninstall io.flamenet.messenger >/dev/null 2>&1 || true
"$ADB" uninstall io.flamenet.messenger.test >/dev/null 2>&1 || true
fi
echo "### signed-in account on the device before we start:"
"$ADB" shell pm list packages 2>/dev/null | grep -i flamenet || echo " (nothing installed — good)"
echo "### relay $RELAY · classes $CLASSES"
set +e
./gradlew --no-daemon :app:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.relay="$RELAY" \
-Pandroid.testInstrumentationRunnerArguments.class="$CLASSES" 2>&1 \
| tee /tmp/fnmsg-instrumented.log \
| grep -E "FAILED|PASSED|SKIPPED|BUILD|Finished [0-9]+ test|Tests on"
STATUS=${PIPESTATUS[0]}
set -e
# 🔴 **A skip is not a pass.** Every one of these suites opens with
# `assumeTrue(relayReachable())`, so an unreachable relay produces a green
# build that exercised nothing. Say which happened.
# ⚠️ Anchored to the TEST line, not a bare "SKIPPED". Gradle prints
# `> Task :app:preDebugAndroidTestBuild SKIPPED` for up-to-date tasks, so the
# loose grep reported four skipped tests on a run where none were skipped —
# a verdict written to stop a skip reading as a pass, inventing skips instead.
SKIPS=$(grep -cE "^io\.flamenet\..*SKIPPED" /tmp/fnmsg-instrumented.log || true)
RAN=$(grep -oE "Finished [0-9]+ test" /tmp/fnmsg-instrumented.log | tail -1 || echo "")
echo
echo "### $RAN, $SKIPS skipped"
if [ "$SKIPS" != "0" ] && [ "$STATUS" = "0" ]; then
echo "### VERDICT: SKIPPED — the relay was unreachable. This is NOT a pass."
exit 3
elif [ "$STATUS" = "0" ]; then
echo "### VERDICT: PASSED on device"
else
echo "### VERDICT: FAILED — see /tmp/fnmsg-instrumented.log"
echo "### and app/build/reports/androidTests/connected/debug/index.html"
fi
exit $STATUS