Merge pull request #2677 from vitorpamplona/claude/audio-transmission-tests-zKzlB
Add prod nostrnests.com audio transmission diagnostic tests
This commit is contained in:
@@ -98,4 +98,10 @@ tasks.withType<Test>().configureEach {
|
|||||||
System.getProperty("nestsInteropMoqRev")?.let { systemProperty("nestsInteropMoqRev", it) }
|
System.getProperty("nestsInteropMoqRev")?.let { systemProperty("nestsInteropMoqRev", it) }
|
||||||
System.getProperty("nestsInteropExternal")?.let { systemProperty("nestsInteropExternal", it) }
|
System.getProperty("nestsInteropExternal")?.let { systemProperty("nestsInteropExternal", it) }
|
||||||
System.getProperty("nestsInteropDebug")?.let { systemProperty("nestsInteropDebug", it) }
|
System.getProperty("nestsInteropDebug")?.let { systemProperty("nestsInteropDebug", it) }
|
||||||
|
// Opt-in for tests that hit the real nostrnests.com infrastructure
|
||||||
|
// (see NostrnestsProdAudioTransmissionTest). Forwarded the same way
|
||||||
|
// the harness flags are.
|
||||||
|
System.getProperty("nestsProd")?.let { systemProperty("nestsProd", it) }
|
||||||
|
System.getProperty("nestsProdEndpoint")?.let { systemProperty("nestsProdEndpoint", it) }
|
||||||
|
System.getProperty("nestsProdAuth")?.let { systemProperty("nestsProdAuth", it) }
|
||||||
}
|
}
|
||||||
|
|||||||
+529
@@ -0,0 +1,529 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.nestsclient.interop
|
||||||
|
|
||||||
|
import com.vitorpamplona.nestsclient.NestsRoomConfig
|
||||||
|
import com.vitorpamplona.nestsclient.OkHttpNestsClient
|
||||||
|
import com.vitorpamplona.nestsclient.connectNestsListener
|
||||||
|
import com.vitorpamplona.nestsclient.connectNestsSpeaker
|
||||||
|
import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.take
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import org.junit.Assume.assumeTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
import kotlin.test.fail
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production-relay diagnostic suite. Drives the EXACT same speaker /
|
||||||
|
* listener entry points the Amethyst app uses
|
||||||
|
* ([connectNestsSpeaker] + [connectNestsListener] +
|
||||||
|
* [OkHttpNestsClient] + [QuicWebTransportFactory]) — pointed at the
|
||||||
|
* real `nostrnests.com` infrastructure instead of the local Docker
|
||||||
|
* harness — so we can isolate WHERE audio transmission breaks between
|
||||||
|
* two real users.
|
||||||
|
*
|
||||||
|
* The four tests progressively narrow the failure surface:
|
||||||
|
*
|
||||||
|
* 1. [auth_minting_works_for_publish_and_listen_paths] — does
|
||||||
|
* `POST https://moq-auth.nostrnests.com/auth` accept our NIP-98
|
||||||
|
* signature for both `publish=true` (speaker) and `publish=false`
|
||||||
|
* (audience)? Failures here are auth / DNS / TLS, NOT transport.
|
||||||
|
* 2. [same_user_speaker_and_listener_round_trip] — one keypair drives
|
||||||
|
* both sides. Sidesteps the question of how nostrnests gates
|
||||||
|
* `publish=true` cross-user, so a green here proves
|
||||||
|
* QUIC + WebTransport + moq-lite + announce/subscribe all work
|
||||||
|
* against the production cert + ALPN.
|
||||||
|
* 3. [two_users_speaker_publishes_listener_subscribes] — user A is
|
||||||
|
* host + speaker, user B (a different keypair) is the audience.
|
||||||
|
* This is the actual app scenario. If #2 passes and this fails,
|
||||||
|
* the bug is in cross-user authorisation / policy, not transport.
|
||||||
|
* 4. [sustained_real_time_cadence_two_users] — same pair as #3 but
|
||||||
|
* pushes 100 frames at the production 20 ms Opus cadence (~2 s of
|
||||||
|
* audio). Catches flow-control / late-subscriber / per-track
|
||||||
|
* buffer issues that only show under steady traffic.
|
||||||
|
*
|
||||||
|
* Reused from the production app (same wire, same code):
|
||||||
|
* - [OkHttpNestsClient] — NIP-98-signed `POST /auth`
|
||||||
|
* - [QuicWebTransportFactory] — pure-Kotlin QUIC v1 + HTTP/3 + WT,
|
||||||
|
* `JdkCertificateValidator` for the real Let's Encrypt-style cert
|
||||||
|
* - [connectNestsSpeaker] / [connectNestsListener] — full state
|
||||||
|
* machine including SUBSCRIBE_OK settle delay, per-frame group
|
||||||
|
* framing, etc.
|
||||||
|
* - [com.vitorpamplona.nestsclient.audio.NestMoqLiteBroadcaster] —
|
||||||
|
* the encode-and-publish loop, identical to what the Android UI
|
||||||
|
* wires into `AudioRecordCapture` + `MediaCodecOpusEncoder`.
|
||||||
|
*
|
||||||
|
* Substitutes (only because the JVM has no `android.media.*`):
|
||||||
|
* - [InteropDriverCapture] for `AudioRecordCapture` — same
|
||||||
|
* [com.vitorpamplona.nestsclient.audio.AudioCapture] interface.
|
||||||
|
* - [InteropStubEncoder] for `MediaCodecOpusEncoder` — emits a
|
||||||
|
* deterministic byte-prefixed payload so we can verify integrity
|
||||||
|
* end-to-end. Real Opus packets are also byte-array opaque from
|
||||||
|
* the moq-lite layer's perspective; the relay never inspects the
|
||||||
|
* payload, so substituting the encoder doesn't change the wire
|
||||||
|
* behaviour we're testing.
|
||||||
|
*
|
||||||
|
* No Nostr events. Each test mints a synthetic [NestsRoomConfig] with
|
||||||
|
* a random `roomId` so tests don't collide; the host pubkey is the
|
||||||
|
* speaker's identity. nostrnests's auth sidecar SHOULD accept this
|
||||||
|
* (the JWT just authorises a namespace + publish flag) — if it
|
||||||
|
* doesn't, that's itself a finding.
|
||||||
|
*
|
||||||
|
* Skipped by default. Enable with `-DnestsProd=true`. Override URLs
|
||||||
|
* with `-DnestsProdEndpoint=...` and `-DnestsProdAuth=...` if you
|
||||||
|
* point at a staging deployment.
|
||||||
|
*/
|
||||||
|
class NostrnestsProdAudioTransmissionTest {
|
||||||
|
@Test
|
||||||
|
fun auth_minting_works_for_publish_and_listen_paths() =
|
||||||
|
runBlocking {
|
||||||
|
assumeProd()
|
||||||
|
val scope = "auth-only"
|
||||||
|
val signer = NostrSignerInternal(KeyPair())
|
||||||
|
val room = freshRoom(hostPubkey = signer.pubKey)
|
||||||
|
val httpClient = OkHttpNestsClient { OkHttpClient() }
|
||||||
|
|
||||||
|
InteropDebug.checkpoint(
|
||||||
|
scope,
|
||||||
|
"auth=${room.authBaseUrl} endpoint=${room.endpoint} ns=${room.moqNamespace()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
val publishToken =
|
||||||
|
InteropDebug.stepSuspending(scope, "mintToken(publish=true)") {
|
||||||
|
httpClient.mintToken(room = room, publish = true, signer = signer)
|
||||||
|
}
|
||||||
|
assertTrue(publishToken.isNotBlank(), "publish JWT must be non-empty")
|
||||||
|
assertTrue(publishToken.count { it == '.' } == 2, "JWT must have header.payload.signature")
|
||||||
|
|
||||||
|
val listenToken =
|
||||||
|
InteropDebug.stepSuspending(scope, "mintToken(publish=false)") {
|
||||||
|
httpClient.mintToken(room = room, publish = false, signer = signer)
|
||||||
|
}
|
||||||
|
assertTrue(listenToken.isNotBlank(), "listen JWT must be non-empty")
|
||||||
|
assertTrue(listenToken.count { it == '.' } == 2, "JWT must have header.payload.signature")
|
||||||
|
|
||||||
|
InteropDebug.checkpoint(scope, "publish JWT=${publishToken.take(24)}…")
|
||||||
|
InteropDebug.checkpoint(scope, "listen JWT=${listenToken.take(24)}…")
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun same_user_speaker_and_listener_round_trip() =
|
||||||
|
runBlocking {
|
||||||
|
assumeProd()
|
||||||
|
val scope = "same-user"
|
||||||
|
|
||||||
|
val signer = NostrSignerInternal(KeyPair())
|
||||||
|
val room = freshRoom(hostPubkey = signer.pubKey)
|
||||||
|
val httpClient = OkHttpNestsClient { OkHttpClient() }
|
||||||
|
// Default JdkCertificateValidator — production cert is
|
||||||
|
// real, no permissive validator like the Docker harness.
|
||||||
|
val transport = QuicWebTransportFactory()
|
||||||
|
|
||||||
|
val supervisor = SupervisorJob()
|
||||||
|
val pumpScope = CoroutineScope(supervisor + Dispatchers.IO)
|
||||||
|
|
||||||
|
val capture = InteropDriverCapture()
|
||||||
|
val encoder = InteropStubEncoder(prefix = "PROD-A-".encodeToByteArray())
|
||||||
|
|
||||||
|
try {
|
||||||
|
val speaker =
|
||||||
|
InteropDebug.stepSuspending(scope, "connectNestsSpeaker") {
|
||||||
|
connectNestsSpeaker(
|
||||||
|
httpClient = httpClient,
|
||||||
|
transport = transport,
|
||||||
|
scope = pumpScope,
|
||||||
|
room = room,
|
||||||
|
signer = signer,
|
||||||
|
speakerPubkeyHex = signer.pubKey,
|
||||||
|
captureFactory = { capture },
|
||||||
|
encoderFactory = { encoder },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InteropDebug.assertSpeakerReached(scope, "Connected", speaker.state.value)
|
||||||
|
|
||||||
|
val broadcast =
|
||||||
|
InteropDebug.stepSuspending(scope, "speaker.startBroadcasting") {
|
||||||
|
speaker.startBroadcasting()
|
||||||
|
}
|
||||||
|
InteropDebug.assertSpeakerReached(scope, "Broadcasting", speaker.state.value)
|
||||||
|
|
||||||
|
val listener =
|
||||||
|
InteropDebug.stepSuspending(scope, "connectNestsListener") {
|
||||||
|
connectNestsListener(
|
||||||
|
httpClient = httpClient,
|
||||||
|
transport = transport,
|
||||||
|
scope = pumpScope,
|
||||||
|
room = room,
|
||||||
|
signer = signer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InteropDebug.assertListenerReached(scope, "Connected", listener.state.value)
|
||||||
|
|
||||||
|
val subscription =
|
||||||
|
InteropDebug.stepSuspending(scope, "listener.subscribeSpeaker(self)") {
|
||||||
|
listener.subscribeSpeaker(signer.pubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start collecting BEFORE pushing — moq-lite group frames
|
||||||
|
// dropped before .objects.collect() runs are gone.
|
||||||
|
val received =
|
||||||
|
async(pumpScope.coroutineContext) {
|
||||||
|
withTimeoutOrNull(RECEIVE_TIMEOUT_MS) {
|
||||||
|
subscription.objects.take(N_FRAMES).toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settle delay so SUBSCRIBE_OK is in flight before the
|
||||||
|
// publisher's first send (otherwise TrackPublisher.send
|
||||||
|
// returns false and the object id rolls back).
|
||||||
|
delay(SUBSCRIBE_SETTLE_MS)
|
||||||
|
|
||||||
|
InteropDebug.checkpoint(scope, "pushing $N_FRAMES frames")
|
||||||
|
for (i in 0 until N_FRAMES) {
|
||||||
|
capture.push(byteValue = i)
|
||||||
|
delay(FRAME_SPACING_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
val frames =
|
||||||
|
InteropDebug.stepSuspending(scope, "await $N_FRAMES frames") {
|
||||||
|
received.await()
|
||||||
|
}
|
||||||
|
if (frames == null) {
|
||||||
|
fail(
|
||||||
|
"[$scope] Did not receive $N_FRAMES moq-lite frames within ${RECEIVE_TIMEOUT_MS}ms — " +
|
||||||
|
"speaker=${InteropDebug.describe(speaker.state.value)}, " +
|
||||||
|
"listener=${InteropDebug.describe(listener.state.value)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(N_FRAMES, frames.size, "expected $N_FRAMES objects")
|
||||||
|
frames.forEachIndexed { idx, obj ->
|
||||||
|
assertContentEquals(
|
||||||
|
"PROD-A-".encodeToByteArray() + byteArrayOf(idx.toByte()),
|
||||||
|
obj.payload,
|
||||||
|
"payload at index $idx round-tripped through nostrnests.com",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching { subscription.unsubscribe() }
|
||||||
|
runCatching { broadcast.close() }
|
||||||
|
runCatching { listener.close() }
|
||||||
|
runCatching { speaker.close() }
|
||||||
|
} finally {
|
||||||
|
capture.stop()
|
||||||
|
supervisor.cancelAndJoin()
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun two_users_speaker_publishes_listener_subscribes() =
|
||||||
|
runBlocking {
|
||||||
|
assumeProd()
|
||||||
|
val scope = "two-users"
|
||||||
|
|
||||||
|
val hostSigner = NostrSignerInternal(KeyPair())
|
||||||
|
val audienceSigner = NostrSignerInternal(KeyPair())
|
||||||
|
// Host pubkey defines the room namespace and the broadcast
|
||||||
|
// suffix. Audience uses a DIFFERENT keypair to mint a
|
||||||
|
// listen-only JWT for the same namespace — this is the
|
||||||
|
// exact app flow when user B joins user A's room.
|
||||||
|
val room = freshRoom(hostPubkey = hostSigner.pubKey)
|
||||||
|
|
||||||
|
val httpClient = OkHttpNestsClient { OkHttpClient() }
|
||||||
|
val transport = QuicWebTransportFactory()
|
||||||
|
|
||||||
|
val supervisor = SupervisorJob()
|
||||||
|
val pumpScope = CoroutineScope(supervisor + Dispatchers.IO)
|
||||||
|
|
||||||
|
val capture = InteropDriverCapture()
|
||||||
|
val encoder = InteropStubEncoder(prefix = "PROD-B-".encodeToByteArray())
|
||||||
|
|
||||||
|
InteropDebug.checkpoint(
|
||||||
|
scope,
|
||||||
|
"host=${hostSigner.pubKey.take(8)}… audience=${audienceSigner.pubKey.take(8)}… " +
|
||||||
|
"ns=${room.moqNamespace()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val speaker =
|
||||||
|
InteropDebug.stepSuspending(scope, "host: connectNestsSpeaker") {
|
||||||
|
connectNestsSpeaker(
|
||||||
|
httpClient = httpClient,
|
||||||
|
transport = transport,
|
||||||
|
scope = pumpScope,
|
||||||
|
room = room,
|
||||||
|
signer = hostSigner,
|
||||||
|
speakerPubkeyHex = hostSigner.pubKey,
|
||||||
|
captureFactory = { capture },
|
||||||
|
encoderFactory = { encoder },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InteropDebug.assertSpeakerReached(scope, "Connected", speaker.state.value)
|
||||||
|
|
||||||
|
val broadcast =
|
||||||
|
InteropDebug.stepSuspending(scope, "host: startBroadcasting") {
|
||||||
|
speaker.startBroadcasting()
|
||||||
|
}
|
||||||
|
InteropDebug.assertSpeakerReached(scope, "Broadcasting", speaker.state.value)
|
||||||
|
|
||||||
|
val listener =
|
||||||
|
InteropDebug.stepSuspending(scope, "audience: connectNestsListener") {
|
||||||
|
connectNestsListener(
|
||||||
|
httpClient = httpClient,
|
||||||
|
transport = transport,
|
||||||
|
scope = pumpScope,
|
||||||
|
room = room,
|
||||||
|
signer = audienceSigner,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InteropDebug.assertListenerReached(scope, "Connected", listener.state.value)
|
||||||
|
|
||||||
|
val subscription =
|
||||||
|
InteropDebug.stepSuspending(scope, "audience: subscribeSpeaker(host)") {
|
||||||
|
listener.subscribeSpeaker(hostSigner.pubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
val received =
|
||||||
|
async(pumpScope.coroutineContext) {
|
||||||
|
withTimeoutOrNull(RECEIVE_TIMEOUT_MS) {
|
||||||
|
subscription.objects.take(N_FRAMES).toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(SUBSCRIBE_SETTLE_MS)
|
||||||
|
|
||||||
|
InteropDebug.checkpoint(scope, "host pushing $N_FRAMES frames")
|
||||||
|
for (i in 0 until N_FRAMES) {
|
||||||
|
capture.push(byteValue = i)
|
||||||
|
delay(FRAME_SPACING_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
val frames =
|
||||||
|
InteropDebug.stepSuspending(scope, "audience: await $N_FRAMES frames") {
|
||||||
|
received.await()
|
||||||
|
}
|
||||||
|
if (frames == null) {
|
||||||
|
fail(
|
||||||
|
"[$scope] audience did not receive $N_FRAMES frames within " +
|
||||||
|
"${RECEIVE_TIMEOUT_MS}ms — speaker=" +
|
||||||
|
InteropDebug.describe(speaker.state.value) +
|
||||||
|
", listener=" + InteropDebug.describe(listener.state.value) +
|
||||||
|
". If same-user round-trip passes but this fails, the bug is " +
|
||||||
|
"cross-user authorisation (nostrnests auth sidecar policy) or " +
|
||||||
|
"broadcast-suffix routing, NOT transport.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(N_FRAMES, frames.size, "audience must see exactly $N_FRAMES objects")
|
||||||
|
frames.forEachIndexed { idx, obj ->
|
||||||
|
assertContentEquals(
|
||||||
|
"PROD-B-".encodeToByteArray() + byteArrayOf(idx.toByte()),
|
||||||
|
obj.payload,
|
||||||
|
"audience payload at index $idx must match what the host encoded",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching { subscription.unsubscribe() }
|
||||||
|
runCatching { broadcast.close() }
|
||||||
|
runCatching { listener.close() }
|
||||||
|
runCatching { speaker.close() }
|
||||||
|
} finally {
|
||||||
|
capture.stop()
|
||||||
|
supervisor.cancelAndJoin()
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sustained_real_time_cadence_two_users() =
|
||||||
|
runBlocking {
|
||||||
|
assumeProd()
|
||||||
|
val scope = "two-users-sustained"
|
||||||
|
|
||||||
|
val hostSigner = NostrSignerInternal(KeyPair())
|
||||||
|
val audienceSigner = NostrSignerInternal(KeyPair())
|
||||||
|
val room = freshRoom(hostPubkey = hostSigner.pubKey)
|
||||||
|
|
||||||
|
val httpClient = OkHttpNestsClient { OkHttpClient() }
|
||||||
|
val transport = QuicWebTransportFactory()
|
||||||
|
|
||||||
|
val supervisor = SupervisorJob()
|
||||||
|
val pumpScope = CoroutineScope(supervisor + Dispatchers.IO)
|
||||||
|
|
||||||
|
val capture = InteropDriverCapture()
|
||||||
|
// Realistic Opus payload size: ~80 bytes for a 20 ms / 32
|
||||||
|
// kbps frame. The relay never inspects the bytes, but the
|
||||||
|
// size matters for stream / datagram path choices.
|
||||||
|
val payloadPrefix = ByteArray(79) { 0x4F.toByte() } + byteArrayOf(0x00)
|
||||||
|
val encoder = InteropStubEncoder(prefix = payloadPrefix)
|
||||||
|
|
||||||
|
InteropDebug.checkpoint(
|
||||||
|
scope,
|
||||||
|
"host=${hostSigner.pubKey.take(8)}… audience=${audienceSigner.pubKey.take(8)}… " +
|
||||||
|
"ns=${room.moqNamespace()} frames=$REAL_TIME_FRAMES cadence=${REAL_TIME_FRAME_MS}ms " +
|
||||||
|
"(~${REAL_TIME_FRAMES * REAL_TIME_FRAME_MS}ms of audio)",
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val speaker =
|
||||||
|
InteropDebug.stepSuspending(scope, "host: connectNestsSpeaker") {
|
||||||
|
connectNestsSpeaker(
|
||||||
|
httpClient = httpClient,
|
||||||
|
transport = transport,
|
||||||
|
scope = pumpScope,
|
||||||
|
room = room,
|
||||||
|
signer = hostSigner,
|
||||||
|
speakerPubkeyHex = hostSigner.pubKey,
|
||||||
|
captureFactory = { capture },
|
||||||
|
encoderFactory = { encoder },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
InteropDebug.assertSpeakerReached(scope, "Connected", speaker.state.value)
|
||||||
|
val broadcast = speaker.startBroadcasting()
|
||||||
|
InteropDebug.assertSpeakerReached(scope, "Broadcasting", speaker.state.value)
|
||||||
|
|
||||||
|
val listener =
|
||||||
|
connectNestsListener(
|
||||||
|
httpClient = httpClient,
|
||||||
|
transport = transport,
|
||||||
|
scope = pumpScope,
|
||||||
|
room = room,
|
||||||
|
signer = audienceSigner,
|
||||||
|
)
|
||||||
|
InteropDebug.assertListenerReached(scope, "Connected", listener.state.value)
|
||||||
|
|
||||||
|
val subscription = listener.subscribeSpeaker(hostSigner.pubKey)
|
||||||
|
|
||||||
|
val received =
|
||||||
|
async(pumpScope.coroutineContext) {
|
||||||
|
withTimeoutOrNull(REAL_TIME_RECEIVE_TIMEOUT_MS) {
|
||||||
|
subscription.objects.take(REAL_TIME_FRAMES).toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(SUBSCRIBE_SETTLE_MS)
|
||||||
|
|
||||||
|
val started = System.currentTimeMillis()
|
||||||
|
for (i in 0 until REAL_TIME_FRAMES) {
|
||||||
|
capture.push(byteValue = i and 0xFF)
|
||||||
|
delay(REAL_TIME_FRAME_MS)
|
||||||
|
}
|
||||||
|
val pumpDurationMs = System.currentTimeMillis() - started
|
||||||
|
|
||||||
|
val frames = received.await()
|
||||||
|
if (frames == null) {
|
||||||
|
fail(
|
||||||
|
"[$scope] only got partial audio within " +
|
||||||
|
"${REAL_TIME_RECEIVE_TIMEOUT_MS}ms — speaker=" +
|
||||||
|
InteropDebug.describe(speaker.state.value) +
|
||||||
|
", listener=" + InteropDebug.describe(listener.state.value) +
|
||||||
|
", pumpDuration=${pumpDurationMs}ms. If two_users round-trip " +
|
||||||
|
"passes but this drops frames, suspect flow control / " +
|
||||||
|
"datagram drops / per-subscription buffer.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Expect every frame: the broadcaster opens one moq-lite
|
||||||
|
// group per frame, so there's no group-level dropping
|
||||||
|
// for late attach inside the 100-frame window.
|
||||||
|
assertEquals(
|
||||||
|
REAL_TIME_FRAMES,
|
||||||
|
frames.size,
|
||||||
|
"expected all $REAL_TIME_FRAMES sustained-cadence frames; received ${frames.size}",
|
||||||
|
)
|
||||||
|
// groupId must be monotonic and dense (no gaps) — gap
|
||||||
|
// detection is the cheapest signal that the relay
|
||||||
|
// dropped a uni stream mid-flight.
|
||||||
|
frames.forEachIndexed { idx, obj ->
|
||||||
|
assertEquals(
|
||||||
|
idx.toLong(),
|
||||||
|
obj.groupId,
|
||||||
|
"groupId gap at index $idx — relay dropped a uni stream",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
runCatching { subscription.unsubscribe() }
|
||||||
|
runCatching { broadcast.close() }
|
||||||
|
runCatching { listener.close() }
|
||||||
|
runCatching { speaker.close() }
|
||||||
|
} finally {
|
||||||
|
capture.stop()
|
||||||
|
supervisor.cancelAndJoin()
|
||||||
|
}
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- helpers -----
|
||||||
|
|
||||||
|
private fun freshRoom(hostPubkey: String): NestsRoomConfig =
|
||||||
|
NestsRoomConfig(
|
||||||
|
authBaseUrl = System.getProperty(PROD_AUTH_PROPERTY) ?: DEFAULT_AUTH_URL,
|
||||||
|
endpoint = System.getProperty(PROD_ENDPOINT_PROPERTY) ?: DEFAULT_ENDPOINT_URL,
|
||||||
|
hostPubkey = hostPubkey,
|
||||||
|
roomId = "prod-diag-${System.currentTimeMillis()}-${(0..9999).random()}",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun assumeProd() {
|
||||||
|
val enabled = System.getProperty(PROD_PROPERTY) == "true"
|
||||||
|
assumeTrue(
|
||||||
|
"Skipping nostrnests.com production test — set -D$PROD_PROPERTY=true to enable. " +
|
||||||
|
"Override URLs with -D$PROD_ENDPOINT_PROPERTY=... and -D$PROD_AUTH_PROPERTY=...",
|
||||||
|
enabled,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// Hardcoded in the Android app's NestsServersScreen — same
|
||||||
|
// values the production app ships with.
|
||||||
|
private const val DEFAULT_ENDPOINT_URL = "https://moq.nostrnests.com:4443"
|
||||||
|
private const val DEFAULT_AUTH_URL = "https://moq-auth.nostrnests.com"
|
||||||
|
|
||||||
|
private const val PROD_PROPERTY = "nestsProd"
|
||||||
|
private const val PROD_ENDPOINT_PROPERTY = "nestsProdEndpoint"
|
||||||
|
private const val PROD_AUTH_PROPERTY = "nestsProdAuth"
|
||||||
|
|
||||||
|
// Round-trip tunables — same shape as the Docker harness round
|
||||||
|
// trip, large enough to detect drops but small enough to keep
|
||||||
|
// the test under 30 s wall-clock against a real WAN relay.
|
||||||
|
private const val N_FRAMES = 8
|
||||||
|
private const val SUBSCRIBE_SETTLE_MS = 500L
|
||||||
|
private const val FRAME_SPACING_MS = 25L
|
||||||
|
private const val RECEIVE_TIMEOUT_MS = 20_000L
|
||||||
|
|
||||||
|
// Sustained cadence: ~2 s of audio at the production Opus
|
||||||
|
// frame size (20 ms). Same cadence the Android broadcaster
|
||||||
|
// hits in real life.
|
||||||
|
private const val REAL_TIME_FRAMES = 100
|
||||||
|
private const val REAL_TIME_FRAME_MS = 20L
|
||||||
|
private const val REAL_TIME_RECEIVE_TIMEOUT_MS = 30_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user