8 plan files updated to reflect what's actually shipped vs. what each doc said before: - 2026-05-06-cross-stack-interop-test.md: 'Spec — ready to implement' → 'Implemented and merged' with pointers to results, gap matrix, and closure roadmap. - 2026-05-06-cross-stack-interop-test-results.md: scenario inventory updated to show all branches merged into claude/cross-stack-interop-test-XAbYB; suite-flake caveats flagged with cross-refs to the routing investigation; file inventory now lists BrowserInteropTest + PlaywrightDriver + the moved nestsClient/tests/browser-interop/ tree (no more 'in sister branches not yet merged' section). - 2026-05-06-cross-stack-interop-test-gap-matrix.md: T8/T10/T11/ T12/T13 all show hang ✅ + browser ✅; I13/I14 'NOT YET LANDED' callouts removed; the 'in sister branch' qualifier on T13 / I7 / I6 dropped; coverage-holes section replaced with caveat pointers to the routing investigation. - 2026-05-06-i4-stereo-cross-stack-scenario.md: 'Spec — ready for implementation' → 'Landed' with PR #2755 + the three test scenarios that ship the assertion. - 2026-05-06-phase4-browser-harness.md: 'Spec — ready for implementation' → 'Landed', with notes on how the implementation diverged (used Container.Legacy.Consumer not @moq/watch; cert pinning via serverCertificateHashes; I2/I3/I4/I13/I14 originally deferred but later landed). - 2026-05-06-phase4-browser-harness-results.md: status updated to reflect 4.D CI removed per maintainer ask; layout note for the nestsClient/tests/browser-interop/ relocation; full scenario list reconciled with results doc. - 2026-05-07-late-join-catalog-flake-investigation.md: status updated to mention00f6cba31+207057374were reverted, and link to 2026-05-07-moq-relay-routing-investigation.md as the action plan. Adds note that the flake also affects four browser-tier scenarios after Browser I7 landed. - 2026-05-01-quic-stream-cliff-investigation.md: adds an HCgOY- override banner — recommendation 2 (framesPerGroup = 5) was superseded 4 days later by HCgOY field tests landing the prod default at 50. Cross-refs the reconciliation + production- rerun plans. Open follow-up #3 ('reset to 1') updated with the same context. No code or test changes.
7.5 KiB
Plan: Phase 4 (browser harness) — landed results
Status: ✅ Phase 4.A (scaffold) + 4.B (Playwright driver) +
4.C (full scenario coverage) all landed. Phase 4.D (CI workflow
job) was originally added in commit c79a3ffa8 but later removed
per maintainer ask in commit b94737de7 ("don't add these tests
to the build.yml for now"); see
2026-05-07-cross-stack-interop-ci-gating.md for the path to
re-wiring.
Browser harness location: nestsClient/tests/browser-interop/
(was originally nestsClient-browser-interop/ at repo root;
moved to mirror nestsClient/tests/hang-interop/ layout).
Final scenario coverage: I1, I2, I3, I4 (stereo), I5
(hot-swap), I7 (Chromium publisher reconnect), I9 (packet loss),
I13 (long broadcast), I14 (WebCodecs warmup × CSD), I15 (ALPN).
See 2026-05-06-cross-stack-interop-test-results.md for the
full inventory.
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.