Merge remote-tracking branch 'origin/feat/nests-browser-interop' into claude/cross-stack-interop-test-XAbYB
# Conflicts: # nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/NativeMoqRelayHarness.kt
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
# 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-browser-interop/` workspace:
|
||||
- `package.json` pins `@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`.
|
||||
- `REV` documents the pinned versions next to the
|
||||
`nestsClient/tests/hang-interop/REV`.
|
||||
- `src/listen.html` + `src/listen.ts` — Watch path, uses
|
||||
`Container.Legacy.Consumer` and WebCodecs `AudioDecoder`
|
||||
directly (the published `@moq/hang` 0.2.4 doesn't expose the
|
||||
higher-level `Container.Consumer` from 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 textual `done` message flips the server's `done` flag.
|
||||
- `tests/harness.spec.ts` — single Playwright spec the Kotlin
|
||||
driver invokes per scenario; reads `NESTS_HARNESS_URL`
|
||||
+ `NESTS_TIMEOUT_MS` from 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
|
||||
test` per test, returns a `HarnessRun(pcmFile, stdout, exit)`.
|
||||
Includes a `CertCapturingValidator` that pulls the relay's leaf
|
||||
cert during the speaker's QUIC handshake so we can pass its
|
||||
SHA-256 to Chromium via `serverCertificateHashes`.
|
||||
- `nestsClient/src/jvmTest/.../interop/native/BrowserInteropTest.kt`
|
||||
— two scenarios:
|
||||
- **I1 forward (browser)**: Amethyst Kotlin speaker → Chromium
|
||||
`@moq/lite` listener; asserts FFT 440 Hz on the captured tail.
|
||||
- **I15 (WT-Protocol round-trip)**: asserts Chromium's
|
||||
`Connection.version` starts with `moq-lite-`.
|
||||
- `nestsClient/build.gradle.kts` — two new tasks:
|
||||
- `interopBuildBrowserHarness` (bun install + bun build → dist/),
|
||||
- `interopInstallPlaywrightChromium` (skipped if
|
||||
`PLAYWRIGHT_BROWSERS_PATH` already points at a chromium build).
|
||||
Both gated on `-DnestsBrowserInterop=true` like the hang tier
|
||||
is gated on `-DnestsHangInterop=true`.
|
||||
- `.github/workflows/build.yml` — new `browser-interop` job
|
||||
parallel to `hang-interop`, with bun + node_modules + Playwright
|
||||
caches.
|
||||
|
||||
## Deviations from the spec
|
||||
|
||||
1. **Source layout: `@moq/lite` + `@moq/hang` direct, NOT
|
||||
`@moq/watch` `Watch.Broadcast`.** The spec called for mirroring
|
||||
NostrNests's `transport/moq-transport.ts` `Watch.Broadcast`
|
||||
verbatim; in practice `@moq/watch` 0.2.x bakes in a heavy
|
||||
reactive `Effect`/`Signal` layer that's unwieldy for a one-shot
|
||||
capture page. The lower-level `connection.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 in
|
||||
`Watch.Broadcast` plumbing aren't in scope of T16.
|
||||
2. **Cert pinning via `serverCertificateHashes`, not
|
||||
`--ignore-certificate-errors`.** Chromium's
|
||||
`--ignore-certificate-errors` flag does NOT bypass QUIC cert
|
||||
validation — reproduced as `net::ERR_QUIC_PROTOCOL_ERROR.
|
||||
QUIC_TLS_CERTIFICATE_UNKNOWN`. The spec mentioned
|
||||
`--ignore-certificate-errors-spki-list` as a "preferred long-
|
||||
term form"; we use `serverCertificateHashes` (Web-API equivalent),
|
||||
which works because moq-relay's `--tls-generate` produces a
|
||||
14-day ECDSA P-256 cert — exactly what the WebTransport spec
|
||||
requires for a serverCertificateHashes pin. The
|
||||
`CertCapturingValidator` snags the cert during the speaker's
|
||||
QUIC handshake so we don't need a separate fingerprint endpoint.
|
||||
3. **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 the `framesPerGroup = 5`
|
||||
per-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.
|
||||
4. **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
|
||||
|
||||
```bash
|
||||
./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 `BrowserInteropTest` to drive
|
||||
`PlaywrightDriver.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 (the
|
||||
`MediaStreamTrackProcessor` → `AudioEncoder` → `Container.Legacy.
|
||||
Producer` chain 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-03` and `moq-lite-04` ALPN advertisement, tighten the
|
||||
assertion from `startsWith("moq-lite-")` to exact-match
|
||||
`moq-lite-03` (or whichever the production stack runs). Right
|
||||
now the relay we boot lands `moq-lite-02` over the legacy
|
||||
`moql` ALPN.
|
||||
- **CI cold-cache time**: cold `npx playwright install chromium`
|
||||
takes ~60 s on a fresh GitHub runner. The `actions/cache@v4`
|
||||
hits keyed on `package.json` should make warm runs near-zero,
|
||||
but the first run on a new branch will be slow.
|
||||
Reference in New Issue
Block a user