Mantiene la copertina YouTube se il telefono cade e avvia ffmpeg sul nodo, non via SSH.

Il relay HLS→RTMPS resta sul worker/agent del nodo assegnato, così tre dirette contemporanee restano in onda sul sito e sul canale della società senza schermo nero.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-16 20:41:46 +02:00
co-authored by Cursor
parent bfb3115a64
commit baa6283a15
36 changed files with 1444 additions and 141 deletions
@@ -37,10 +37,26 @@ class E2EWizardFlowTest {
@Test
fun login_newMatch_wizard_reachesBroadcastScreen() {
waitForAnyText(s(R.string.login_email), su(R.string.login_submit), timeoutMs = 45_000)
fillLogin()
waitForText(su(R.string.matches_new), timeoutMs = 45_000)
tapClickableText(su(R.string.matches_new))
waitForAnyText(
s(R.string.login_email),
su(R.string.login_submit),
"Email",
"ACCEDI",
"LOG IN",
su(R.string.matches_new),
"NUOVA PARTITA",
"NEW MATCH",
timeoutMs = 45_000,
)
if (!hasMatchList()) {
fillLogin()
}
if (!hasMatchList()) {
tapLoginSubmit()
SystemClock.sleep(4000)
}
waitForAnyText(su(R.string.matches_new), "NUOVA PARTITA", "NEW MATCH", timeoutMs = 60_000)
tapFirstAvailable(su(R.string.matches_new), "NUOVA PARTITA", "NEW MATCH")
waitForText(s(R.string.sheet_quick_option), timeoutMs = 15_000)
tapClickableText(s(R.string.sheet_quick_option))
waitForText(s(R.string.wizard_step_title_match), timeoutMs = 45_000)
@@ -48,20 +64,46 @@ class E2EWizardFlowTest {
tapClickableText(s(R.string.wizard_action_next))
waitForText(s(R.string.wizard_step_title_transmission), timeoutMs = 45_000)
waitForText(s(R.string.wizard_transmission_platform_title), timeoutMs = 30_000)
scrollDown()
tapClickableText(s(R.string.wizard_action_next))
waitForText(s(R.string.wizard_step_title_network), timeoutMs = 45_000)
selectYoutubeIfRequested()
val onNetworkStep = {
device.hasObject(By.text(s(R.string.wizard_network_test_start_label))) ||
device.hasObject(By.text("AVVIA TEST RETE")) ||
device.hasObject(By.text("START NETWORK TEST"))
}
repeat(4) {
if (onNetworkStep()) return@repeat
runCatching { tapClickableText(s(R.string.wizard_action_next)) }
if (onNetworkStep()) return@repeat
// AVANTI è in fondo alla colonna: uno swipe verso l'alto lo riporta in vista.
scrollUp()
SystemClock.sleep(1_200)
}
waitForAnyText(
s(R.string.wizard_network_test_start_label),
"AVVIA TEST RETE",
"START NETWORK TEST",
timeoutMs = 45_000,
)
waitForText(s(R.string.wizard_network_test_start_label), timeoutMs = 30_000)
tapClickableText(s(R.string.wizard_network_test_start_label))
waitForText(s(R.string.wizard_action_start), timeoutMs = 30_000)
waitUntilEnabled(s(R.string.wizard_action_start), timeoutMs = 25_000)
scrollDown()
tapClickableText(s(R.string.wizard_action_start))
if (wantsYoutube()) {
waitForTextContaining("youtube.com", "youtu.be", timeoutMs = 60_000)
}
tapStartLive()
waitForAnyText(
s(R.string.broadcast_status_live),
s(R.string.broadcast_status_connecting),
s(R.string.broadcast_status_reconnecting),
timeoutMs = 60_000,
s(R.string.broadcast_close_set_button),
s(R.string.score_action_close_set),
s(R.string.broadcast_terminate_cd),
"IN DIRETTA",
"LIVE",
"CONNESSIONE…",
"CONNECTING…",
timeoutMs = 90_000,
)
// CLOSE SET è un SideIconButton: in hierarchy compare come content-desc, non sempre come text.
assertTrue(
@@ -73,6 +115,9 @@ class E2EWizardFlowTest {
s(R.string.broadcast_terminate_cd),
),
)
// Tiene la diretta accesa per visione browser / secondo emulatore.
val holdMs = InstrumentationRegistry.getArguments().getString("holdLiveMs")?.toLongOrNull() ?: 0L
if (holdMs > 0) SystemClock.sleep(holdMs)
}
private fun grantRuntimePermissions() {
@@ -94,14 +139,108 @@ class E2EWizardFlowTest {
}
private fun fillLogin() {
val fields = device.wait(Until.findObjects(By.clazz("android.widget.EditText")), 15_000)
if (fields.size < 2) error("Campi login non trovati (${fields.size})")
pasteIntoField(fields[0], "coach@matchlivetv.test")
pasteIntoField(fields[1], "Password123")
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
// Preferisci il bottone (ultimo match uppercase), non il titolo.
tapLastMatchingText(su(R.string.login_submit))
repeat(3) {
val email = waitForRes("login_email")
val password = waitForRes("login_password")
pasteIntoField(email, "coach@matchlivetv.test")
pasteIntoField(password, "Password123")
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
device.waitForIdle()
SystemClock.sleep(1500)
tapLoginSubmit()
device.waitForIdle()
SystemClock.sleep(4000)
if (hasMatchList()) return
}
}
private fun wantsYoutube(): Boolean =
InstrumentationRegistry.getArguments().getString("platform").equals("youtube", ignoreCase = true)
private fun tapStartLive() {
repeat(6) {
val tagged = device.findObject(By.res("wizard_start_live"))
?: device.findObject(By.res("$pkg:id/wizard_start_live"))
if (tagged != null && tagged.isEnabled) {
val b = tagged.visibleBounds
if (b.height() >= 20 && b.bottom <= device.displayHeight && b.top >= 0) {
tagged.click()
device.waitForIdle()
return
}
}
val label = s(R.string.wizard_action_start)
val nodes = device.findObjects(By.text(label))
val target = nodes.lastOrNull { it.isClickable && it.isEnabled }
if (target != null && target.visibleBounds.height() >= 20) {
target.click()
device.waitForIdle()
return
}
val x = device.displayWidth / 2
device.swipe(x, (device.displayHeight * 0.88).toInt(), x, (device.displayHeight * 0.62).toInt(), 24)
device.waitForIdle()
SystemClock.sleep(300)
}
tapClickableText(s(R.string.wizard_action_start))
}
private fun selectYoutubeIfRequested() {
if (!wantsYoutube()) return
waitForAnyText("YouTube Live", timeoutMs = 20_000)
tapClickableText("YouTube Live")
waitForAnyText(
s(R.string.wizard_transmission_unlisted_label),
"NON IN ELENCO",
"UNLISTED",
timeoutMs = 10_000,
)
tapFirstAvailable(s(R.string.wizard_transmission_unlisted_label), "NON IN ELENCO", "UNLISTED")
device.waitForIdle()
SystemClock.sleep(400)
}
private fun hasMatchList(): Boolean =
device.hasObject(By.text(su(R.string.matches_new))) ||
device.hasObject(By.text("NUOVA PARTITA")) ||
device.hasObject(By.text("NEW MATCH"))
private fun waitForRes(tag: String): UiObject2 {
val obj = device.wait(Until.findObject(By.res(tag)), 10_000)
?: device.wait(Until.findObject(By.res("$pkg:id/$tag")), 2_000)
?: error("Nodo non trovato: $tag")
return obj
}
private fun tapLoginSubmit() {
val btn = device.wait(Until.findObject(By.res("login_submit")), 3_000)
?: device.wait(Until.findObject(By.res("$pkg:id/login_submit")), 1_000)
if (btn != null) {
val bounds = btn.visibleBounds
device.click(bounds.centerX(), bounds.centerY())
device.waitForIdle()
return
}
if (hasMatchList()) return
val nodes = device.findObjects(By.text(su(R.string.login_submit))) +
device.findObjects(By.text("ACCEDI")) +
device.findObjects(By.text("LOG IN"))
val target = nodes.lastOrNull { it.isClickable } ?: nodes.lastOrNull()
if (target != null) {
target.click()
device.waitForIdle()
return
}
}
private fun tapFirstAvailable(vararg texts: String) {
for (text in texts) {
if (device.hasObject(By.text(text))) {
tapClickableText(text)
return
}
}
error("Nessun testo cliccabile: ${texts.joinToString()}")
}
private fun pasteIntoField(field: UiObject2, value: String) {
@@ -118,6 +257,18 @@ class E2EWizardFlowTest {
return obj!!
}
private fun waitForTextContaining(vararg needles: String, timeoutMs: Long) {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
for (needle in needles) {
if (device.hasObject(By.textContains(needle))) return
}
scrollDown(1)
SystemClock.sleep(250)
}
error("Nessun testo contenente: ${needles.joinToString()}")
}
private fun waitForAnyText(vararg texts: String, timeoutMs: Long) {
val deadline = SystemClock.elapsedRealtime() + timeoutMs
while (SystemClock.elapsedRealtime() < deadline) {
@@ -158,25 +309,31 @@ class E2EWizardFlowTest {
}
private fun tapClickableText(text: String) {
device.findObject(By.text(text).clickable(true))?.let { node ->
node.click()
device.waitForIdle()
return
}
val label = device.wait(Until.findObject(By.text(text)), 15_000)
?: error("Testo non trovato: $text")
var node: UiObject2? = label
for (i in 0 until 6) {
val current = node ?: break
if (current.isClickable) {
current.click()
device.waitForIdle()
return
repeat(6) {
val clickable = device.findObject(By.text(text).clickable(true))
val label = clickable ?: device.findObject(By.text(text))
if (label != null) {
val b = label.visibleBounds
if (b.height() >= 16 && b.bottom <= device.displayHeight && b.top >= 0) {
var node: UiObject2? = label
for (i in 0 until 6) {
val current = node ?: break
if (current.isClickable) {
current.click()
device.waitForIdle()
return
}
node = current.parent
}
label.click()
device.waitForIdle()
return
}
}
node = current.parent
scrollDown(1)
device.waitForIdle()
}
label.click()
device.waitForIdle()
error("Testo non cliccabile visibile: $text")
}
private fun waitUntilEnabled(text: String, timeoutMs: Long) {
@@ -206,4 +363,15 @@ class E2EWizardFlowTest {
SystemClock.sleep(300)
}
}
private fun scrollUp(steps: Int = 1) {
val centerX = device.displayWidth / 2
val startY = (device.displayHeight * 0.35).toInt()
val endY = (device.displayHeight * 0.75).toInt()
repeat(steps) {
device.swipe(centerX, startY, centerX, endY, 24)
device.waitForIdle()
SystemClock.sleep(300)
}
}
}
@@ -26,6 +26,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@@ -263,7 +264,9 @@ fun StepNetworkTestScreen(
starting = false
}
},
modifier = Modifier.weight(2f),
modifier = Modifier
.weight(2f)
.testTag("wizard_start_live"),
)
}
}
@@ -22,6 +22,15 @@ class RtmpIngestUrlTest {
assertEquals("rtmp://10.0.2.2:1935/live/match_abc", resolved)
}
@Test
fun preservesLabIngestPort() {
val resolved = RtmpIngestUrl.resolve(
"rtmp://127.0.0.1:11935/live/match_abc",
apiBaseUrl = "http://10.0.2.2:3000",
)
assertEquals("rtmp://10.0.2.2:11935/live/match_abc", resolved)
}
@Test
fun keepsProductionHost() {
val input = "rtmp://stream.matchlivetv.it:1935/live/match_abc"