diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49a7b5594..be8f2d415 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -239,6 +239,97 @@ jobs: name: Hang Interop Test Reports path: nestsClient/build/reports/tests/jvmTest/ + # Phase 4 of T16: browser-side cross-stack interop. Drives a headless + # Chromium (via Playwright) running @moq/lite + @moq/hang against + # the same moq-relay subprocess the hang-interop job uses. Linux-only: + # Chromium QUIC behaviour is consistent across platforms in the + # scenarios we care about, and macOS/Windows would double the matrix + # cost without catching new defects. + # + # Inherits the cargo cache from `hang-interop` because the browser + # path still needs `moq-relay` + `hang-listen` built (the harness + # boots the same Rust subprocess). Adds bun + node_modules + Playwright + # browser caches. See: + # nestsClient/plans/2026-05-06-phase4-browser-harness.md + browser-interop: + needs: lint + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: 21 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' }} + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + nestsClient/tests/hang-interop/target + ~/.cache/amethyst-nests-interop/hang-interop-cargo + key: ${{ runner.os }}-cargo-${{ hashFiles('nestsClient/tests/hang-interop/Cargo.lock', 'nestsClient/tests/hang-interop/REV') }} + restore-keys: | + ${{ runner.os }}-cargo- + + # bun is a separate install — Playwright + the bun harness build + # both go through it. Pin the version that matches the local agent + # tooling so a CI/local skew can't surface a wire-format change + # in `bun build` output. + - name: Set up bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + # Cache bun-installed node_modules for the browser harness. + # Keyed on package.json + bun.lock so a dep bump invalidates. + - name: Cache node_modules + uses: actions/cache@v4 + with: + path: | + nestsClient-browser-interop/node_modules + nestsClient-browser-interop/dist + key: ${{ runner.os }}-bun-${{ hashFiles('nestsClient-browser-interop/package.json', 'nestsClient-browser-interop/bun.lock') }} + + # Cache Playwright's browser cache (~/.cache/ms-playwright/chromium-*). + # The cold install of Chromium is ~200 MB and 30–60 s; cached runs + # are essentially instant. + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('nestsClient-browser-interop/package.json') }} + + - name: Run browser cross-stack interop suite + run: | + ./gradlew :nestsClient:jvmTest \ + --tests "com.vitorpamplona.nestsclient.interop.native.BrowserInteropTest" \ + -DnestsHangInterop=true \ + -DnestsBrowserInterop=true + + - name: Upload browser interop test report + uses: actions/upload-artifact@v7 + if: failure() + with: + name: Browser Interop Test Reports + path: | + nestsClient/build/reports/tests/jvmTest/ + nestsClient-browser-interop/test-results/ + nestsClient-browser-interop/playwright-report/ + test-and-build-android: needs: lint runs-on: ubuntu-latest diff --git a/nestsClient/plans/2026-05-06-phase4-browser-harness-results.md b/nestsClient/plans/2026-05-06-phase4-browser-harness-results.md new file mode 100644 index 000000000..aba42caa8 --- /dev/null +++ b/nestsClient/plans/2026-05-06-phase4-browser-harness-results.md @@ -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. diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt index a64cb33a5..1393e4548 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/native/BrowserInteropTest.kt @@ -83,6 +83,34 @@ class BrowserInteropTest { } } + /** + * **I15 (WT-Protocol round-trip)** — assert Chromium's WebTransport + * round-trip with `moq-relay 0.10.x` produces a known-good moq-lite + * version on the `Connection`. The harness page exposes + * `connection.version` at `window.__moqVersion`; the Playwright + * spec bundles it in the trailing JSON line on stdout. + * + * The assertion accepts any of the moq-lite draft versions the + * relay advertises through SETUP — Chromium's `@moq/lite` 0.2.x + * client offers `moq-lite-04`, `moq-lite-03`, `moql` (legacy) + * ALPNs in that priority. moq-relay 0.10.x's choice depends on + * its build flags, but the `moq-lite-` prefix is invariant. A + * regression that breaks ALPN negotiation entirely or + * downgrades to a non-lite version (`draft-17` etc.) is caught + * here even if I1 audio assertions still pass via a fallback path. + */ + @Test + fun chromium_round_trips_a_moq_lite_session() = + runBlocking { + val out = runSpeakerToBrowserListen(speakerSeconds = 5) + val moqVersion = parseMoqVersionFromStdout(out.stdout) + assertTrue( + moqVersion != null && moqVersion.startsWith("moq-lite-"), + "expected Chromium to round-trip a moq-lite-* version; got '$moqVersion'.\n" + + "playwright stdout:\n${out.stdout}", + ) + } + /** * **I1 forward (browser)** — Amethyst Kotlin speaker → Chromium * `@moq/lite` listener with `@moq/hang` `Container.Legacy.Consumer`. @@ -318,6 +346,23 @@ private object StaticTokenNestsClientForBrowser : NestsClient { ): String = "" } +/** + * Pull the `meta.moqVersion` field out of the trailing JSON line + * the Playwright spec emits to stdout. The spec writes a single + * `{"state":"done","meta":{"moqVersion":"moq-lite-03",...}}` line + * per run; we substring-search for it rather than wiring up a JSON + * dependency just for this one helper. + */ +private fun parseMoqVersionFromStdout(stdout: String): String? { + val needle = "\"moqVersion\":\"" + val start = stdout.indexOf(needle) + if (start < 0) return null + val valueStart = start + needle.length + val valueEnd = stdout.indexOf('"', valueStart) + if (valueEnd < 0) return null + return stdout.substring(valueStart, valueEnd) +} + /** * Read a file of native-endian Float32 LE PCM into a [FloatArray]. * Matches the format the bun WS server appends per binary frame —