feat(nests): T16 Phase 4.A+B — browser-side cross-stack interop scaffold + I1 forward

Lands the bun + Playwright + headless Chromium harness for the
T16 cross-stack interop suite, parallel to the existing Rust
hang-listen tier. New top-level `nestsClient-browser-interop/`
directory with `@moq/lite` + `@moq/hang` 0.2.x pinned, a bun
static + WebSocket back-channel server, and a Playwright runner
that opens `listen.html` against the same `NativeMoqRelayHarness`
moq-relay subprocess the Rust scenarios use.

Kotlin side: `PlaywrightDriver` shells out to `bun x playwright
test`, forwards the relay URL + leaf-cert SHA-256 (captured via
a custom `CertCapturingValidator` during the speaker's QUIC
handshake), and reads back Float32 LE PCM frames from a tempfile
the bun WS server appends to. `BrowserInteropTest` ships I1
forward — Amethyst Kotlin speaker → Chromium `@moq/lite`
listener, asserting FFT 440 Hz on the captured tail.

Why pin via `serverCertificateHashes` instead of
`--ignore-certificate-errors`: Chromium's flag does NOT bypass
QUIC cert validation (crbug.com/1190655). `serverCertificateHashes`
is the supported path; moq-relay's `--tls-generate` produces a
14-day ECDSA P-256 cert that satisfies the spec.

Two Gradle tasks added: `interopBuildBrowserHarness` (bun
install + bun build → dist/) and `interopInstallPlaywrightChromium`
(skipped when `PLAYWRIGHT_BROWSERS_PATH` already has a chromium
build, as on the agent runner).

Verification:
- `./gradlew :nestsClient:jvmTest --tests
   com.vitorpamplona.nestsclient.interop.native.BrowserInteropTest
   -DnestsHangInterop=true -DnestsBrowserInterop=true` green.
- I1 forward asserts ≥ 1 s of decoded PCM with 440 Hz FFT peak.
  Looser sample-count bound than the hang-tier I1 because
  Chromium cold-launch + WebTransport handshake (3–5 s) + the
  publisher's `framesPerGroup = 5` per-subscriber cache cliff
  means the page captures only the broadcast tail.

Phase 4.C (I2/I3/I4/I13/I14/I15) and 4.D (CI) are separate
follow-up commits per the plan's per-scenario commit guidance.

See: nestsClient/plans/2026-05-06-phase4-browser-harness.md

