Files
amethyst/nestsClient/plans/2026-05-06-phase4-browser-harness.md
T
Claude 589ced580c docs(nests): refresh all T16 + cliff plans to current state
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 mention 00f6cba31 + 207057374 were 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.
2026-05-07 14:52:21 +00:00

444 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Plan: Phase 4 — browser-side cross-stack harness (T16)
**Status:** ✅ Landed. Browser harness lives at
`nestsClient/tests/browser-interop/`; tests are
`BrowserInteropTest.kt` covering I1, I2, I3, I4 (stereo), I5
(hot-swap), I7 (publisher reconnect), I9 (packet loss), I13
(long broadcast), I14 (WebCodecs warmup × CSD), I15 (ALPN).
Companion landed-results doc:
`2026-05-06-phase4-browser-harness-results.md`.
The spec text below is preserved for archaeology; some pieces
shifted during implementation:
- `@moq/watch` / `@moq/publish` weren't directly used; the
harness uses `@moq/lite` + `@moq/hang` `Container.Legacy.Consumer/Producer`
because the published `@moq/hang` 0.2.4 didn't expose the
high-level `Container.Consumer` API.
- Cert pinning uses `serverCertificateHashes` not
`--ignore-certificate-errors` because Chromium's flag does
NOT bypass QUIC cert validation.
- Phase 4.C originally deferred I2/I3/I4/I13/I14 — those
subsequently landed, see results doc.
**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:<rand> │ │ 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
<int>` `--out-pcm <path>`.
### 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=<sha256-of-test-cert>',
],
},
},
});
```
`--ignore-certificate-errors` is acceptable for a test-only
Chromium instance; preferred form
`--ignore-certificate-errors-spki-list=<base64-sha256>` 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<Test>().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` |
I1I4 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:<port>/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=<sha256>` 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 13 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.