Mirrors the existing nestsClient/tests/hang-interop/ layout — all T16 cross-stack interop test infrastructure (Rust sidecars + bun browser harness) now lives under nestsClient/tests/. Updates: - nestsClient/build.gradle.kts: browserInteropDir path. - PlaywrightDriver.kt + BrowserInteropTest.kt: kdoc comments. - All plan docs in nestsClient/plans/ that referenced the old path. Compiles clean post-move. No CI changes (those jobs are intentionally not wired per 2026-05-07-cross-stack-interop-ci-gating.md; when re-added they'll reference the new path).
18 KiB
Plan: Phase 4 — browser-side cross-stack harness (T16)
Status: 📋 Spec — ready for implementation. Phase 1–3 of the
T16 cross-stack interop suite landed the Rust path
(hang-listen + hang-publish against moq-relay 0.10.x,
seven scenarios green). Phase 4 adds the browser path:
headless Chromium running @moq/watch (listener) and
@moq/publish (publisher) against the same harness's relay,
driven from :nestsClient:jvmTest via Playwright.
Origin: parent plan
nestsClient/plans/2026-05-06-cross-stack-interop-test.md,
"Phase 4 — Browser harness (1.5 days)".
Branch convention: new branch — don't fold into the
cross-stack-test branch. Suggested name
feat/nests-browser-interop.
Why a browser path
hang-listen validates the wire format against the canonical
Rust kixelated/moq parser. The browser path additionally
validates:
- Chromium's QUIC + WebTransport stack (different
implementation from quinn /
:quic), - WebCodecs
AudioDecoder(different from libopus — different look-ahead, different first-frame handling behaviour, sameOpusHeadregression risk per T8/T14), - AudioWorklet rendering timing (200 ms playback buffer + AudioContext clock drift),
WT-Available-Protocols/WT-ProtocolALPN negotiation over Chromium's WebTransport — completely separate from quinn's TLS-ALPN exchange.
The reference NostrNests web app runs on @moq/watch /
@moq/publish — this is the actual production stack the
project ships against. A wire-byte round-trip through
hang-listen alone doesn't catch a Chromium quirk that breaks
real users.
Goal
End-to-end verify:
- forward — Amethyst Kotlin speaker → headless Chromium
listener (
@moq/watch), tone recoverable from PCM tap. - reverse — headless Chromium publisher (
@moq/publish) → Amethyst Kotlin listener, tone recoverable. - browser-only scenarios I13 (
framesPerGroup=50long broadcast againstContainer.Consumer), I14 (WebCodecs warmup × CSD-skip interaction), I15 (WT-Available-ProtocolsChromium round-trip).
All scenarios drive the same NativeMoqRelayHarness from
Phase 1 — no Docker, no second relay, no fake auth sidecar.
Architecture
Test runner (Gradle :nestsClient:jvmTest)
│
┌───────────────────────┼─────────────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────┐ ┌─────────────────────────────┐
│ NativeMoqRelayHarness│ │ Kotlin in-proc │ │ Browser harness │
│ (existing — Phase 1) │ │ speaker / listener│ │ nestsClient/tests/browser-interop/│
│ moq-relay subprocess │ │ via │ │ - bun static + WS server │
│ 127.0.0.1:<rand> │ │ connectNestsSpeaker │ - listen.html + listen.ts │
│ --auth-public "" │ │ connectNestsListener │ - publish.html+publish.ts │
│ --tls-generate │ │ │ │ - pcm-tap-worklet.ts │
└──────────▲───────────┘ └──────────────────┘ │ - Playwright driver │
│ WebTransport over UDP │ - PCM capture via WS back- │
│ │ channel │
│ └─────────────────────────────┘
└─────────────────────────────────────────────┘
Components
1. nestsClient/tests/browser-interop/ — bun + Playwright workspace
New top-level directory, mirrors the parent plan's specification. Contents:
nestsClient/tests/browser-interop/
├── package.json
├── tsconfig.json
├── bun.lockb # pinned via REV file
├── REV # @moq/* npm versions
├── src/
│ ├── listen.html # static page driving Watch.Broadcast
│ ├── publish.html # static page driving Publish.Broadcast
│ ├── listen.ts # imports @moq/watch + @moq/lite + PCM tap
│ ├── publish.ts # imports @moq/publish + @moq/lite + Oscillator src
│ ├── pcm-tap-worklet.ts # AudioWorklet that posts Float32Array on every
│ │ # inputs[0] frame to the main thread
│ └── server.ts # bun static server + WebSocket back-channel
└── playwright.config.ts # Chromium-only; --enable-quic flags
Pin all @moq/* deps to the same versions nostrnests/nests
ships in NestsUI-v2/package.json. Document the rev in
nestsClient/tests/browser-interop/REV (parallel to
nestsClient/tests/hang-interop/REV).
2. listen.ts — browser listener
Mirrors NostrNests' transport/moq-transport.ts
Watch.Broadcast configuration verbatim where possible.
Reads relay, path, jwt (optional), ws, duration
from query params. Sets up an AudioContext, registers
pcm-tap-worklet, hooks the worklet into
broadcast.audio.root, posts every inputs[0]
Float32Array over the WebSocket back-channel as a binary
frame. Closes when duration elapses.
3. publish.ts — browser publisher
Reads same params plus freqHz and channels. Builds an
OscillatorNode at freqHz connected to a
MediaStreamAudioDestinationNode. Passes the resulting
MediaStream's audio track into Publish.Broadcast's
audio.source. Closes after duration.
4. server.ts — bun static + WebSocket back-channel
Bun HTTP server: serves listen.html, publish.html, and
the bundled JS from dist/. Bun WebSocket on a separate path
(e.g. /pcm): receives PCM chunks from the harness page and
appends them to a file the Gradle test reads. Argv: --port <int> --out-pcm <path>.
5. playwright.config.ts — Chromium with QUIC enabled
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
launchOptions: {
args: [
'--enable-quic',
'--ignore-certificate-errors', // self-signed harness cert
'--enable-features=AutoplayPolicy=NoUserGestureRequired',
// For tighter cert pinning:
// '--ignore-certificate-errors-spki-list=<sha256-of-test-cert>',
],
},
},
});
--ignore-certificate-errors is acceptable for a test-only
Chromium instance; preferred form
--ignore-certificate-errors-spki-list=<base64-sha256> is
documented but optional (cert SPKI is hard to compute from
the relay's auto-generated cert without parsing).
6. interopBuildBrowserHarness Gradle task
nestsClient/build.gradle.kts parallel to
interopBuildHangSidecars:
val interopBuildBrowserHarness by tasks.registering(Exec::class) {
description = "bun install && bun build for the browser interop harness"
group = "interop"
workingDir = file("nestsClient/tests/browser-interop")
commandLine("bash", "-c", "bun install && bun build src/listen.ts src/publish.ts src/pcm-tap-worklet.ts --outdir dist --target browser")
inputs.files(
fileTree("nestsClient/tests/browser-interop") {
include("package.json", "bun.lockb", "src/**/*")
}
)
outputs.dir("nestsClient/tests/browser-interop/dist")
}
A second task installs Playwright's Chromium:
val interopInstallPlaywrightChromium by tasks.registering(Exec::class) {
description = "Install Playwright Chromium + dependencies"
group = "interop"
workingDir = file("nestsClient/tests/browser-interop")
commandLine("bash", "-c", "npx playwright install --with-deps chromium")
onlyIf {
// Skip if Chromium binary exists in the cache
val home = System.getProperty("user.home")
!file("$home/.cache/ms-playwright/chromium-*").exists() // glob matches if any
}
}
Forward to test workers:
tasks.withType<Test>().configureEach {
val isBrowserInterop = System.getProperty("nestsBrowserInterop") == "true"
if (isBrowserInterop) {
dependsOn(interopBuildBrowserHarness, interopInstallPlaywrightChromium)
}
systemProperty(
"nestsBrowserInteropHarnessDir",
file("nestsClient/tests/browser-interop").absolutePath,
)
System.getProperty("nestsBrowserInterop")?.let {
systemProperty("nestsBrowserInterop", it)
}
}
7. Kotlin-side PlaywrightDriver + BrowserInteropTest
Path:
nestsClient/src/jvmTest/.../interop/native/PlaywrightDriver.kt
(new) and
nestsClient/src/jvmTest/.../interop/native/BrowserInteropTest.kt
(new).
PlaywrightDriver shells out to npx playwright test (or
uses playwright-java from Maven Central — verify
availability at implementation time). Returns when the
harness page reports completion via a final WebSocket
message or a console log.
object PlaywrightDriver {
fun openListenPage(
harnessUrl: String,
relayUrl: String,
path: String,
jwt: String?,
durationSec: Int,
wsOutPcm: File,
): Process { … }
fun openPublishPage(
harnessUrl: String,
relayUrl: String,
path: String,
jwt: String?,
freqHz: Int,
channels: Int,
durationSec: Int,
): Process { … }
}
Each invocation:
- Runs the bun static server on a random port (one per
test for isolation; reuses the same
:0-bound socket pattern asNativeMoqRelayHarness). - Spawns
npx playwright testwith--config playwright.config.tsand a per-test runner that opens the right URL with the right query params. - Plays through
durationSecseconds; WS server appends PCM frames towsOutPcmas native-endian Float32 LE. - Returns the Process so the test can kill it cleanly.
8. BrowserInteropTest scenarios
Mirror of HangInteropTest's shape. P0 scenarios per the
parent plan:
| ID | Direction | Speaker | Listener | Asserts |
|---|---|---|---|---|
| I1 browser | A→ref | Amethyst Kotlin | Chromium @moq/watch | FFT 440 Hz |
| I2 browser | both | … | … | late-join still gets tail |
| I3 browser | A→ref | Amethyst Kotlin | Chromium | mute window |
| I4 browser | both | Amethyst (stereo) | Chromium | per-channel FFT |
| I13 | A→ref | Amethyst | Chromium | 60 s, no eviction-driven silence |
| I14 | A→ref | Amethyst | Chromium | WebCodecs 3-frame warmup × T8 CSD-skip |
| I15 | A→ref | Amethyst | Chromium | WT-Protocol matches moq-lite-03 |
I1–I4 reuse the existing runSpeakerToHangListen harness
infrastructure but swap the listener subprocess from
hang-listen to PlaywrightDriver.openListenPage. The
harness already exposes relayUrl + relay UDP loopback;
no new harness API needed.
Phases
Total: ~1.5 days.
Phase 4.A — bun harness scaffold (~3 hr)
bun initinnestsClient/tests/browser-interop/. Pin@moq/lite,@moq/watch,@moq/publish,@moq/hangto the versionsnostrnests/nestsNestsUI-v2/package.jsonships at the time of implementation. Document inREV.- Write
listen.ts+pcm-tap-worklet.ts+listen.html. Mirror NostrNests'transport/moq-transport.tsWatch.Broadcastconfiguration verbatim. - Write
publish.ts+publish.html(sine source viaOscillatorNode→MediaStreamAudioDestinationNode). - Write
server.ts(bun static + WebSocket back-channel, writes PCM to a file on disk). - Wire
interopBuildBrowserHarnessGradle task.
Verify by running the bun server manually + opening
http://localhost:<port>/listen.html in a desktop Chromium
with the --enable-quic + --ignore-certificate-errors
flags; confirm a manual moq-relay + hang-publish behind it
delivers tone.
Phase 4.B — Playwright driver + Kotlin tests (~3 hr)
- Add Playwright (
@playwright/test) to the bun harness's dev deps. WireinteropInstallPlaywrightChromiumGradle task. - Write
PlaywrightDriver.ktshelling out tonpx playwright test(orplaywright-javaif available). Cert-pin via--ignore-certificate-errorsfor the test-only Chromium instance. - Write
BrowserInteropTest.ktwith the I1 forward scenario as the smoke test (Amethyst speaker → Chromium listener, FFT 440 Hz on the captured PCM).
Verify green via:
./gradlew :nestsClient:jvmTest \
--tests "com.vitorpamplona.nestsclient.interop.native.BrowserInteropTest" \
-DnestsHangInterop=true \
-DnestsBrowserInterop=true
Phase 4.C — additional P0 scenarios (~3 hr)
- I2 (late-join), I3 (mute), I4 (stereo if I4 stereo plan has landed; else skip and unblock when stereo merges).
- I13 (
framesPerGroup=50long broadcast — interesting because the Chromium path may have a different per-group cliff threshold thanhang-listen; this scenario likely NEEDSframesPerGroup=5like the hang-listen ones). - I14 (WebCodecs warmup × CSD-skip): assert that with T8's CODEC_CONFIG filter active, the browser receives a normal decode after the standard 3-frame warmup — no extra warmup penalty.
- I15 (
WT-Available-Protocolsround-trip): Playwright'sbrowser.newContext()exposes the response headers; assertWT-Protocolmatchesmoq-lite-03.
Per-scenario commits (one per BrowserInteropTest test
method).
Phase 4.D — CI integration (~1 hr)
- Add
browser-interopjob to.github/workflows/build.ymlparallel tohang-interop. CachenestsClient/tests/browser-interop/node_modulesand~/.cache/ms-playwrighton the bun.lockb hash. - Run
./gradlew :nestsClient:jvmTest -DnestsBrowserInterop=trueon Linux runners. macOS / Windows would double the matrix cost without catching new defects (Chromium QUIC behaviour is consistent across platforms in the test scenarios we care about).
Risks + mitigations
| Risk | Mitigation |
|---|---|
| Chromium WebTransport rejects self-signed cert | Use --ignore-certificate-errors for test-only Chromium. Long-term, --ignore-certificate-errors-spki-list=<sha256> is preferable but needs SPKI extraction from the relay's auto-generated cert. |
WebCodecs AudioDecoder not available in headless Chromium |
WebCodecs is in stable Chromium since 94 (2021); Playwright bundles current Chromium. Verify on PR. |
AudioWorklet on a headless context — AudioContext.resume() requires user gesture in some Chromium configs |
Pass --enable-features=AutoplayPolicy=NoUserGestureRequired (already in playwright.config.ts) AND call AudioContext.resume() explicitly in the harness page before adding the audio source. |
@moq/watch API changes between bun.lockb pins |
Pin to specific versions matching nostrnests/nests. Bump deliberately. |
| Bun → Playwright integration weird on CI runners | Fall back to node if bun doesn't ship Playwright runner properly; the harness server doesn't depend on bun-specific APIs. |
| WS back-channel binary frames vs JSON: Playwright captures only stdout, not WS | Server.ts writes PCM directly to disk; the test reads the file path forwarded from the runner. No WS-from-test path. |
Cold cache: 60s+ for npx playwright install --with-deps chromium |
Cache ~/.cache/ms-playwright on package.json hash. Document the cold cost in CI docs. |
Definition of done
nestsClient/tests/browser-interop/directory complete with bun + Playwright + sources building cleanly viainteropBuildBrowserHarness.- P0 scenarios green: I1 forward, I2, I3, I13, I14 (and I4 if the stereo plan landed).
- P1 scenarios green: I15 (
WT-Protocolround-trip). - CI:
browser-interopjob green on PRs and main. nestsClient/plans/2026-05-06-phase4-browser-harness-results.mdsummarising what landed, deviations, and follow-ups.nestsClient/plans/2026-05-06-cross-stack-interop-test-results.mdgets a "Phase 4" section appended.- The hang-tier scenarios (HangInteropTest) stay green when
-DnestsBrowserInterop=trueis OFF — no regression.
Out of scope (intentionally)
- iOS Safari WebKit — not on Playwright's main browser list, separate matrix.
- Mobile Chromium variants (Android Chrome, Samsung Internet) — desktop Chromium is what the production stack ships against today.
- Real device microphone in the publisher path — sine
via
OscillatorNodeis enough for wire-format and decoder correctness. Real-microphone parity is a field-test concern. moq-lite-04ALPN bump — the parent plan's out-of-scope, separate task. Pin--client-version moq-lite-03(matches the existing hang-tier scenarios).
When picking up
This plan is self-contained. The agent should:
- Read
nestsClient/plans/2026-05-06-cross-stack-interop-test.md(parent) andnestsClient/plans/2026-05-06-cross-stack-interop-test-results.md(Phase 1–3 status) for context on the harness. - Skim
nestsClient/src/jvmTest/.../interop/native/HangInteropTest.ktfor the existing scenario shape —BrowserInteropTestreusesrunSpeakerToHangListen's harness orchestration pattern. - Re-clone
kixelated/moqto/tmp/moqfor reference:git clone --depth=1 https://github.com/kixelated/moq.git /tmp/moq. Confirm/tmp/moq/js/watch,/tmp/moq/js/publish,/tmp/moq/js/lite,/tmp/moq/js/hangare present (sparse checkout if needed). The browser harness'slisten.ts/publish.tsmirror that JS. - Verify
bun --version≥ 1.3 andnpx playwright --versionavailable on the host. The cargo + Rust toolchain from Phase 1 stays unchanged. - Implement Phase 4.A first (scaffolding + manual verification), then 4.B (driver + first Kotlin test). Don't proceed to scenario expansion (4.C) until 4.B is green.
- Each scenario commits separately. The harness setup (4.A + 4.B) is one logical chunk.