bd7b166fda
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).
6.8 KiB
6.8 KiB
Plan: Phase 4 (browser harness) — landed results
Status: 4.A scaffold + 4.B Playwright driver + first Kotlin
test green; 4.C ships I15; 4.D ships the CI workflow job. Tracks
the spec at nestsClient/plans/2026-05-06-phase4-browser-harness.md.
Where it landed
- New top-level
nestsClient/tests/browser-interop/workspace:package.jsonpins@moq/lite@0.2.2,@moq/hang@0.2.4,@moq/watch@0.2.10,@moq/publish@0.2.6,@playwright/test@1.56.1.REVdocuments the pinned versions next to thenestsClient/tests/hang-interop/REV.src/listen.html+src/listen.ts— Watch path, usesContainer.Legacy.Consumerand WebCodecsAudioDecoderdirectly (the published@moq/hang0.2.4 doesn't expose the higher-levelContainer.Consumerfrom upstream HEAD; we wire its data path manually).src/publish.html+src/publish.ts— symmetric publisher scaffold for the I4-reverse / I14-decoder-warmup scenarios Phase 4.C extension can pick up.src/server.ts— bun static + WebSocket back-channel; the listener page posts Float32 LE PCM frames as binary messages, a textualdonemessage flips the server'sdoneflag.tests/harness.spec.ts— single Playwright spec the Kotlin driver invokes per scenario; readsNESTS_HARNESS_URLNESTS_TIMEOUT_MSfrom env.
playwright.config.ts— Chromium with--enable-quic,--ignore-certificate-errors, AutoplayPolicy override.
nestsClient/src/jvmTest/.../interop/native/PlaywrightDriver.kt— Kotlin shim that spawns the bun server +bun x playwright testper test, returns aHarnessRun(pcmFile, stdout, exit). Includes aCertCapturingValidatorthat pulls the relay's leaf cert during the speaker's QUIC handshake so we can pass its SHA-256 to Chromium viaserverCertificateHashes.nestsClient/src/jvmTest/.../interop/native/BrowserInteropTest.kt— two scenarios:- I1 forward (browser): Amethyst Kotlin speaker → Chromium
@moq/litelistener; asserts FFT 440 Hz on the captured tail. - I15 (WT-Protocol round-trip): asserts Chromium's
Connection.versionstarts withmoq-lite-.
- I1 forward (browser): Amethyst Kotlin speaker → Chromium
nestsClient/build.gradle.kts— two new tasks:interopBuildBrowserHarness(bun install + bun build → dist/),interopInstallPlaywrightChromium(skipped ifPLAYWRIGHT_BROWSERS_PATHalready points at a chromium build). Both gated on-DnestsBrowserInterop=truelike the hang tier is gated on-DnestsHangInterop=true.
.github/workflows/build.yml— newbrowser-interopjob parallel tohang-interop, with bun + node_modules + Playwright caches.
Deviations from the spec
- Source layout:
@moq/lite+@moq/hangdirect, NOT@moq/watchWatch.Broadcast. The spec called for mirroring NostrNests'stransport/moq-transport.tsWatch.Broadcastverbatim; in practice@moq/watch0.2.x bakes in a heavy reactiveEffect/Signallayer that's unwieldy for a one-shot capture page. The lower-levelconnection.consume(path) → broadcast.subscribe(track) → track.readFrame()pipeline is what the watch decoder uses internally, so this is a functionally equivalent path. NostrNests-side regressions inWatch.Broadcastplumbing aren't in scope of T16. - Cert pinning via
serverCertificateHashes, not--ignore-certificate-errors. Chromium's--ignore-certificate-errorsflag does NOT bypass QUIC cert validation — reproduced asnet::ERR_QUIC_PROTOCOL_ERROR. QUIC_TLS_CERTIFICATE_UNKNOWN. The spec mentioned--ignore-certificate-errors-spki-listas a "preferred long- term form"; we useserverCertificateHashes(Web-API equivalent), which works because moq-relay's--tls-generateproduces a 14-day ECDSA P-256 cert — exactly what the WebTransport spec requires for a serverCertificateHashes pin. TheCertCapturingValidatorsnags the cert during the speaker's QUIC handshake so we don't need a separate fingerprint endpoint. - I1 sample-count assertion loosened. Hang-tier I1 asserts
assertSampleCount(expected = 5 s, tolerance = 0.20)— the browser path can't hit that because Chromium cold-launch + Playwright runner setup eats 3–10 s before the page starts capturing, by which time theframesPerGroup = 5per-subscriber forward cliff means only the latest cached group is replayable. The browser I1 instead asserts ≥ 1 s of decoded audio + FFT peak at 440 Hz. The FFT peak is the load-bearing assertion (catches downmix / channel-swap / OpusHead-leak regressions); the sample-count threshold is just a sanity floor. - Phase 4.C scenarios I2/I3/I4/I13/I14 deferred. I2 late-join collapses into "tail capture" anyway given the Chromium boot lag, so it's not adding signal beyond I1. I3 mute-window has the same visibility issue. I4 needs the reverse publisher path wired up end-to-end (a stub publish.ts landed but isn't exercised by a Kotlin test yet). I13 long broadcast and I14 CSD-skip are runtime-of-test concerns the I1 path already exercises implicitly. Tracked as a follow-up on a separate plan if/when the gap matters.
Verification
./gradlew :nestsClient:jvmTest \
--tests "com.vitorpamplona.nestsclient.interop.native.BrowserInteropTest" \
-DnestsHangInterop=true \
-DnestsBrowserInterop=true
Both amethyst_speaker_to_chromium_listener_static_tone_440 and
chromium_round_trips_a_moq_lite_session pass in isolation.
Follow-ups
- I4-reverse: wire
BrowserInteropTestto drivePlaywrightDriver.openPublishPage(the Kotlin side already exposes the entry point) → Amethyst Kotlin listener decodes; assert per-channel FFT peaks. Needs the publish.ts harness graduated from scaffold to a fully-working pump (theMediaStreamTrackProcessor→AudioEncoder→Container.Legacy. Producerchain compiles but isn't yet validated end-to-end). - I3 mute-window: works on the Kotlin speaker side, but the short browser tail capture window means the mute-gap deficit isn't observable. Would need either a longer broadcast (60 s+) or a tighter capture window that brackets the mute schedule reliably. Low priority — the hang-tier I3 already validates the speaker-side mute behaviour against a parser-correct watcher.
- I15 strict pin: when moq-relay 0.10.x ships with both
moq-lite-03andmoq-lite-04ALPN advertisement, tighten the assertion fromstartsWith("moq-lite-")to exact-matchmoq-lite-03(or whichever the production stack runs). Right now the relay we boot landsmoq-lite-02over the legacymoqlALPN. - CI cold-cache time: cold
npx playwright install chromiumtakes ~60 s on a fresh GitHub runner. Theactions/cache@v4hits keyed onpackage.jsonshould make warm runs near-zero, but the first run on a new branch will be slow.