Corregge cloud-init montato, PublisherOnline multi-nodo e E2E locale-aware.
Senza volume stream-node i nodi Hetzner nascevano senza MediaMTX; sync live usa ora Client.for_session e rtmpconns (MediaMTX 1.20). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -105,6 +105,16 @@ module Mediamtx
|
|||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def list_rtmp_conns
|
||||||
|
response = @conn.get("/v3/rtmpconns/list")
|
||||||
|
return [] unless response.success?
|
||||||
|
|
||||||
|
body = response.body
|
||||||
|
body.is_a?(Hash) ? (body["items"] || []) : []
|
||||||
|
rescue Error, Faraday::Error
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
def online_path_names
|
def online_path_names
|
||||||
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,17 +4,37 @@ module Mediamtx
|
|||||||
module_function
|
module_function
|
||||||
|
|
||||||
def active?(session)
|
def active?(session)
|
||||||
|
return true if rtmp_publisher?(session)
|
||||||
|
|
||||||
active_path?(path_info(session))
|
active_path?(path_info(session))
|
||||||
end
|
end
|
||||||
|
|
||||||
def path_info(session)
|
def path_info(session)
|
||||||
Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# MediaMTX <=1.19: online + source.type=rtmpConn.
|
||||||
|
# MediaMTX 1.20+: online/source spesso null anche con publisher; usare rtmpconns.
|
||||||
def active_path?(info)
|
def active_path?(info)
|
||||||
return false unless info
|
return false unless info
|
||||||
|
return true if info["online"] == true && rtmp_source?(info.dig("source", "type"))
|
||||||
|
|
||||||
info["online"] == true && info.dig("source", "type") == "rtmpConn"
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def rtmp_publisher?(session)
|
||||||
|
path = session.mediamtx_path_name.to_s
|
||||||
|
return false if path.blank?
|
||||||
|
|
||||||
|
Client.for_session(session).list_rtmp_conns.any? do |conn|
|
||||||
|
conn_path = conn["path"].to_s.sub(%r{\A/}, "")
|
||||||
|
next false unless conn_path == path
|
||||||
|
|
||||||
|
state = conn["state"].to_s
|
||||||
|
state.empty? || state == "publish" || state == "idle"
|
||||||
|
end
|
||||||
|
rescue StandardError
|
||||||
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def h264_video?(info)
|
def h264_video?(info)
|
||||||
@@ -26,12 +46,14 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
|
|
||||||
def video_publishing?(session)
|
def video_publishing?(session)
|
||||||
info = path_info(session)
|
return false unless active?(session)
|
||||||
return false unless active_path?(info)
|
|
||||||
# Slate alwaysAvailable ha H264 ma non è il telefono.
|
|
||||||
return false unless info.dig("source", "type") == "rtmpConn"
|
|
||||||
|
|
||||||
|
info = path_info(session)
|
||||||
h264_video?(info)
|
h264_video?(info)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def rtmp_source?(type)
|
||||||
|
type.to_s.match?(/\Artmps?Conn\z/)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ module Mediamtx
|
|||||||
return @session if @session.terminal?
|
return @session if @session.terminal?
|
||||||
|
|
||||||
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
||||||
publisher_online = Mediamtx::PublisherOnline.active_path?(path_info)
|
publisher_online = Mediamtx::PublisherOnline.active?(@session)
|
||||||
|
|
||||||
if publisher_online
|
if publisher_online
|
||||||
clear_publisher_misses!(@session.id)
|
clear_publisher_misses!(@session.id)
|
||||||
@@ -86,7 +86,7 @@ module Mediamtx
|
|||||||
key = format("youtube:slate_disabled:%s", session.id)
|
key = format("youtube:slate_disabled:%s", session.id)
|
||||||
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
|
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
|
||||||
|
|
||||||
Client.new.set_always_available(session, enabled: false)
|
Client.for_session(session).set_always_available(session, enabled: false)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
redis.del(format("youtube:slate_disabled:%s", session.id))
|
redis.del(format("youtube:slate_disabled:%s", session.id))
|
||||||
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
||||||
@@ -95,7 +95,7 @@ module Mediamtx
|
|||||||
def restore_slate_path!(session)
|
def restore_slate_path!(session)
|
||||||
return if session.platform == "matchlivetv"
|
return if session.platform == "matchlivetv"
|
||||||
|
|
||||||
Client.new.set_always_available(session, enabled: true)
|
Client.for_session(session).set_always_available(session, enabled: true)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
@@ -128,7 +128,7 @@ module Mediamtx
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
Client.new.set_path_recording(session, enabled: enabled)
|
Client.for_session(session).set_path_recording(session, enabled: enabled)
|
||||||
redis.set(key, desired, ex: 48.hours.to_i)
|
redis.set(key, desired, ex: 48.hours.to_i)
|
||||||
mark_recording_patch!(session.id)
|
mark_recording_patch!(session.id)
|
||||||
rescue Client::Error => e
|
rescue Client::Error => e
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ module Streams
|
|||||||
network_id = ENV["HCLOUD_NETWORK_ID"].presence
|
network_id = ENV["HCLOUD_NETWORK_ID"].presence
|
||||||
body[:networks] = [network_id.to_i] if network_id
|
body[:networks] = [network_id.to_i] if network_id
|
||||||
user_data = cloud_init_user_data
|
user_data = cloud_init_user_data
|
||||||
body[:user_data] = user_data if user_data.present?
|
body[:user_data] = user_data
|
||||||
|
|
||||||
data = post("servers", body)
|
data = post("servers", body)
|
||||||
server = data["server"] || {}
|
server = data["server"] || {}
|
||||||
@@ -114,9 +114,16 @@ module Streams
|
|||||||
|
|
||||||
def cloud_init_user_data
|
def cloud_init_user_data
|
||||||
path = ENV["HCLOUD_USER_DATA_FILE"].presence
|
path = ENV["HCLOUD_USER_DATA_FILE"].presence
|
||||||
return File.read(path) if path && File.file?(path)
|
if path.present?
|
||||||
|
raise Error, "HCLOUD_USER_DATA_FILE non leggibile nel container: #{path}" unless File.file?(path)
|
||||||
|
|
||||||
ENV["HCLOUD_USER_DATA"].presence
|
return File.read(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
inline = ENV["HCLOUD_USER_DATA"].presence
|
||||||
|
raise Error, "Manca cloud-init: imposta HCLOUD_USER_DATA_FILE (montato) o HCLOUD_USER_DATA" if inline.blank?
|
||||||
|
|
||||||
|
inline
|
||||||
end
|
end
|
||||||
|
|
||||||
def conn
|
def conn
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe Mediamtx::PublisherSync do
|
RSpec.describe Mediamtx::PublisherSync do
|
||||||
let(:user) { User.create!(email: "sync@test.com", name: "Sync", password: "password123", role: "coach") }
|
let(:user) { User.create!(email: "sync@test.com", name: "Sync", password: "Password123", role: "coach") }
|
||||||
let(:club) { Club.create!(name: "Sync Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
let(:club) { Club.create!(name: "Sync Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
let(:team) { club.teams.create!(name: "Under 16", sport: "volleyball") }
|
let(:team) { club.teams.create!(name: "Under 16", sport: "volleyball") }
|
||||||
let!(:match) { team.matches.create!(opponent_name: "Avversario") }
|
let!(:match) { team.matches.create!(opponent_name: "Avversario") }
|
||||||
@@ -20,8 +20,11 @@ RSpec.describe Mediamtx::PublisherSync do
|
|||||||
before do
|
before do
|
||||||
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
||||||
allow(Mediamtx::Client).to receive(:new).and_return(client)
|
allow(Mediamtx::Client).to receive(:new).and_return(client)
|
||||||
|
allow(Mediamtx::Client).to receive(:for_session).and_return(client)
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:path_info).and_return(path_info)
|
allow(Mediamtx::PublisherOnline).to receive(:path_info).and_return(path_info)
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(true)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:rtmp_publisher?).and_return(false)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(true)
|
||||||
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
|
allow_any_instance_of(described_class).to receive(:redis).and_return(redis)
|
||||||
allow(client).to receive(:set_path_recording)
|
allow(client).to receive(:set_path_recording)
|
||||||
end
|
end
|
||||||
@@ -36,6 +39,7 @@ RSpec.describe Mediamtx::PublisherSync do
|
|||||||
|
|
||||||
it "non abilita la registrazione in connecting senza publisher online" do
|
it "non abilita la registrazione in connecting senza publisher online" do
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
|
||||||
path_info["online"] = false
|
path_info["online"] = false
|
||||||
|
|
||||||
described_class.new(session).call
|
described_class.new(session).call
|
||||||
@@ -53,6 +57,7 @@ RSpec.describe Mediamtx::PublisherSync do
|
|||||||
it "non disabilita la registrazione su reconnecting con publisher offline" do
|
it "non disabilita la registrazione su reconnecting con publisher offline" do
|
||||||
session.update!(status: "reconnecting")
|
session.update!(status: "reconnecting")
|
||||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||||
|
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
|
||||||
|
|
||||||
described_class.new(session).call
|
described_class.new(session).call
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,18 @@ services:
|
|||||||
- "18888:8888" # HLS diretto opzionale; in genere basta /hls via edge+NPM
|
- "18888:8888" # HLS diretto opzionale; in genere basta /hls via edge+NPM
|
||||||
|
|
||||||
# Passa tutto .env (HCLOUD_*, STREAM_*, …) ai processi Rails/Sidekiq
|
# Passa tutto .env (HCLOUD_*, STREAM_*, …) ai processi Rails/Sidekiq
|
||||||
|
# Monta cloud-init: HCLOUD_USER_DATA_FILE punta a path host non presente nell'immagine.
|
||||||
rails:
|
rails:
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||||
|
|
||||||
sidekiq:
|
sidekiq:
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||||
|
|
||||||
# ntfy collaudo su porta diversa (evita confusione se si punta per sbaglio l'host)
|
# ntfy collaudo su porta diversa (evita confusione se si punta per sbaglio l'host)
|
||||||
ntfy:
|
ntfy:
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ services:
|
|||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
||||||
|
- ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -230,6 +231,7 @@ services:
|
|||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
||||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
||||||
|
- ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro
|
||||||
|
|
||||||
garage:
|
garage:
|
||||||
image: dxflrs/garage:v1.0.1
|
image: dxflrs/garage:v1.0.1
|
||||||
|
|||||||
@@ -49,10 +49,11 @@ write_files:
|
|||||||
docker pull bluenviron/mediamtx:latest
|
docker pull bluenviron/mediamtx:latest
|
||||||
docker rm -f mediamtx 2>/dev/null || true
|
docker rm -f mediamtx 2>/dev/null || true
|
||||||
# Slate offline (alwaysAvailable) — richiesta da Mediamtx::Client#create_path
|
# Slate offline (alwaysAvailable) — richiesta da Mediamtx::Client#create_path
|
||||||
|
# MediaMTX 1.20+: il publisher RTMP deve matchare canali audio della slate (stereo).
|
||||||
mkdir -p /slates
|
mkdir -p /slates
|
||||||
if [ ! -f /slates/offline.mp4 ]; then
|
if [ ! -f /slates/offline.mp4 ]; then
|
||||||
ffmpeg -y -f lavfi -i color=c=black:s=1280x720:d=2 -f lavfi -i anullsrc=r=44100:cl=stereo \
|
ffmpeg -y -f lavfi -i color=c=black:s=1280x720:d=2 -f lavfi -i anullsrc=r=44100:cl=stereo \
|
||||||
-c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -shortest /slates/offline.mp4
|
-c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -ac 2 -shortest /slates/offline.mp4
|
||||||
fi
|
fi
|
||||||
# Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor
|
# Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor
|
||||||
docker run -d --name mediamtx --restart unless-stopped --network host \
|
docker run -d --name mediamtx --restart unless-stopped --network host \
|
||||||
|
|||||||
+70
-24
@@ -17,11 +17,16 @@ import org.junit.runner.RunWith
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E sul simulatore: login → nuova partita → wizard 3 step → schermata diretta.
|
* E2E sul simulatore: login → nuova partita → wizard 3 step → schermata diretta.
|
||||||
|
* Usa le stringhe dell'app (locale del device), non testo hardcodato IT/EN.
|
||||||
*/
|
*/
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class E2EWizardFlowTest {
|
class E2EWizardFlowTest {
|
||||||
private lateinit var device: UiDevice
|
private lateinit var device: UiDevice
|
||||||
private val pkg = "com.matchlivetv.match_live_tv"
|
private val pkg = "com.matchlivetv.match_live_tv"
|
||||||
|
private val ctx by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
|
||||||
|
|
||||||
|
private fun s(id: Int): String = ctx.getString(id)
|
||||||
|
private fun su(id: Int): String = s(id).uppercase()
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
@@ -32,30 +37,42 @@ class E2EWizardFlowTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun login_newMatch_wizard_reachesBroadcastScreen() {
|
fun login_newMatch_wizard_reachesBroadcastScreen() {
|
||||||
waitForAnyText("Email", "ACCEDI", timeoutMs = 45_000)
|
waitForAnyText(s(R.string.login_email), su(R.string.login_submit), timeoutMs = 45_000)
|
||||||
fillLogin()
|
fillLogin()
|
||||||
waitForText("NUOVA PARTITA", timeoutMs = 45_000)
|
waitForText(su(R.string.matches_new), timeoutMs = 45_000)
|
||||||
tapClickableText("NUOVA PARTITA")
|
tapClickableText(su(R.string.matches_new))
|
||||||
waitForText("Avvia subito", timeoutMs = 15_000)
|
waitForText(s(R.string.sheet_quick_option), timeoutMs = 15_000)
|
||||||
tapClickableText("Avvia subito")
|
tapClickableText(s(R.string.sheet_quick_option))
|
||||||
waitForText("01 · Partita", timeoutMs = 45_000)
|
waitForText(s(R.string.wizard_step_title_match), timeoutMs = 45_000)
|
||||||
scrollDown()
|
scrollDown()
|
||||||
tapClickableText("AVANTI >")
|
tapClickableText(s(R.string.wizard_action_next))
|
||||||
waitForText("02 · Trasmissione", timeoutMs = 45_000)
|
waitForText(s(R.string.wizard_step_title_transmission), timeoutMs = 45_000)
|
||||||
waitForText("Piattaforma", timeoutMs = 30_000)
|
waitForText(s(R.string.wizard_transmission_platform_title), timeoutMs = 30_000)
|
||||||
scrollDown()
|
scrollDown()
|
||||||
tapClickableText("AVANTI >")
|
tapClickableText(s(R.string.wizard_action_next))
|
||||||
waitForText("03 · Test rete", timeoutMs = 45_000)
|
waitForText(s(R.string.wizard_step_title_network), timeoutMs = 45_000)
|
||||||
waitForText("AVVIA TEST RETE", timeoutMs = 30_000)
|
waitForText(s(R.string.wizard_network_test_start_label), timeoutMs = 30_000)
|
||||||
tapClickableText("AVVIA TEST RETE")
|
tapClickableText(s(R.string.wizard_network_test_start_label))
|
||||||
waitForText("INIZIA >", timeoutMs = 30_000)
|
waitForText(s(R.string.wizard_action_start), timeoutMs = 30_000)
|
||||||
waitUntilEnabled("INIZIA >", timeoutMs = 25_000)
|
waitUntilEnabled(s(R.string.wizard_action_start), timeoutMs = 25_000)
|
||||||
scrollDown()
|
scrollDown()
|
||||||
tapClickableText("INIZIA >")
|
tapClickableText(s(R.string.wizard_action_start))
|
||||||
val diretta = waitForText("Diretta", timeoutMs = 60_000)
|
waitForAnyText(
|
||||||
assertNotNull(diretta)
|
s(R.string.broadcast_status_live),
|
||||||
assertNotNull(waitForText("TERMINA DIRETTA", timeoutMs = 30_000))
|
s(R.string.broadcast_status_connecting),
|
||||||
assertNotNull(waitForText("CHIUDI SET", timeoutMs = 20_000))
|
s(R.string.broadcast_status_reconnecting),
|
||||||
|
timeoutMs = 60_000,
|
||||||
|
)
|
||||||
|
// CLOSE SET è un SideIconButton: in hierarchy compare come content-desc, non sempre come text.
|
||||||
|
assertTrue(
|
||||||
|
"Schermata diretta non pronta (né CLOSE SET né End live)",
|
||||||
|
waitForTextOrDesc(
|
||||||
|
timeoutMs = 30_000,
|
||||||
|
s(R.string.broadcast_close_set_button),
|
||||||
|
s(R.string.score_action_close_set),
|
||||||
|
s(R.string.broadcast_terminate_cd),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun grantRuntimePermissions() {
|
private fun grantRuntimePermissions() {
|
||||||
@@ -69,11 +86,10 @@ class E2EWizardFlowTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun launchApp() {
|
private fun launchApp() {
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
val intent = ctx.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
||||||
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
|
||||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
} ?: error("Launch intent mancante per $pkg")
|
} ?: error("Launch intent mancante per $pkg")
|
||||||
context.startActivity(intent)
|
ctx.startActivity(intent)
|
||||||
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
|
device.wait(Until.hasObject(By.pkg(pkg).depth(0)), 15_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +97,10 @@ class E2EWizardFlowTest {
|
|||||||
val fields = device.wait(Until.findObjects(By.clazz("android.widget.EditText")), 15_000)
|
val fields = device.wait(Until.findObjects(By.clazz("android.widget.EditText")), 15_000)
|
||||||
if (fields.size < 2) error("Campi login non trovati (${fields.size})")
|
if (fields.size < 2) error("Campi login non trovati (${fields.size})")
|
||||||
pasteIntoField(fields[0], "coach@matchlivetv.test")
|
pasteIntoField(fields[0], "coach@matchlivetv.test")
|
||||||
pasteIntoField(fields[1], "password123")
|
pasteIntoField(fields[1], "Password123")
|
||||||
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
|
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
|
||||||
|
// Preferisci il bottone (ultimo match uppercase), non il titolo.
|
||||||
|
tapLastMatchingText(su(R.string.login_submit))
|
||||||
device.waitForIdle()
|
device.waitForIdle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +129,34 @@ class E2EWizardFlowTest {
|
|||||||
error("Nessuno dei testi trovato: ${texts.joinToString()}")
|
error("Nessuno dei testi trovato: ${texts.joinToString()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun waitForTextOrDesc(timeoutMs: Long, vararg labels: String): Boolean {
|
||||||
|
val deadline = SystemClock.elapsedRealtime() + timeoutMs
|
||||||
|
while (SystemClock.elapsedRealtime() < deadline) {
|
||||||
|
for (label in labels) {
|
||||||
|
if (device.hasObject(By.text(label)) || device.hasObject(By.desc(label))) return true
|
||||||
|
}
|
||||||
|
SystemClock.sleep(250)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tapLastMatchingText(text: String) {
|
||||||
|
val nodes = device.findObjects(By.text(text))
|
||||||
|
val target = nodes.lastOrNull { it.isClickable }
|
||||||
|
?: nodes.lastOrNull()?.let { label ->
|
||||||
|
var node: UiObject2? = label
|
||||||
|
repeat(6) {
|
||||||
|
val current = node ?: return@repeat
|
||||||
|
if (current.isClickable) return@let current
|
||||||
|
node = current.parent
|
||||||
|
}
|
||||||
|
label
|
||||||
|
}
|
||||||
|
?: error("Testo non trovato: $text")
|
||||||
|
target.click()
|
||||||
|
device.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
private fun tapClickableText(text: String) {
|
private fun tapClickableText(text: String) {
|
||||||
device.findObject(By.text(text).clickable(true))?.let { node ->
|
device.findObject(By.text(text).clickable(true))?.let { node ->
|
||||||
node.click()
|
node.click()
|
||||||
|
|||||||
Reference in New Issue
Block a user