# 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, same `OpusHead` regression risk per T8/T14), - AudioWorklet rendering timing (200 ms playback buffer + AudioContext clock drift), - `WT-Available-Protocols` / `WT-Protocol` ALPN 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: 1. **forward** β€” Amethyst Kotlin speaker β†’ headless Chromium listener (`@moq/watch`), tone recoverable from PCM tap. 2. **reverse** β€” headless Chromium publisher (`@moq/publish`) β†’ Amethyst Kotlin listener, tone recoverable. 3. browser-only scenarios I13 (`framesPerGroup=50` long broadcast against `Container.Consumer`), I14 (WebCodecs warmup Γ— CSD-skip interaction), I15 (`WT-Available-Protocols` Chromium 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: β”‚ β”‚ 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 ` `--out-pcm `. ### 5. `playwright.config.ts` β€” Chromium with QUIC enabled ```ts 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=', ], }, }, }); ``` `--ignore-certificate-errors` is acceptable for a test-only Chromium instance; preferred form `--ignore-certificate-errors-spki-list=` 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`: ```kotlin 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: ```kotlin 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: ```kotlin tasks.withType().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. ```kotlin 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: 1. Runs the bun static server on a random port (one per test for isolation; reuses the same `:0`-bound socket pattern as `NativeMoqRelayHarness`). 2. Spawns `npx playwright test` with `--config playwright.config.ts` and a per-test runner that opens the right URL with the right query params. 3. Plays through `durationSec` seconds; WS server appends PCM frames to `wsOutPcm` as native-endian Float32 LE. 4. 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) 1. `bun init` in `nestsClient/tests/browser-interop/`. Pin `@moq/lite`, `@moq/watch`, `@moq/publish`, `@moq/hang` to the versions `nostrnests/nests` `NestsUI-v2/package.json` ships at the time of implementation. Document in `REV`. 2. Write `listen.ts` + `pcm-tap-worklet.ts` + `listen.html`. Mirror NostrNests' `transport/moq-transport.ts` `Watch.Broadcast` configuration verbatim. 3. Write `publish.ts` + `publish.html` (sine source via `OscillatorNode` β†’ `MediaStreamAudioDestinationNode`). 4. Write `server.ts` (bun static + WebSocket back-channel, writes PCM to a file on disk). 5. Wire `interopBuildBrowserHarness` Gradle task. Verify by running the bun server manually + opening `http://localhost:/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) 6. Add Playwright (`@playwright/test`) to the bun harness's dev deps. Wire `interopInstallPlaywrightChromium` Gradle task. 7. Write `PlaywrightDriver.kt` shelling out to `npx playwright test` (or `playwright-java` if available). Cert-pin via `--ignore-certificate-errors` for the test-only Chromium instance. 8. Write `BrowserInteropTest.kt` with the I1 forward scenario as the smoke test (Amethyst speaker β†’ Chromium listener, FFT 440 Hz on the captured PCM). Verify green via: ```bash ./gradlew :nestsClient:jvmTest \ --tests "com.vitorpamplona.nestsclient.interop.native.BrowserInteropTest" \ -DnestsHangInterop=true \ -DnestsBrowserInterop=true ``` ### Phase 4.C β€” additional P0 scenarios (~3 hr) 9. I2 (late-join), I3 (mute), I4 (stereo if I4 stereo plan has landed; else skip and unblock when stereo merges). 10. I13 (`framesPerGroup=50` long broadcast β€” interesting because the Chromium path may have a different per-group cliff threshold than `hang-listen`; this scenario likely NEEDS `framesPerGroup=5` like the hang-listen ones). 11. 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. 12. I15 (`WT-Available-Protocols` round-trip): Playwright's `browser.newContext()` exposes the response headers; assert `WT-Protocol` matches `moq-lite-03`. Per-scenario commits (one per `BrowserInteropTest` test method). ### Phase 4.D β€” CI integration (~1 hr) 13. Add `browser-interop` job to `.github/workflows/build.yml` parallel to `hang-interop`. Cache `nestsClient/tests/browser-interop/node_modules` and `~/.cache/ms-playwright` on the bun.lockb hash. 14. Run `./gradlew :nestsClient:jvmTest -DnestsBrowserInterop=true` on 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=` 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 1. `nestsClient/tests/browser-interop/` directory complete with bun + Playwright + sources building cleanly via `interopBuildBrowserHarness`. 2. P0 scenarios green: I1 forward, I2, I3, I13, I14 (and I4 if the stereo plan landed). 3. P1 scenarios green: I15 (`WT-Protocol` round-trip). 4. CI: `browser-interop` job green on PRs and main. 5. `nestsClient/plans/2026-05-06-phase4-browser-harness-results.md` summarising what landed, deviations, and follow-ups. 6. `nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md` gets a "Phase 4" section appended. 7. The hang-tier scenarios (HangInteropTest) stay green when `-DnestsBrowserInterop=true` is 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 `OscillatorNode` is enough for wire-format and decoder correctness. Real-microphone parity is a field-test concern. - **`moq-lite-04` ALPN 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: 1. Read `nestsClient/plans/2026-05-06-cross-stack-interop-test.md` (parent) and `nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md` (Phase 1–3 status) for context on the harness. 2. Skim `nestsClient/src/jvmTest/.../interop/native/HangInteropTest.kt` for the existing scenario shape β€” `BrowserInteropTest` reuses `runSpeakerToHangListen`'s harness orchestration pattern. 3. Re-clone `kixelated/moq` to `/tmp/moq` for 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/hang` are present (sparse checkout if needed). The browser harness's `listen.ts` / `publish.ts` mirror that JS. 4. Verify `bun --version` β‰₯ 1.3 and `npx playwright --version` available on the host. The cargo + Rust toolchain from Phase 1 stays unchanged. 5. 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. 6. Each scenario commits separately. The harness setup (4.A + 4.B) is one logical chunk.