Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a0ca8ef1c | ||
|
|
714602f3da |
@@ -105,6 +105,16 @@ module Mediamtx
|
||||
[]
|
||||
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
|
||||
Set.new(list_paths.filter_map { |item| item["name"] if item["online"] })
|
||||
end
|
||||
|
||||
@@ -4,17 +4,37 @@ module Mediamtx
|
||||
module_function
|
||||
|
||||
def active?(session)
|
||||
return true if rtmp_publisher?(session)
|
||||
|
||||
active_path?(path_info(session))
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
# MediaMTX <=1.19: online + source.type=rtmpConn.
|
||||
# MediaMTX 1.20+: online/source spesso null anche con publisher; usare rtmpconns.
|
||||
def active_path?(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
|
||||
|
||||
def h264_video?(info)
|
||||
@@ -26,12 +46,14 @@ module Mediamtx
|
||||
end
|
||||
|
||||
def video_publishing?(session)
|
||||
info = path_info(session)
|
||||
return false unless active_path?(info)
|
||||
# Slate alwaysAvailable ha H264 ma non è il telefono.
|
||||
return false unless info.dig("source", "type") == "rtmpConn"
|
||||
return false unless active?(session)
|
||||
|
||||
info = path_info(session)
|
||||
h264_video?(info)
|
||||
end
|
||||
|
||||
def rtmp_source?(type)
|
||||
type.to_s.match?(/\Artmps?Conn\z/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,7 +12,7 @@ module Mediamtx
|
||||
return @session if @session.terminal?
|
||||
|
||||
path_info = Mediamtx::PublisherOnline.path_info(@session)
|
||||
publisher_online = Mediamtx::PublisherOnline.active_path?(path_info)
|
||||
publisher_online = Mediamtx::PublisherOnline.active?(@session)
|
||||
|
||||
if publisher_online
|
||||
clear_publisher_misses!(@session.id)
|
||||
@@ -86,7 +86,7 @@ module Mediamtx
|
||||
key = format("youtube:slate_disabled:%s", session.id)
|
||||
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
|
||||
redis.del(format("youtube:slate_disabled:%s", session.id))
|
||||
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
||||
@@ -95,7 +95,7 @@ module Mediamtx
|
||||
def restore_slate_path!(session)
|
||||
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
|
||||
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
||||
end
|
||||
@@ -128,7 +128,7 @@ module Mediamtx
|
||||
return
|
||||
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)
|
||||
mark_recording_patch!(session.id)
|
||||
rescue Client::Error => e
|
||||
|
||||
@@ -40,7 +40,7 @@ module Streams
|
||||
network_id = ENV["HCLOUD_NETWORK_ID"].presence
|
||||
body[:networks] = [network_id.to_i] if network_id
|
||||
user_data = cloud_init_user_data
|
||||
body[:user_data] = user_data if user_data.present?
|
||||
body[:user_data] = user_data
|
||||
|
||||
data = post("servers", body)
|
||||
server = data["server"] || {}
|
||||
@@ -114,9 +114,16 @@ module Streams
|
||||
|
||||
def cloud_init_user_data
|
||||
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
|
||||
|
||||
def conn
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require "rails_helper"
|
||||
|
||||
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(:team) { club.teams.create!(name: "Under 16", sport: "volleyball") }
|
||||
let!(:match) { team.matches.create!(opponent_name: "Avversario") }
|
||||
@@ -20,8 +20,11 @@ RSpec.describe Mediamtx::PublisherSync do
|
||||
before do
|
||||
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
||||
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(: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(client).to receive(:set_path_recording)
|
||||
end
|
||||
@@ -36,6 +39,7 @@ RSpec.describe Mediamtx::PublisherSync 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?).and_return(false)
|
||||
path_info["online"] = false
|
||||
|
||||
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
|
||||
session.update!(status: "reconnecting")
|
||||
allow(Mediamtx::PublisherOnline).to receive(:active_path?).and_return(false)
|
||||
allow(Mediamtx::PublisherOnline).to receive(:active?).and_return(false)
|
||||
|
||||
described_class.new(session).call
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Copia come infra/.env sul container di collaudo e genera i secret.
|
||||
# cp .env.collaudo.example .env && openssl rand -hex 32 (ripeti per ogni CHANGE_ME)
|
||||
|
||||
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||
SECRET_KEY_BASE=CHANGE_ME_openssl_rand_hex_64
|
||||
JWT_SECRET=CHANGE_ME_openssl_rand_hex_32
|
||||
MEDIAMTX_WEBHOOK_SECRET=CHANGE_ME_openssl_rand_hex_32
|
||||
|
||||
# RTMP collaudo: porta host 11935 (vedi docker-compose.collaudo.yml).
|
||||
# LAN: rtmp://192.168.1.157:11935
|
||||
# WAN (opzionale): apri SOLO 11935 sul router → .157 — NON toccare :1935 di produzione.
|
||||
MEDIAMTX_RTMP_URL=rtmp://192.168.1.157:11935
|
||||
|
||||
APP_PUBLIC_URL=https://collaudo.matchlivetv.it
|
||||
HLS_PUBLIC_URL=https://collaudo.matchlivetv.it/hls
|
||||
CORS_ORIGINS=https://collaudo.matchlivetv.it
|
||||
ALLOWED_HOSTS=collaudo.matchlivetv.it,192.168.1.157,localhost
|
||||
YOUTUBE_REDIRECT_URI=https://collaudo.matchlivetv.it/api/v1/youtube/callback
|
||||
|
||||
YOUTUBE_CLIENT_ID=
|
||||
YOUTUBE_CLIENT_SECRET=
|
||||
YOUTUBE_PLATFORM_REFRESH_TOKEN=
|
||||
|
||||
# Stripe: preferisci chiavi Test su collaudo
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=
|
||||
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=
|
||||
STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID=
|
||||
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
|
||||
STRIPE_PREMIUM_LIGHT_PRICE_ID=
|
||||
STRIPE_PREMIUM_FULL_PRICE_ID=
|
||||
|
||||
RAILS_LOG_LEVEL=info
|
||||
|
||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||
PRIVACY_CONTROLLER_VAT=
|
||||
|
||||
MAILER_FROM=Match Live TV Collaudo <noreply@matchlivetv.it>
|
||||
SMTP_ADDRESS=
|
||||
SMTP_PORT=465
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_AUTH=plain
|
||||
SMTP_SSL=true
|
||||
SMTP_STARTTLS=false
|
||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||
|
||||
MATCHLIVETV_VIDEOS_ROOT=/media/videos/matchlivetv
|
||||
|
||||
REPLAY_STORAGE_ENDPOINT=http://garage:3900
|
||||
REPLAY_STORAGE_BUCKET=matchlivetv-replays
|
||||
REPLAY_STORAGE_REGION=garage
|
||||
REPLAY_STORAGE_ACCESS_KEY_ID=
|
||||
REPLAY_STORAGE_SECRET_ACCESS_KEY=
|
||||
REPLAY_STORAGE_FORCE_PATH_STYLE=true
|
||||
REPLAY_MEDIA_PUBLIC_BASE_URL=https://collaudo.matchlivetv.it/media
|
||||
REPLAY_MEDIA_REDIRECT=true
|
||||
|
||||
RAILS_MAX_THREADS=5
|
||||
|
||||
OPS_HTTP_RAILS_URL=http://edge/up
|
||||
OPS_HTTP_PUBLIC_INTERVAL_SECS=900
|
||||
NTFY_PUBLIC_URL=http://192.168.1.157:18090
|
||||
OPS_NTFY_URL=
|
||||
OPS_NTFY_TOKEN=
|
||||
OPS_ALERT_EMAIL=
|
||||
OPS_NOTIFY_SEVERITIES=critical,warning
|
||||
OPS_NOTIFY_COOLDOWN_MINUTES=30
|
||||
OPS_HEALTH_INTERVAL_SECS=180
|
||||
OPS_HEALTH_TOKEN=
|
||||
OPS_DISK_WARN_PERCENT=80
|
||||
OPS_DISK_CRIT_PERCENT=90
|
||||
OPS_RECORDINGS_WARN_GB=20
|
||||
OPS_RECORDINGS_CRIT_GB=50
|
||||
OPS_SIDEKIQ_STALE_SECS=300
|
||||
OPS_LOG_SUBSCRIBER=false
|
||||
|
||||
SENTRY_DSN=
|
||||
|
||||
# Stream autoscale — token Hetzner da compilare; kill-switch cloud off finché WG non è ok
|
||||
HCLOUD_TOKEN=
|
||||
HCLOUD_LOCATION=nbg1
|
||||
HCLOUD_SERVER_TYPE=cpx12
|
||||
HCLOUD_IMAGE=debian-12
|
||||
HCLOUD_SSH_KEY=matchlivetv-stream-hetzner
|
||||
HCLOUD_NETWORK_ID=
|
||||
HCLOUD_USER_DATA_FILE=/opt/matchlivetv/infra/stream-node/cloud-init.yaml
|
||||
STREAM_DNS_ZONE=mltv-stream.net
|
||||
STREAM_DNS_TTL=60
|
||||
STREAM_CLOUD_DNS_SUFFIX=mltv-stream.net
|
||||
STREAM_CLOUD_MAX_PUBLISHERS=4
|
||||
STREAM_NODE_ENV=collaudo
|
||||
STREAM_CLOUD_PROVIDER=local_lab
|
||||
RELAY_MAX_CONCURRENT=4
|
||||
YOUTUBE_RELAY_WORKER=1
|
||||
|
||||
STREAM_AUTOSCALE_ENABLED=0
|
||||
STREAM_AUTOSCALE_KIND=lab
|
||||
STREAM_AUTOSCALE_ALLOW_CLOUD=0
|
||||
STREAM_AUTOSCALE_SOFT_FREE_SLOTS=2
|
||||
STREAM_AUTOSCALE_WARM_SPARE=1
|
||||
STREAM_AUTOSCALE_IDLE_MINUTES=30
|
||||
STREAM_AUTOSCALE_MAX_NODES=5
|
||||
STREAM_AUTOSCALE_NODE_EUR_PER_HOUR=0.015
|
||||
STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR=40
|
||||
STREAM_OVERFLOW_ORPHAN_HOURS=3
|
||||
@@ -0,0 +1,35 @@
|
||||
# Collaudo Proxmox (es. 192.168.1.157) — overlay su docker-compose.prod.yml
|
||||
#
|
||||
# Uso:
|
||||
# export COMPOSE_FILE=docker-compose.prod.yml:docker-compose.collaudo.yml
|
||||
# docker compose --env-file .env up -d --build
|
||||
#
|
||||
# Porte host MediaMTX diverse da produzione per non competere sullo stesso IP pubblico:
|
||||
# prod WAN :1935 → 192.168.1.146
|
||||
# collaudo LAN/WAN opzionale :11935 → 192.168.1.157
|
||||
# HTTP resta :3000 (NPM → collaudo.matchlivetv.it); IP LAN diversi = nessun conflitto.
|
||||
|
||||
services:
|
||||
mediamtx:
|
||||
ports: !override
|
||||
- "11935:1935" # RTMP collaudo (NON usare WAN :1935 di produzione)
|
||||
- "18888:8888" # HLS diretto opzionale; in genere basta /hls via edge+NPM
|
||||
|
||||
# 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:
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./stream-node:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
sidekiq:
|
||||
env_file:
|
||||
- .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:
|
||||
ports: !override
|
||||
- "18090:80"
|
||||
@@ -135,6 +135,7 @@ services:
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
||||
- ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
||||
interval: 15s
|
||||
@@ -230,6 +231,7 @@ services:
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/recordings:/recordings
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/active_storage:/app/storage
|
||||
- ${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}/log:/app/log
|
||||
- ${HCLOUD_USER_DATA_HOST_DIR:-./stream-node}:/opt/matchlivetv/infra/stream-node:ro
|
||||
|
||||
garage:
|
||||
image: dxflrs/garage:v1.0.1
|
||||
|
||||
@@ -5,14 +5,14 @@ set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
export COMPOSE_FILE="docker-compose.prod.yml"
|
||||
export COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||
export ENV_FILE="${ROOT}/.env"
|
||||
export CREDS_FILE="${ROOT}/garage/prod-credentials.env"
|
||||
|
||||
bash "${ROOT}/scripts/ensure_garage_prod_config.sh"
|
||||
|
||||
echo "Avvio Garage..."
|
||||
docker compose -f docker-compose.prod.yml --env-file .env up -d garage
|
||||
echo "Avvio Garage (COMPOSE_FILE=${COMPOSE_FILE})..."
|
||||
docker compose --env-file .env up -d garage
|
||||
|
||||
videos_root="${MATCHLIVETV_VIDEOS_ROOT:-/media/videos/matchlivetv}"
|
||||
capacity_df="${videos_root}"
|
||||
@@ -27,8 +27,8 @@ echo "Capacità nodo Garage: ${GARAGE_NODE_CAPACITY} (disco ~${avail_gb}G, riser
|
||||
bash "${ROOT}/scripts/setup_garage_replays.sh"
|
||||
|
||||
echo "Riavvio Rails e Sidekiq..."
|
||||
docker compose -f docker-compose.prod.yml --env-file .env up -d rails sidekiq
|
||||
docker compose --env-file .env up -d rails sidekiq
|
||||
|
||||
echo "Verifica storage:"
|
||||
docker compose -f docker-compose.prod.yml --env-file .env exec -T rails bundle exec rails runner \
|
||||
docker compose --env-file .env exec -T rails bundle exec rails runner \
|
||||
'puts({local: MatchLiveTv.replay_storage_local?, backend: Recordings::Storage.new.backend_name}.inspect)'
|
||||
|
||||
@@ -49,10 +49,11 @@ write_files:
|
||||
docker pull bluenviron/mediamtx:latest
|
||||
docker rm -f mediamtx 2>/dev/null || true
|
||||
# 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
|
||||
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 \
|
||||
-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
|
||||
# Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor
|
||||
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.
|
||||
* Usa le stringhe dell'app (locale del device), non testo hardcodato IT/EN.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class E2EWizardFlowTest {
|
||||
private lateinit var device: UiDevice
|
||||
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
|
||||
fun setUp() {
|
||||
@@ -32,30 +37,42 @@ class E2EWizardFlowTest {
|
||||
|
||||
@Test
|
||||
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()
|
||||
waitForText("NUOVA PARTITA", timeoutMs = 45_000)
|
||||
tapClickableText("NUOVA PARTITA")
|
||||
waitForText("Avvia subito", timeoutMs = 15_000)
|
||||
tapClickableText("Avvia subito")
|
||||
waitForText("01 · Partita", timeoutMs = 45_000)
|
||||
waitForText(su(R.string.matches_new), timeoutMs = 45_000)
|
||||
tapClickableText(su(R.string.matches_new))
|
||||
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)
|
||||
scrollDown()
|
||||
tapClickableText("AVANTI >")
|
||||
waitForText("02 · Trasmissione", timeoutMs = 45_000)
|
||||
waitForText("Piattaforma", timeoutMs = 30_000)
|
||||
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("AVANTI >")
|
||||
waitForText("03 · Test rete", timeoutMs = 45_000)
|
||||
waitForText("AVVIA TEST RETE", timeoutMs = 30_000)
|
||||
tapClickableText("AVVIA TEST RETE")
|
||||
waitForText("INIZIA >", timeoutMs = 30_000)
|
||||
waitUntilEnabled("INIZIA >", timeoutMs = 25_000)
|
||||
tapClickableText(s(R.string.wizard_action_next))
|
||||
waitForText(s(R.string.wizard_step_title_network), 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("INIZIA >")
|
||||
val diretta = waitForText("Diretta", timeoutMs = 60_000)
|
||||
assertNotNull(diretta)
|
||||
assertNotNull(waitForText("TERMINA DIRETTA", timeoutMs = 30_000))
|
||||
assertNotNull(waitForText("CHIUDI SET", timeoutMs = 20_000))
|
||||
tapClickableText(s(R.string.wizard_action_start))
|
||||
waitForAnyText(
|
||||
s(R.string.broadcast_status_live),
|
||||
s(R.string.broadcast_status_connecting),
|
||||
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() {
|
||||
@@ -69,11 +86,10 @@ class E2EWizardFlowTest {
|
||||
}
|
||||
|
||||
private fun launchApp() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val intent = context.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
||||
val intent = ctx.packageManager.getLaunchIntentForPackage(pkg)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
} ?: error("Launch intent mancante per $pkg")
|
||||
context.startActivity(intent)
|
||||
ctx.startActivity(intent)
|
||||
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)
|
||||
if (fields.size < 2) error("Campi login non trovati (${fields.size})")
|
||||
pasteIntoField(fields[0], "coach@matchlivetv.test")
|
||||
pasteIntoField(fields[1], "password123")
|
||||
pasteIntoField(fields[1], "Password123")
|
||||
device.pressKeyCode(KeyEvent.KEYCODE_ENTER)
|
||||
// Preferisci il bottone (ultimo match uppercase), non il titolo.
|
||||
tapLastMatchingText(su(R.string.login_submit))
|
||||
device.waitForIdle()
|
||||
}
|
||||
|
||||
@@ -111,6 +129,34 @@ class E2EWizardFlowTest {
|
||||
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) {
|
||||
device.findObject(By.text(text).clickable(true))?.let { node ->
|
||||
node.click()
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Helper collaudo Proxmox: forza sempre prod + overlay porte isolate.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
INFRA="${MATCHLIVETV_INFRA:-$ROOT/infra}"
|
||||
if [ -d /opt/matchlivetv/infra ]; then
|
||||
INFRA=/opt/matchlivetv/infra
|
||||
fi
|
||||
cd "$INFRA"
|
||||
export COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml:docker-compose.collaudo.yml}"
|
||||
exec docker compose --env-file .env "$@"
|
||||
Reference in New Issue
Block a user