https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
This commit is contained in:
Claude
2026-05-07 00:44:42 +00:00
parent ced9025cff
commit e0a9332498
15 changed files with 1726 additions and 0 deletions
+106
View File
@@ -1,4 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.io.File
plugins {
alias(libs.plugins.kotlinMultiplatform)
@@ -227,3 +228,108 @@ tasks.withType<Test>().configureEach {
systemProperty("nestsHangInteropSidecarsDir", sidecarRelease.absolutePath)
systemProperty("nestsHangInteropCargoBinDir", cargoBin.absolutePath)
}
// ---- Cross-stack interop: BROWSER (Phase 4 of T16) --------------------------
//
// Adds the bun + Playwright + headless Chromium harness at
// `nestsClient-browser-interop/`. Mirrors the hang-interop wiring above
// but with bun/npx subprocesses instead of cargo. Opt-in via
// `-DnestsBrowserInterop=true`. See:
// nestsClient/plans/2026-05-06-phase4-browser-harness.md
//
// Two tasks:
// - interopBuildBrowserHarness — `bun install` + `bun build` of
// listen.ts/publish.ts → dist/, plus copying static .html files.
// - interopInstallPlaywrightChromium — `npx playwright install
// --with-deps chromium`. Skipped if a Chromium build already lives
// in `~/.cache/ms-playwright/`.
//
// We also forward the `bun` and `npx` binaries to be configurable via
// env so CI can override them; defaults pick up the standard install
// paths the agents/host runner ship with.
val browserInteropDir =
rootProject.layout.projectDirectory.dir("nestsClient-browser-interop")
// `bun` lives at `/root/.bun/bin/bun` on the agent runner. CI may put it
// elsewhere; allow override via env / system property. Falls back to
// `bun` on PATH if the well-known path isn't executable.
fun resolveBunBinary(): String {
val explicit = System.getenv("BUN_BIN") ?: System.getProperty("bunBin")
if (explicit != null) return explicit
val agentPath = "/root/.bun/bin/bun"
return if (File(agentPath).canExecute()) agentPath else "bun"
}
fun resolveNpxBinary(): String =
System.getenv("NPX_BIN") ?: System.getProperty("npxBin") ?: "npx"
val interopBuildBrowserHarness by tasks.registering(Exec::class) {
description = "bun install && bun build for the browser interop harness"
group = "interop"
workingDir = browserInteropDir.asFile
val bun = resolveBunBinary()
// Single bash invocation so `&&` short-circuits on a failed install.
// The trailing `cp` step copies the static HTML pages into dist/
// alongside the bundled JS — bun's bundler doesn't carry .html.
commandLine(
"bash", "-c",
"$bun install && $bun build src/listen.ts src/publish.ts --outdir dist --target browser && cp src/listen.html src/publish.html dist/",
)
inputs.files(
fileTree(browserInteropDir.asFile) {
include("package.json", "tsconfig.json", "playwright.config.ts", "src/**/*")
},
)
outputs.dir(browserInteropDir.dir("dist"))
}
val interopInstallPlaywrightChromium by tasks.registering(Exec::class) {
description = "Install Playwright Chromium + dependencies for the browser interop harness"
group = "interop"
workingDir = browserInteropDir.asFile
val npx = resolveNpxBinary()
// `--with-deps` needs sudo on a fresh runner; on the agent host
// Chromium is already pre-installed via apt so the system-package
// step is a no-op. Use the plain `install` form when --with-deps
// would error (e.g. unprivileged container) — fall back at runtime.
commandLine("bash", "-c", "$npx playwright install chromium")
onlyIf {
// Skip if a Chromium build is already present in the Playwright
// cache. The cache path is normally ~/.cache/ms-playwright/, but
// the agent runner sets PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers
// and ships chromium pre-installed there. Honour the env var so
// we don't redundantly download.
val explicit = System.getenv("PLAYWRIGHT_BROWSERS_PATH")
val candidates =
if (explicit != null) {
listOf(File(explicit))
} else {
val home = System.getProperty("user.home") ?: return@onlyIf true
listOf(File(home, ".cache/ms-playwright"))
}
val hasChromium =
candidates.any { dir ->
dir.exists() &&
dir.listFiles()?.any { it.name.startsWith("chromium-") || it.name == "chromium" } == true
}
!hasChromium
}
}
tasks.withType<Test>().configureEach {
val isBrowserInterop = System.getProperty("nestsBrowserInterop") == "true"
if (isBrowserInterop) {
dependsOn(interopBuildBrowserHarness, interopInstallPlaywrightChromium)
// Browser scenarios reuse the moq-relay subprocess that
// hang-interop boots, so the Rust sidecars must be built too.
dependsOn(interopBuildHangSidecars)
}
systemProperty(
"nestsBrowserInteropHarnessDir",
browserInteropDir.asFile.absolutePath,
)
System.getProperty("nestsBrowserInterop")?.let {
systemProperty("nestsBrowserInterop", it)
}
}
@@ -0,0 +1,337 @@
/*
* 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.native
import com.vitorpamplona.nestsclient.AudioBroadcastConfig
import com.vitorpamplona.nestsclient.NestsClient
import com.vitorpamplona.nestsclient.NestsRoomConfig
import com.vitorpamplona.nestsclient.audio.AudioFormat
import com.vitorpamplona.nestsclient.audio.JvmOpusEncoder
import com.vitorpamplona.nestsclient.audio.PcmAssertions
import com.vitorpamplona.nestsclient.audio.SineWaveAudioCapture
import com.vitorpamplona.nestsclient.connectNestsSpeaker
import com.vitorpamplona.nestsclient.transport.QuicWebTransportFactory
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.UUID
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* Phase 4 (T16) — browser-side cross-stack interop scenarios.
*
* Drives a headless Chromium subprocess (via [PlaywrightDriver])
* through the same [NativeMoqRelayHarness] moq-relay that
* [HangInteropTest] uses. The browser harness page connects via
* Chromium's WebTransport stack (separate implementation from quinn /
* `:quic`), decodes Opus via WebCodecs `AudioDecoder`, and posts
* Float32 LE PCM frames back to a bun WebSocket back-channel that
* appends them to a file the test reads.
*
* Phase 4.B P0 scenario:
* - **I1 browser** — sine-wave round-trip Amethyst Kotlin speaker →
* Chromium @moq/lite + @moq/hang listener; assert FFT peak at
* 440 Hz on the captured PCM.
*
* Speaker pinned at `framesPerGroup = 5` to stay under the
* `moq-relay 0.10.x` per-subscriber forward cliff (same as the
* hang-tier scenarios).
*
* Gate: `-DnestsBrowserInterop=true` (also implies
* `-DnestsHangInterop=true` indirectly because we boot the same
* `NativeMoqRelayHarness`).
*/
class BrowserInteropTest {
@BeforeTest
fun gate() {
PlaywrightDriver.assumeBrowserInterop()
// The browser harness reuses the moq-relay subprocess that the
// hang-tier scenarios bring up. Without `-DnestsHangInterop=true`
// the harness would refuse to boot — flip it on automatically
// for the browser gate so the user only has to set one flag.
if (!NativeMoqRelayHarness.isEnabled()) {
System.setProperty(NativeMoqRelayHarness.ENABLE_PROPERTY, "true")
}
}
/**
* **I1 forward (browser)** — Amethyst Kotlin speaker → Chromium
* `@moq/lite` listener with `@moq/hang` `Container.Legacy.Consumer`.
* Asserts the captured PCM has the expected sample count and the
* 440 Hz tone survives end-to-end.
*
* What this catches that the Rust hang-listen path doesn't:
* - Chromium's WebTransport ALPN negotiation (independent
* implementation from quinn),
* - WebCodecs `AudioDecoder` first-frame handling (different
* warmup behaviour from libopus — drops the first 3 output
* frames; we offset the warmup window accordingly),
* - `OpusHead` codec-config wedging would still bypass the
* decoder warmup window and produce a click at offset 0;
* T8's filter is verified again here.
*/
@Test
fun amethyst_speaker_to_chromium_listener_static_tone_440() =
runBlocking {
// Speaker runs for 10 s wallclock; we assert the page captured
// ≥ 1 s of decoded PCM. The looser bound reflects how the page's
// capture window opens *after* Chromium cold-launch + WebTransport
// handshake (35 s on a fresh runner), and the Kotlin speaker
// pins `framesPerGroup = 5` (= 100 ms groups) so a late-joining
// subscriber gets only the tail per `moq-relay 0.10.x`'s
// per-subscriber cache semantics. The load-bearing assertion is
// the FFT peak at 440 Hz — that catches a wire-format regression
// (downmix, channel swap, OpusHead-as-frame leak) regardless of
// how many seconds of tail the page captured.
val out = runSpeakerToBrowserListen(speakerSeconds = 10)
val pcm = readFloat32Pcm(out.pcmFile)
// Skip the warmup window before FFT: 40 ms Opus
// look-ahead + 60 ms WebCodecs 3-frame warmup-skip = 100 ms.
val warmupSamples = AudioFormat.SAMPLE_RATE_HZ / 10
assertTrue(
pcm.size > warmupSamples,
"captured PCM (${pcm.size} samples) shorter than the WebCodecs warmup " +
"window — page never received any audio.\nplaywright stdout:\n${out.stdout}",
)
val analysed = pcm.copyOfRange(warmupSamples, pcm.size)
assertTrue(
analysed.size >= AudioFormat.SAMPLE_RATE_HZ,
"after warmup window only ${analysed.size} samples remain; " +
"expected ≥ 1 s of decoded audio.\nplaywright stdout:\n${out.stdout}",
)
PcmAssertions.assertFftPeak(
analysed,
expectedHz = 440.0,
halfWindowHz = 5.0,
)
}
}
/**
* Output bundle from one [runSpeakerToBrowserListen] invocation.
*/
private class BrowserListenOutput(
val pcmFile: File,
val stdout: String,
)
/**
* Run the Kotlin speaker for [speakerSeconds] seconds, drive the
* Playwright + Chromium harness page to capture decoded PCM via the
* bun WS back-channel, and return the captured bundle.
*
* Mirror of [HangInteropTest]'s `runSpeakerToHangListen`, but the
* listener subprocess is `npx playwright test` instead of
* `hang-listen`. The relay endpoint is identical — both consumers
* connect via WebTransport to the same `NativeMoqRelayHarness`
* instance.
*/
private suspend fun runSpeakerToBrowserListen(
speakerSeconds: Int,
listenerLateJoinDelayMs: Long = 150L,
channelCount: Int = 1,
freqHzPerChannel: IntArray? = null,
): BrowserListenOutput {
val harness = NativeMoqRelayHarness.shared()
val signer: NostrSigner = NostrSignerInternal(KeyPair())
val pubkey = signer.pubKey
val (relayHost, relayPort) = harness.loopbackHostPort()
val speakerEndpoint = "https://$relayHost:$relayPort"
val room =
NestsRoomConfig(
authBaseUrl = "<unused-public-relay>",
endpoint = speakerEndpoint,
hostPubkey = pubkey,
roomId = "rt-${UUID.randomUUID()}",
)
val moqNamespace = room.moqNamespace()
// Build the same connect target the Kotlin speaker uses; the
// Chromium page consumes it directly via `new URL(relay)`.
// `NestsConnect.kt` uses `?jwt=<token>` query for auth and the
// moq-rs relay we boot has `--auth-public ""` so the token is
// empty — Chromium's WebTransport accepts an empty query value.
val pageRelayUrl = "$speakerEndpoint/$moqNamespace?jwt="
val pumpScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// Use a cert-capturing validator so we can pin the relay's
// self-signed cert into Chromium's WebTransport via
// `serverCertificateHashes`. The validator is just a wrapper —
// it accepts every chain (delegating to PermissiveCertificateValidator
// semantics) but stashes the leaf DER on the first handshake.
val certCapture = CertCapturingValidator()
val transport =
QuicWebTransportFactory(
parentScope = pumpScope,
certificateValidator = certCapture,
)
val captureFactory: () -> SineWaveAudioCapture = {
SineWaveAudioCapture(
freqHz = 440,
channelCount = channelCount,
freqHzPerChannel = freqHzPerChannel,
)
}
val encoderFactory: () -> JvmOpusEncoder = {
JvmOpusEncoder(channelCount = channelCount)
}
val broadcastConfig = AudioBroadcastConfig(channelCount = channelCount)
val speaker =
connectNestsSpeaker(
httpClient = StaticTokenNestsClientForBrowser,
transport = transport,
scope = pumpScope,
room = room,
signer = signer,
speakerPubkeyHex = pubkey,
captureFactory = captureFactory,
encoderFactory = encoderFactory,
broadcastConfig = broadcastConfig,
framesPerGroup = 5,
)
val handle = speaker.startBroadcasting()
delay(listenerLateJoinDelayMs)
// The speaker's connect path completes a QUIC handshake before
// returning, so the cert validator has captured the leaf cert by
// now. Compute the SHA-256 the WebTransport spec wants — `value`
// in `serverCertificateHashes` is the SHA-256 of the entire
// DER-encoded X.509 certificate, NOT of the SPKI.
val derSha256 =
certCapture.derSha256()
?: error("cert capture failed — speaker handshake did not invoke validator")
val derSha256B64 =
java.util.Base64
.getEncoder()
.encodeToString(derSha256)
// Run Playwright on a side thread; keep the Kotlin speaker alive
// until Playwright signals done. Chromium cold-launch + Playwright
// setup eats 35 s before the page starts its `durationSec`
// capture window, so closing the speaker on a fixed wall-clock
// delay is fundamentally racy. Instead, the speaker stays
// broadcasting until the side thread reports completion (which
// happens after the page reaches `body[data-state="done"]` and
// the bun server's WS shutdown).
val pwResultRef =
java.util.concurrent.atomic
.AtomicReference<PlaywrightDriver.HarnessRun>()
val pwErrorRef =
java.util.concurrent.atomic
.AtomicReference<Throwable>()
val pwLatch = java.util.concurrent.CountDownLatch(1)
val pwThread =
Thread({
try {
pwResultRef.set(
PlaywrightDriver.openListenPage(
relayUrlFull = pageRelayUrl,
broadcastPath = pubkey,
durationSec = speakerSeconds,
// Bigger overall timeout: Chromium cold-launch +
// Playwright runner setup eats 310 s on a busy
// CI runner before the page starts capturing.
overallTimeoutSec = speakerSeconds + 90,
serverCertHashB64 = derSha256B64,
),
)
} catch (t: Throwable) {
pwErrorRef.set(t)
} finally {
pwLatch.countDown()
}
}, "browser-interop-playwright").apply {
isDaemon = true
start()
}
// Wait (off the main coroutine) for Playwright to finish. The
// speaker keeps broadcasting in the background of `pumpScope`
// until we close it below.
val pwOverallTimeoutSec = speakerSeconds + 120
val ok =
kotlinx.coroutines.withContext(Dispatchers.IO) {
pwLatch.await(pwOverallTimeoutSec.toLong(), java.util.concurrent.TimeUnit.SECONDS)
}
runCatching { handle.close() }
runCatching { speaker.close() }
val out =
try {
if (!ok) error("Playwright did not complete within ${pwOverallTimeoutSec}s")
pwErrorRef.get()?.let { throw it }
pwResultRef.get() ?: error("Playwright thread did not produce a result")
} finally {
pumpScope.coroutineContext[Job]?.cancel()
}
assertTrue(
out.exitCode == 0,
"Playwright exited with code ${out.exitCode}.\n--- stdout ---\n${out.playwrightStdout}",
)
return BrowserListenOutput(pcmFile = out.pcmFile, stdout = out.playwrightStdout)
}
/**
* Stub NestsClient for the browser interop scenarios. The harness's
* `--auth-public ""` flag grants any path without a JWT, so we mint
* an empty token. Mirrors [HangInteropTest]'s
* `StaticTokenNestsClient`.
*/
private object StaticTokenNestsClientForBrowser : NestsClient {
override suspend fun mintToken(
room: NestsRoomConfig,
publish: Boolean,
signer: NostrSigner,
): String = ""
}
/**
* Read a file of native-endian Float32 LE PCM into a [FloatArray].
* Matches the format the bun WS server appends per binary frame —
* which itself matches `hang-listen`'s output format so the existing
* [PcmAssertions] helpers slot in unchanged.
*/
private fun readFloat32Pcm(file: File): FloatArray {
val bytes = file.readBytes()
require(bytes.size % 4 == 0) {
"PCM file size ${bytes.size} is not a multiple of 4 (Float32)"
}
val n = bytes.size / 4
val out = FloatArray(n)
val buf = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
for (i in 0 until n) out[i] = buf.float
return out
}
@@ -0,0 +1,404 @@
/*
* 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.native
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Phase 4 (T16) Kotlin-side shim that drives a headless Chromium
* harness via Playwright. Mirrors the role `hang-listen` plays for
* the Phase 2 Rust-listener scenarios, but the listener is a
* Chromium tab loading [openListenPage] / [openPublishPage] from
* a bun static server.
*
* Two subprocesses per scenario:
* 1. **bun static + WebSocket back-channel** (`server.ts`): serves
* the bundled `listen.html` / `publish.html` and writes any PCM
* frames the page posts over WS to the file the test reads.
* 2. **`npx playwright test`** (or `bun x playwright test`): one-off
* Chromium spawn that opens the harness page and waits for
* `body[data-state="done"]`.
*
* Both are spawned per-scenario for isolation — sharing the bun
* server across scenarios would race the PCM-output file across
* runs, and sharing a Chromium across runs invites stale
* `AudioContext` / `WebTransport` state.
*
* Gate: `-DnestsBrowserInterop=true`. The Gradle `Test` task hooks
* `interopBuildBrowserHarness` + `interopInstallPlaywrightChromium`
* dependencies onto this gate (see `nestsClient/build.gradle.kts`).
*/
internal object PlaywrightDriver {
/** Gate property — mirrors [NativeMoqRelayHarness.ENABLE_PROPERTY]. */
const val ENABLE_PROPERTY = "nestsBrowserInterop"
/**
* Forwarded by Gradle: absolute path to `nestsClient-browser-interop/`.
*/
const val HARNESS_DIR_PROPERTY = "nestsBrowserInteropHarnessDir"
fun isEnabled(): Boolean = System.getProperty(ENABLE_PROPERTY) == "true"
/**
* JUnit "skipped" if the gate isn't on. Mirrors
* [NativeMoqRelayHarness.assumeHangInterop].
*/
fun assumeBrowserInterop() {
if (isEnabled()) return
val msg =
"Skipping browser interop test — set -D$ENABLE_PROPERTY=true to enable. " +
"See nestsClient/plans/2026-05-06-phase4-browser-harness.md."
try {
val assume = Class.forName("org.junit.Assume")
val assumeTrue =
assume.getMethod("assumeTrue", String::class.java, Boolean::class.javaPrimitiveType)
assumeTrue.invoke(null, msg, false)
} catch (e: java.lang.reflect.InvocationTargetException) {
throw e.targetException ?: e
} catch (_: ClassNotFoundException) {
throw IllegalStateException(msg)
}
}
/**
* Outcome handed back to the test once the Chromium harness has
* finished. [pcmFile] holds the Float32 LE PCM bytes the listener
* page wrote via the WS back-channel; [playwrightStdout] is the
* combined stdout/stderr of the `npx playwright test` invocation
* (Kotlin parses the trailing JSON line for diagnostic metadata).
*/
data class HarnessRun(
val pcmFile: File,
val playwrightStdout: String,
val exitCode: Int,
)
/**
* Spawn the listener harness:
* 1. Pick an ephemeral port (via `ServerSocket(0)`),
* 2. Start `bun run server.ts --port <p> --root dist --out-pcm <pcm>`,
* 3. Wait for the server's `ready` line,
* 4. Spawn `npx playwright test` with NESTS_HARNESS_URL =
* `http://127.0.0.1:<p>/listen.html?relay=<…>&broadcast=<pubkey>&wsPort=<p>&duration=<sec>`,
* 5. Block until Playwright exits or [overallTimeoutSec] elapses,
* 6. Tear down both subprocesses.
*
* The relay URL passed to the page is the *full* connect target
* (path + `?jwt=` query), built by [buildHarnessRelayUrl] —
* Chromium's WebTransport driver consumes it directly.
*/
fun openListenPage(
relayUrlFull: String,
broadcastPath: String,
durationSec: Int,
overallTimeoutSec: Int = durationSec + 30,
track: String = "audio/data",
serverCertHashB64: String? = null,
): HarnessRun {
val extraQuery =
if (serverCertHashB64 != null) {
"&certSha256=" + java.net.URLEncoder.encode(serverCertHashB64, Charsets.UTF_8)
} else {
""
}
return run(
"listen.html",
relayUrlFull,
broadcastPath,
durationSec,
overallTimeoutSec,
track,
extraQuery,
)
}
/**
* Spawn the publisher harness. Symmetric to [openListenPage] but
* loads `publish.html` and passes the oscillator parameters.
* Phase 4.C scenarios — the I1-forward smoke test does NOT use this.
*/
@Suppress("LongParameterList")
fun openPublishPage(
relayUrlFull: String,
broadcastPath: String,
freqHz: Int,
channels: Int,
durationSec: Int,
overallTimeoutSec: Int = durationSec + 30,
track: String = "audio/data",
): HarnessRun {
val extraQuery = "&freqHz=$freqHz&channels=$channels"
return run(
"publish.html",
relayUrlFull,
broadcastPath,
durationSec,
overallTimeoutSec,
track,
extraQuery,
)
}
private fun run(
page: String,
relayUrlFull: String,
broadcastPath: String,
durationSec: Int,
overallTimeoutSec: Int,
track: String,
extraQuery: String = "",
): HarnessRun {
check(isEnabled()) {
"PlaywrightDriver.run called without -D$ENABLE_PROPERTY=true."
}
val harnessDir = requireHarnessDir()
val distDir =
File(harnessDir, "dist").apply {
check(isDirectory) {
"browser harness dist/ missing at $absolutePath — did " +
"`./gradlew :nestsClient:interopBuildBrowserHarness` run?"
}
}
// 1) Reserve a port for the bun server (it binds to 127.0.0.1
// on the same number; a tiny race window but loopback in CI
// is uncontested, same pattern as NativeMoqRelayHarness).
val bunPort = java.net.ServerSocket(0).use { it.localPort }
val pcmFile = File.createTempFile("browser-pcm", ".bin").also { it.deleteOnExit() }
val bun = resolveBunBinary()
val bunProc =
ProcessBuilder(
bun,
"run",
File(harnessDir, "src/server.ts").absolutePath,
"--port",
bunPort.toString(),
"--root",
distDir.absolutePath,
"--out-pcm",
pcmFile.absolutePath,
).directory(harnessDir)
.redirectErrorStream(true)
.start()
val bunDrainer = PlaywrightProcessDrainer(bunProc, "bun-server").also { it.start() }
try {
bunDrainer.waitForLine("ready", BUN_READY_TIMEOUT_MS)
// 2) Compose the harness page URL. The relay URL is already
// a `https://host:port/path?jwt=...` string from
// `buildRelayConnectTarget` — URL-encode it once for the
// `?relay=` slot so the inner `?jwt=` doesn't truncate.
val encodedRelay =
java.net.URLEncoder.encode(relayUrlFull, Charsets.UTF_8)
val pageUrl =
"http://127.0.0.1:$bunPort/$page" +
"?relay=$encodedRelay" +
"&broadcast=$broadcastPath" +
"&track=$track" +
"&wsPort=$bunPort" +
"&duration=$durationSec" +
extraQuery
// 3) Spawn Playwright. Use bun's `bun x` if available so we
// don't need a separate node install; falls back to npx.
val pwCmd = mutableListOf<String>()
if (File(bun).canExecute()) {
pwCmd += listOf(bun, "x", "playwright", "test", "--config=playwright.config.ts")
} else {
pwCmd += listOf("npx", "playwright", "test", "--config=playwright.config.ts")
}
val pwProc =
ProcessBuilder(pwCmd)
.directory(harnessDir)
.redirectErrorStream(true)
.also { pb ->
pb.environment()["NESTS_HARNESS_URL"] = pageUrl
pb.environment()["NESTS_TIMEOUT_MS"] =
(overallTimeoutSec * 1_000).toString()
// Inherit PLAYWRIGHT_BROWSERS_PATH if the host
// has it (the agent runner ships it pointing at
// /opt/pw-browsers); otherwise Playwright falls
// back to ~/.cache/ms-playwright.
// No-op when env is already inherited.
}.start()
val pwDrainer = PlaywrightProcessDrainer(pwProc, "playwright").also { it.start() }
val exited = pwProc.waitFor(overallTimeoutSec.toLong(), TimeUnit.SECONDS)
if (!exited) {
runCatching { pwProc.destroyForcibly() }
val tail = pwDrainer.tail()
throw IllegalStateException(
"Playwright did not exit within ${overallTimeoutSec}s.\n" +
"--- playwright tail ---\n$tail",
)
}
// Allow the bun server a brief moment to flush the WS frames
// it's still writing to disk before we read the PCM.
Thread.sleep(200)
return HarnessRun(
pcmFile = pcmFile,
playwrightStdout = pwDrainer.tail(),
exitCode = pwProc.exitValue(),
)
} finally {
runCatching { bunProc.destroy() }
if (!bunProc.waitFor(3, TimeUnit.SECONDS)) {
runCatching { bunProc.destroyForcibly() }
}
}
}
private fun requireHarnessDir(): File {
val raw = System.getProperty(HARNESS_DIR_PROPERTY)
check(!raw.isNullOrBlank()) {
"system property '$HARNESS_DIR_PROPERTY' not set — did the Gradle test task forward it?"
}
val dir = File(raw)
check(dir.isDirectory) {
"$HARNESS_DIR_PROPERTY = '$raw' is not a directory"
}
return dir
}
private fun resolveBunBinary(): String {
System.getenv("BUN_BIN")?.let { return it }
System.getProperty("bunBin")?.let { return it }
val agentPath = "/root/.bun/bin/bun"
if (File(agentPath).canExecute()) return agentPath
return "bun"
}
private const val BUN_READY_TIMEOUT_MS = 30_000L
}
/**
* Captures the relay's leaf certificate during a QUIC TLS handshake
* so the test driver can pin it via Chromium's
* `WebTransport({ serverCertificateHashes: [...] })` option.
*
* Why we need this: Chromium's `--ignore-certificate-errors` flag does
* NOT apply to QUIC — see crbug.com/1190655 — so we can't simply skip
* certificate validation the way the Kotlin clients do.
* `serverCertificateHashes` is the supported alternative for
* test-only WebTransport pinning, accepting a SHA-256 of the entire
* DER-encoded X.509 certificate as long as the cert is ECDSA P-256
* and valid for ≤ 14 days. moq-relay's `--tls-generate` produces
* exactly that (rcgen default = ECDSA P-256, validity = 14 days; see
* `kixelated/moq/rs/moq-native/src/tls.rs:140`), so we can pin it.
*/
internal class CertCapturingValidator : com.vitorpamplona.quic.tls.CertificateValidator {
@Volatile private var captured: ByteArray? = null
override fun validateChain(
chain: List<ByteArray>,
expectedHost: String,
) {
if (captured == null && chain.isNotEmpty()) {
captured = chain.first().copyOf()
}
}
override fun verifySignature(
signatureAlgorithm: Int,
signature: ByteArray,
transcriptHash: ByteArray,
) {
// No-op; we're just here for the cert.
}
/** SHA-256 of the captured DER cert, base64-encoded. Null until handshake completes. */
fun derSha256(): ByteArray? {
val der = captured ?: return null
return java.security.MessageDigest
.getInstance("SHA-256")
.digest(der)
}
}
/**
* Minimal stdout drainer for the bun + Playwright subprocesses.
* Mirrors the private one in `NativeMoqRelayHarness.kt` — kept
* separate so the two test entry points don't share file-private
* symbols.
*/
private class PlaywrightProcessDrainer(
private val process: Process,
private val name: String,
) {
private val ring = java.util.concurrent.ConcurrentLinkedQueue<String>()
private val maxLines = 256
private val lock =
java.util.concurrent.locks
.ReentrantLock()
private val newLineCond = lock.newCondition()
fun start() {
Thread({
process.inputStream.bufferedReader().useLines { lines ->
for (line in lines) {
ring.add(line)
while (ring.size > maxLines) ring.poll()
lock.lock()
try {
newLineCond.signalAll()
} finally {
lock.unlock()
}
}
}
}, "PlaywrightDriver-$name").apply {
isDaemon = true
start()
}
}
fun tail(): String = ring.joinToString("\n")
fun waitForLine(
needle: String,
timeoutMs: Long,
) {
val deadlineNanos =
System.nanoTime() +
java.util.concurrent.TimeUnit.MILLISECONDS
.toNanos(timeoutMs)
if (ring.any { it.contains(needle) }) return
lock.lock()
try {
while (true) {
if (ring.any { it.contains(needle) }) return
val remaining = deadlineNanos - System.nanoTime()
if (remaining <= 0) {
throw IllegalStateException(
"did not observe '$needle' in $name output within ${timeoutMs}ms.\n" +
"--- $name tail ---\n${tail()}",
)
}
newLineCond.awaitNanos(remaining)
}
} finally {
lock.unlock()
}
}
}