chore(nests): mv nestsClient-browser-interop/ → nestsClient/tests/browser-interop/

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).
This commit is contained in:
Claude
2026-05-07 14:43:55 +00:00
parent 32065901c8
commit bd7b166fda
21 changed files with 33 additions and 33 deletions
+2 -2
View File
@@ -232,7 +232,7 @@ tasks.withType<Test>().configureEach {
// ---- Cross-stack interop: BROWSER (Phase 4 of T16) --------------------------
//
// Adds the bun + Playwright + headless Chromium harness at
// `nestsClient-browser-interop/`. Mirrors the hang-interop wiring above
// `nestsClient/tests/browser-interop/`. Mirrors the hang-interop wiring above
// but with bun/npx subprocesses instead of cargo. Opt-in via
// `-DnestsBrowserInterop=true`. See:
// nestsClient/plans/2026-05-06-phase4-browser-harness.md
@@ -249,7 +249,7 @@ tasks.withType<Test>().configureEach {
// paths the agents/host runner ship with.
val browserInteropDir =
rootProject.layout.projectDirectory.dir("nestsClient-browser-interop")
rootProject.layout.projectDirectory.dir("nestsClient/tests/browser-interop")
// `bun` lives at `/root/.bun/bin/bun` on the agent runner. CI may put it
// elsewhere; allow override via env / system property. Falls back to
@@ -379,7 +379,7 @@ adds an explicit goaway frame, the test would slot in here.
Running in agent worktree (`feat/nests-browser-interop`). Adds:
- `nestsClient-browser-interop/` — TypeScript + Vite project shipping
- `nestsClient/tests/browser-interop/` — TypeScript + Vite project shipping
the upstream `@kixelated/moq` and `@kixelated/hang-wasm` consumers/
publishers, bundled into static `listen.html` / `publish.html`
pages.
@@ -492,7 +492,7 @@ nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/
# In sister branches (not yet merged):
# feat/nests-i6-multi-listener -> HangInteropMultiListenerTest.kt (I6)
# feat/nests-i7-publisher-reconnect -> HangInteropReverseTest.kt (I7)
# feat/nests-browser-interop -> nestsClient-browser-interop/ +
# feat/nests-browser-interop -> nestsClient/tests/browser-interop/ +
# BrowserInteropTest.kt (Phase 4)
nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md # this file
@@ -196,10 +196,10 @@ Behaviour: standard tokio UDP relay. For each datagram received,
### Browser harness (bun + Playwright)
New module at `nestsClient-browser-interop/`.
New module at `nestsClient/tests/browser-interop/`.
```
nestsClient-browser-interop/
nestsClient/tests/browser-interop/
├── package.json
├── tsconfig.json
├── src/
@@ -225,7 +225,7 @@ nestsClient-browser-interop/
```
Pin to the same npm versions that `nostrnests/nests` `NestsUI-v2/package.json`
ships. Document the rev in `nestsClient-browser-interop/REV`.
ships. Document the rev in `nestsClient/tests/browser-interop/REV`.
#### `listen.ts`
Mirrors NostrNests' `transport/moq-transport.ts` `Watch.Broadcast`
@@ -552,7 +552,7 @@ Total: ~5 days. P0 deliverable (1+2+4) is **3 days**.
### Phase 4 — Browser harness (1.5 days)
15. Bootstrap `nestsClient-browser-interop/`: bun init, install
15. Bootstrap `nestsClient/tests/browser-interop/`: bun init, install
`@moq/lite` `@moq/watch` `@moq/publish` `@moq/hang` at pinned
versions matching `nostrnests/nests` `NestsUI-v2`. Document rev.
16. Write `listen.ts` + `pcm-tap-worklet.ts` + `listen.html`. Mirror
@@ -618,8 +618,8 @@ jobs:
path: |
~/.cargo/registry
~/.cache/ms-playwright
nestsClient-browser-interop/node_modules
key: ${{ runner.os }}-browser-${{ hashFiles('nestsClient-browser-interop/bun.lockb', 'nestsClient/tests/hang-interop/Cargo.lock') }}
nestsClient/tests/browser-interop/node_modules
key: ${{ runner.os }}-browser-${{ hashFiles('nestsClient/tests/browser-interop/bun.lockb', 'nestsClient/tests/hang-interop/Cargo.lock') }}
- run: ./gradlew :nestsClient:jvmTest -DnestsBrowserInterop=true
```
@@ -664,7 +664,7 @@ Acceptable for PR-level CI.
committed at `nestsClient/plans/2026-05-06-cross-stack-interop-test-gap-matrix.md`.
6. Both `-DnestsHangInterop=true` and `-DnestsBrowserInterop=true`
in the default PR-level GitHub Actions config.
7. `nestsClient/tests/hang-interop/REV` and `nestsClient-browser-interop/REV`
7. `nestsClient/tests/hang-interop/REV` and `nestsClient/tests/browser-interop/REV`
document the pinned upstream revs.
8. New plan filed at
`nestsClient/plans/2026-05-06-cross-stack-interop-test-results.md`
@@ -331,7 +331,7 @@ tests stay green.
- **5.1 / spatial audio.** Catalog field is `numberOfChannels`,
but the audio pipeline assumes interleaved planar — beyond
stereo would need a separate plan.
- **Browser side I4.** `nestsClient-browser-interop/` doesn't
- **Browser side I4.** `nestsClient/tests/browser-interop/` doesn't
exist yet (Phase 4 of the parent plan); when it lands the
same I4 shape ports straight to a `BrowserInteropTest`.
- **Per-channel mute.** The existing `setMuted(true)` mutes the
@@ -6,7 +6,7 @@ the spec at `nestsClient/plans/2026-05-06-phase4-browser-harness.md`.
## Where it landed
- New top-level `nestsClient-browser-interop/` workspace:
- New top-level `nestsClient/tests/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
@@ -65,7 +65,7 @@ Phase 1 — no Docker, no second relay, no fake auth sidecar.
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────┐ ┌─────────────────────────────┐
│ NativeMoqRelayHarness│ │ Kotlin in-proc │ │ Browser harness │
│ (existing — Phase 1) │ │ speaker / listener│ │ nestsClient-browser-interop/│
│ (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 │
@@ -79,13 +79,13 @@ Phase 1 — no Docker, no second relay, no fake auth sidecar.
## Components
### 1. `nestsClient-browser-interop/` — bun + Playwright workspace
### 1. `nestsClient/tests/browser-interop/` — bun + Playwright workspace
New top-level directory, mirrors the parent plan's
specification. Contents:
```
nestsClient-browser-interop/
nestsClient/tests/browser-interop/
├── package.json
├── tsconfig.json
├── bun.lockb # pinned via REV file
@@ -103,7 +103,7 @@ nestsClient-browser-interop/
Pin all `@moq/*` deps to the same versions `nostrnests/nests`
ships in `NestsUI-v2/package.json`. Document the rev in
`nestsClient-browser-interop/REV` (parallel to
`nestsClient/tests/browser-interop/REV` (parallel to
`nestsClient/tests/hang-interop/REV`).
### 2. `listen.ts` — browser listener
@@ -168,14 +168,14 @@ the relay's auto-generated cert without parsing).
val interopBuildBrowserHarness by tasks.registering(Exec::class) {
description = "bun install && bun build for the browser interop harness"
group = "interop"
workingDir = file("nestsClient-browser-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-browser-interop") {
fileTree("nestsClient/tests/browser-interop") {
include("package.json", "bun.lockb", "src/**/*")
}
)
outputs.dir("nestsClient-browser-interop/dist")
outputs.dir("nestsClient/tests/browser-interop/dist")
}
```
@@ -185,7 +185,7 @@ A second task installs Playwright's Chromium:
val interopInstallPlaywrightChromium by tasks.registering(Exec::class) {
description = "Install Playwright Chromium + dependencies"
group = "interop"
workingDir = file("nestsClient-browser-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
@@ -205,7 +205,7 @@ tasks.withType<Test>().configureEach {
}
systemProperty(
"nestsBrowserInteropHarnessDir",
file("nestsClient-browser-interop").absolutePath,
file("nestsClient/tests/browser-interop").absolutePath,
)
System.getProperty("nestsBrowserInterop")?.let {
systemProperty("nestsBrowserInterop", it)
@@ -288,7 +288,7 @@ Total: ~1.5 days.
### Phase 4.A — bun harness scaffold (~3 hr)
1. `bun init` in `nestsClient-browser-interop/`. Pin `@moq/lite`,
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`.
@@ -351,7 +351,7 @@ method).
13. Add `browser-interop` job to `.github/workflows/build.yml`
parallel to `hang-interop`. Cache
`nestsClient-browser-interop/node_modules` and
`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
@@ -373,7 +373,7 @@ method).
## Definition of done
1. `nestsClient-browser-interop/` directory complete with
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
@@ -73,13 +73,13 @@ browser-interop:
- 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') }}
nestsClient/tests/browser-interop/node_modules
nestsClient/tests/browser-interop/dist
key: ${{ runner.os }}-bun-${{ hashFiles('nestsClient/tests/browser-interop/package.json', 'nestsClient/tests/browser-interop/bun.lock') }}
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('nestsClient-browser-interop/package.json') }}
key: ${{ runner.os }}-playwright-${{ hashFiles('nestsClient/tests/browser-interop/package.json') }}
- name: Run browser cross-stack interop suite
run: |
./gradlew :nestsClient:jvmTest \
@@ -92,8 +92,8 @@ browser-interop:
name: Browser Interop Test Reports
path: |
nestsClient/build/reports/tests/jvmTest/
nestsClient-browser-interop/test-results/
nestsClient-browser-interop/playwright-report/
nestsClient/tests/browser-interop/test-results/
nestsClient/tests/browser-interop/playwright-report/
```
Same `git show b94737de7 -- .github/workflows/build.yml` reverse
@@ -138,7 +138,7 @@ class BrowserInteropTest {
* `Container.Consumer`", but two constraints reshape this here:
*
* 1. `@moq/hang` 0.2.4 (the published version pinned in
* `nestsClient-browser-interop/package.json`) does not export
* `nestsClient/tests/browser-interop/package.json`) does not export
* the high-level `Container.Consumer` / `Format` API. Phase 4
* uses `Container.Legacy.Consumer` directly same data path
* `@moq/watch` uses internally for `container.kind = "legacy"`.
@@ -52,7 +52,7 @@ internal object PlaywrightDriver {
const val ENABLE_PROPERTY = "nestsBrowserInterop"
/**
* Forwarded by Gradle: absolute path to `nestsClient-browser-interop/`.
* Forwarded by Gradle: absolute path to `nestsClient/tests/browser-interop/`.
*/
const val HARNESS_DIR_PROPERTY = "nestsBrowserInteropHarnessDir"
@@ -0,0 +1,5 @@
node_modules/
dist/
test-results/
playwright-report/
.bun/
+31
View File
@@ -0,0 +1,31 @@
# Pinned upstream npm package versions for the browser-side cross-stack
# interop harness (Phase 4 of T16).
#
# These versions are what `nestsClient-browser-interop/package.json` pins
# and what the bun build resolves at install time. Bumping requires
# touching package.json + bun.lockb + this file together so a silent
# upstream rev change can't mask a regression.
#
# See: nestsClient/plans/2026-05-06-phase4-browser-harness.md
#
# Source: https://github.com/kixelated/moq , workspace published to npm
# under the @moq/* scope. The `@moq/lite` 0.2.x line implements
# `moq-lite-03` (see /tmp/moq/js/lite/src/lite/), matching the pin in
# nestsClient/tests/hang-interop/REV (KIXELATED_MOQ_GIT_REV).
# Browser listener: builds Watch.Broadcast on top of @moq/lite +
# @moq/hang. We use @moq/lite + @moq/hang directly for the harness path
# (closer to nestsClient's own moq-lite stack) but keep @moq/watch
# pinned in case a future scenario wants the higher-level reactive
# Broadcast wrapper.
MOQ_WATCH_VERSION=0.2.10
# Browser publisher: same story for @moq/publish.
MOQ_PUBLISH_VERSION=0.2.6
# Lower-level moq-lite-03 client + hang catalog/container.
MOQ_LITE_VERSION=0.2.2
MOQ_HANG_VERSION=0.2.4
# Playwright Chromium driver.
PLAYWRIGHT_VERSION=1.56.1
@@ -0,0 +1,73 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "nests-browser-interop",
"dependencies": {
"@moq/hang": "0.2.4",
"@moq/lite": "0.2.2",
"@moq/publish": "0.2.6",
"@moq/watch": "0.2.10",
},
"devDependencies": {
"@playwright/test": "1.56.1",
"@types/bun": "latest",
"typescript": "^5.6.0",
},
},
},
"packages": {
"@kixelated/libavjs-webcodecs-polyfill": ["@kixelated/libavjs-webcodecs-polyfill@0.5.5", "", { "dependencies": { "@libav.js/types": "^6.7.7", "@ungap/global-this": "^0.4.4" } }, "sha512-Q1zgnTMMQ2F7IE9ylx3C1XzVbg5vYN18jiDINO5U3kNPBOHdYuUlJsMhtBoqr1M6ocLtoiqdHmLs7tHFgrw5KA=="],
"@libav.js/types": ["@libav.js/types@6.8.8", "", {}, "sha512-Lbik/0Q3x2R8cI7mOtRgt+nUWLqGXh7UinMndmpdXSDY4YEjYyVUDsq6fxkuriL78+LCYx8frZIN1r+oDsvYCQ=="],
"@libav.js/variant-opus-af": ["@libav.js/variant-opus-af@6.8.8", "", {}, "sha512-8KBQyA8n5goN7lyctOaPxpcx7dapOgqKh8dWW/NAcl87AgM/WoUGSex3fFc46oCtTHYrUKEm1OmZUrtkt3Q56A=="],
"@moq/hang": ["@moq/hang@0.2.4", "", { "dependencies": { "@kixelated/libavjs-webcodecs-polyfill": "^0.5.5", "@libav.js/variant-opus-af": "^6.8.8", "@moq/lite": "^0.2.2", "@moq/signals": "^0.1.6", "@svta/cml-iso-bmff": "^1.0.0-alpha.9", "zod": "^4.1.5" } }, "sha512-I7OzutII+Sp5oWKd33t6b1SSY9Tu2dpu6pEaUp7CAKzNRpIaE7O6DhWBsBlcGAMTuDj/zA3CkR3iGx7WW2WW6w=="],
"@moq/lite": ["@moq/lite@0.2.2", "", { "dependencies": { "@moq/qmux": "^0.0.6", "@moq/signals": "^0.1.6", "async-mutex": "^0.5.0" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-o5X4qQlfhO8xWcWwpYsEEWWHr66SIPQKFY++ZxgMYhU+77rTfe1vWgomYjqoQcCZT3flRY2iTRLJriHgyDX/gA=="],
"@moq/msf": ["@moq/msf@0.1.0", "", { "dependencies": { "@moq/lite": "^0.2.1", "zod": "^4.1.5" } }, "sha512-5Y/RcxxofBXQSdy6IexzB8s2rpRI7xFiut1Zh6WO6hjNNqM+WKPPt+CTGKqnnnx/vhecgKrvpHnN228Zc0bakg=="],
"@moq/publish": ["@moq/publish@0.2.6", "", { "dependencies": { "@moq/hang": "^0.2.4", "@moq/lite": "^0.2.2", "@moq/signals": "^0.1.6", "@moq/ui-core": "^0.1.0" } }, "sha512-cAHt8ZRMKOZh/yd1CX8wS6N5XLCGxzsKB/hU7R9PtmlJ40U3hDKokMGSd+jYWstDOgppoMwr4qU//yjDyeOiNw=="],
"@moq/qmux": ["@moq/qmux@0.0.6", "", {}, "sha512-ISuGz05lUvf1hzHW3Aw3VnsGRJe1w9Qdog3LQ66KS+l+5mzQsPANvW8yOioEe1Z9dJO2G3sAHoGPnzwnsY9SIQ=="],
"@moq/signals": ["@moq/signals@0.1.6", "", { "peerDependencies": { "@types/react": "^19.1.8", "react": "^19.0.0", "solid-js": "^1.9.7" }, "optionalPeers": ["@types/react", "react", "solid-js"] }, "sha512-ic7ttiz6dHXOPoVAfhz4K6LGT2LWdDGTi1x2u8sYSGZ5nOKGWfqDkwYcGvCPlcVQetn3PaeXYSPFiMAC6RO3tQ=="],
"@moq/ui-core": ["@moq/ui-core@0.1.0", "", { "peerDependencies": { "@moq/signals": "^0.1.2" } }, "sha512-DJNBpUNQDyh7Tou324fbJ5/pT08UPghH3OxcVdLEo9IQeX//8NiEzJwcX7iuacR32nzgdiBThIbIpeFa60U3/g=="],
"@moq/watch": ["@moq/watch@0.2.10", "", { "dependencies": { "@moq/hang": "^0.2.4", "@moq/lite": "^0.2.2", "@moq/msf": "^0.1.0", "@moq/signals": "^0.1.6", "@moq/ui-core": "^0.1.0" } }, "sha512-uLVwdtx0XIvJ20c1dYJ5NIVLXBA/cbTNzvM1mujvYLVKGQnwPkrrllEFlNpWfwV9SN1Kb8BnDvA6LLtYTFAhkQ=="],
"@playwright/test": ["@playwright/test@1.56.1", "", { "dependencies": { "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" } }, "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg=="],
"@svta/cml-iso-bmff": ["@svta/cml-iso-bmff@1.0.1", "", { "peerDependencies": { "@svta/cml-utils": "1.4.0" } }, "sha512-MOhATJYQ6cVrIcoY3nj8p/vGYDpG3wjQIIhBPHNt9yjFijdwFdBNqdZbCXv3aFhRjdx5Saca5TkgNJusKhnI/w=="],
"@svta/cml-utils": ["@svta/cml-utils@1.4.0", "", {}, "sha512-vNtHtv/z+9I9ysxFwNrgwxic1oceVPr8TpcpV/NA1l8Gy4phynwtOppkCIBB+PmoyKDcqE4lO85g+lfsuSTBBA=="],
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
"@ungap/global-this": ["@ungap/global-this@0.4.4", "", {}, "sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA=="],
"async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="],
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"playwright": ["playwright@1.56.1", "", { "dependencies": { "playwright-core": "1.56.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw=="],
"playwright-core": ["playwright-core@1.56.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
}
}
@@ -0,0 +1,23 @@
{
"name": "nests-browser-interop",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Phase 4 (T16) browser-side cross-stack interop harness — headless Chromium running @moq/lite + @moq/hang against the same NativeMoqRelayHarness moq-relay subprocess that drives HangInteropTest. Lands behind -DnestsBrowserInterop=true.",
"scripts": {
"build": "bun build src/listen.ts src/publish.ts --outdir dist --target browser && cp src/listen.html src/publish.html src/pcm-tap-worklet.js dist/",
"serve": "bun run src/server.ts",
"playwright": "playwright test"
},
"dependencies": {
"@moq/hang": "0.2.4",
"@moq/lite": "0.2.2",
"@moq/publish": "0.2.6",
"@moq/watch": "0.2.10"
},
"devDependencies": {
"@playwright/test": "1.56.1",
"@types/bun": "latest",
"typescript": "^5.6.0"
}
}
@@ -0,0 +1,56 @@
import { defineConfig } from "@playwright/test";
// Phase 4 (T16) browser-interop Playwright config.
//
// One-off Chromium spawn per Kotlin test. We disable the default test
// projects + reporters (the runner is invoked headlessly from the
// PlaywrightDriver Kotlin shim with `--reporter list` for stdout
// streaming).
//
// Chromium flags:
// --enable-quic — required for WebTransport.
// --ignore-certificate-errors — accept the self-signed
// cert moq-relay generates
// with --tls-generate.
// --enable-features=AutoplayPolicy=NoUserGestureRequired
// — let AudioContext.resume()
// succeed without a user
// gesture (we're headless).
// --enable-blink-features=WebTransport
// — defensively re-enable in
// case the build disables
// the blink feature flag
// by default.
export default defineConfig({
testDir: "./tests",
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: process.env.PLAYWRIGHT_REPORTER ?? "list",
timeout: 120_000,
use: {
headless: true,
trace: "off",
video: "off",
screenshot: "off",
launchOptions: {
args: [
"--enable-quic",
"--ignore-certificate-errors",
"--enable-features=AutoplayPolicy=NoUserGestureRequired",
"--enable-blink-features=WebTransport",
// Disable network sandbox so WebTransport over loopback
// doesn't trip the network service sandbox in headless.
"--disable-features=IsolateOrigins,site-per-process",
],
},
},
projects: [
{
name: "chromium",
use: { browserName: "chromium" },
},
],
});
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>nests browser-interop listener</title>
<style>
body { font: 14px monospace; padding: 8px; }
body[data-state="error"] { background: #fdd; }
body[data-state="done"] { background: #dfd; }
</style>
</head>
<body data-state="init">
<h1>nests-browser-interop / listen</h1>
<div id="status">init</div>
<script type="module" src="./listen.js"></script>
</body>
</html>
@@ -0,0 +1,249 @@
// Phase 4 (T16) browser-listener harness. Connects to the
// `NativeMoqRelayHarness` moq-relay subprocess via WebTransport, subscribes
// to the `<broadcast>/audio/data` track produced by the Amethyst Kotlin
// speaker, decodes each Opus packet via WebCodecs AudioDecoder, and posts
// the resulting Float32 PCM samples back to the bun WS server (`ws://`),
// which appends them to a file on disk for the Kotlin test to read.
//
// Reads its parameters from `location.search`:
//
// relay — the relay's WebTransport URL,
// e.g. `https://127.0.0.1:43219/nests/<kind>:<host>:<room>?jwt=`.
// Pass the FULL connection target (path + query) — Amethyst's
// nests namespace is part of the relay path per `NestsConnect.kt`.
// broadcast — the publisher's moq-lite broadcast path
// (= `speakerPubkeyHex` per `MoqLiteNestsSpeaker.kt`).
// track — the audio track name. Defaults to `audio/data`.
// wsPort — the bun WS back-channel port; we POST PCM here.
// duration — broadcast capture window in seconds.
//
// Mirrors the data path of `kixelated/moq` `js/watch/src/audio/decoder.ts`
// (`#runLegacyDecoder`) with the `@moq/hang` `Container.Legacy.Format` consumer
// and the WebCodecs AudioDecoder warmup-skip semantics. Verbatim matching
// the watcher's per-frame behaviour is what catches a Chromium-side
// regression that wire-byte tests can't see.
import * as Moq from "@moq/lite";
import * as Container from "@moq/hang/container";
import { PRIORITY as CATALOG_PRIORITY } from "@moq/hang/catalog";
const params = new URLSearchParams(location.search);
const relayParam = params.get("relay");
const broadcastParam = params.get("broadcast");
const trackParam = params.get("track") ?? "audio/data";
const wsPort = Number(params.get("wsPort") ?? "0");
const durationSec = Number(params.get("duration") ?? "5");
const certSha256B64 = params.get("certSha256"); // Base64 SHA-256 of leaf DER cert.
function required(v: string | null, name: string): string {
if (!v) throw new Error(`listen.html: missing ?${name}=`);
return v;
}
const relayUrlString = required(relayParam, "relay");
const broadcastName = required(broadcastParam, "broadcast");
if (!wsPort) throw new Error("listen.html: missing ?wsPort=");
const status = (msg: string) => {
const el = document.getElementById("status");
if (el) el.textContent = msg;
console.log("[listen]", msg);
};
const fail = (msg: string) => {
status(`ERROR: ${msg}`);
document.body.dataset.state = "error";
throw new Error(msg);
};
async function main() {
// -- WS back-channel ------------------------------------------------
// The bun server appends every binary message we send to a PCM file
// on disk. We open it BEFORE the WebTransport so the very first frame
// (which decodes via WebCodecs after Container.Legacy strips the 2-byte
// timestamp) is captured even if it arrives before the page reaches
// its `done` state.
const ws = new WebSocket(`ws://127.0.0.1:${wsPort}/pcm`);
ws.binaryType = "arraybuffer";
await new Promise<void>((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true });
ws.addEventListener("error", () => reject(new Error("ws connect failed")), { once: true });
});
status("ws connected");
const sendPcm = (chunk: Float32Array) => {
// Float32 LE matches the format hang-listen writes; the Kotlin
// test reads it via `readFloat32Pcm`.
if (ws.readyState === WebSocket.OPEN) ws.send(chunk.buffer);
};
const sendDone = () => {
if (ws.readyState === WebSocket.OPEN) ws.send("done");
};
// -- Connect to the relay ------------------------------------------
// `relayParam` already includes the namespace path + ?jwt=… query
// (built by `buildRelayConnectTarget` in NestsConnect.kt). We pass it
// straight to `Connection.connect` which feeds it to `new WebTransport(url)`
// verbatim. Self-signed cert pinning is via Chromium's
// `--ignore-certificate-errors` flag — we do NOT compute a SHA-256 hash
// since the relay's auto-generated cert isn't deterministic.
const relayUrl = new URL(relayUrlString);
status(`connecting to ${relayUrl.toString()}`);
// If the test driver passed a leaf-cert SHA-256, pin it via
// `serverCertificateHashes`. Chromium's `--ignore-certificate-errors`
// does NOT bypass QUIC cert validation (crbug.com/1190655), so this
// is the supported path for self-signed test certs over WebTransport.
// The hash is base64 — convert to a Uint8Array. Fail loudly if the
// hash is malformed; falling back to no-pin would just produce a
// QUIC_TLS_CERTIFICATE_UNKNOWN error one round-trip later.
const webtransportOpts: WebTransportOptions = {};
if (certSha256B64) {
const raw = Uint8Array.from(atob(certSha256B64), (c) => c.charCodeAt(0));
webtransportOpts.serverCertificateHashes = [
{ algorithm: "sha-256", value: raw },
];
}
const conn = await Moq.Connection.connect(relayUrl, {
// Disable the WebSocket fallback — the harness relay only speaks QUIC.
websocket: { enabled: false },
webtransport: webtransportOpts,
});
status(`connected, alpn=${conn.version}`);
// Expose for Playwright to read post-hoc.
(window as any).__moqVersion = conn.version;
// -- Subscribe to the audio track ----------------------------------
const broadcastPath = Moq.Path.from(broadcastName);
const broadcast = conn.consume(broadcastPath);
const track = broadcast.subscribe(trackParam, CATALOG_PRIORITY.audio);
status(`subscribed broadcast=${broadcastName} track=${trackParam}`);
// The hang Container.Legacy.Consumer strips the Varint-encoded
// timestamp prefix (per `kixelated/moq/js/hang/src/container/legacy.ts`)
// and yields an Opus packet per `next()`. Mirrors the data path the
// @moq/watch decoder uses internally for `container.kind = "legacy"`
// catalogs (the kind Amethyst publishes via `MoqLiteHangCatalog.opus48k`).
const consumer = new Container.Legacy.Consumer(track, {
// Tight latency — the harness runs over loopback, no jitter.
// Pass a literal Time.Milli (number); the consumer accepts it directly.
latency: 100 as any,
});
// -- WebCodecs AudioDecoder ----------------------------------------
const sampleRate = 48_000;
// Channel count from `?channels=N` URL param; defaults to mono.
// The hang-tier I4 uses 2 (440 Hz L / 660 Hz R) — Chromium's
// WebCodecs AudioDecoder must be configured with the correct
// channel count up front; reconfiguring after frames arrive
// discards decoder state.
const numberOfChannels = Number(params.get("channels") ?? "1");
let warmed = 0;
// I14 instrumentation. `decoderOutputs` counts every successful
// `output()` callback (warmup frames included), `decoderErrors`
// counts every WebCodecs `error()` callback. A T8 regression that
// leaks `OpusHead` into a normal audio frame surfaces as either a
// non-zero error count (decoder rejects the bytes) or — if Chromium
// tolerates it — as the warmup window absorbing the stray frame
// and the FFT peak shifting. The error counter catches case 1
// deterministically; the FFT peak in I1 catches case 2.
let decoderOutputs = 0;
let decoderErrors = 0;
const decoder = new AudioDecoder({
output: (data: AudioData) => {
warmed++;
decoderOutputs++;
if (warmed <= 3) {
// Mirror @moq/watch's 3-frame WebCodecs warmup skip.
data.close();
return;
}
const channels = data.numberOfChannels;
const frames = data.numberOfFrames;
// Interleave channels into a single Float32 buffer (Float32 LE,
// matching hang-listen's output format). Mono → just one plane.
if (channels === 1) {
const buf = new Float32Array(frames);
data.copyTo(buf, { format: "f32-planar", planeIndex: 0 });
sendPcm(buf);
} else {
const planes: Float32Array[] = [];
for (let c = 0; c < channels; c++) {
const p = new Float32Array(frames);
data.copyTo(p, { format: "f32-planar", planeIndex: c });
planes.push(p);
}
const interleaved = new Float32Array(frames * channels);
for (let f = 0; f < frames; f++) {
for (let c = 0; c < channels; c++) {
interleaved[f * channels + c] = planes[c][f];
}
}
sendPcm(interleaved);
}
data.close();
},
error: (err) => {
decoderErrors++;
console.error("[listen] AudioDecoder", err);
},
});
decoder.configure({
codec: "opus",
sampleRate,
numberOfChannels,
// No description for Opus per @moq/watch decoder.ts comment:
// "Opus in CMAF uses raw packets; dOps is not a valid OGG header".
});
// -- Frame pump -----------------------------------------------------
const deadline = performance.now() + durationSec * 1000;
let framesDecoded = 0;
document.body.dataset.state = "playing";
while (performance.now() < deadline) {
const next = await Promise.race([
consumer.next(),
new Promise<undefined>((r) =>
setTimeout(() => r(undefined), Math.max(50, deadline - performance.now())),
),
]);
if (!next) break;
const { frame } = next;
if (!frame) continue;
framesDecoded++;
if (decoder.state === "closed") break;
decoder.decode(
new EncodedAudioChunk({
type: frame.keyframe ? "key" : "delta",
data: frame.data,
timestamp: frame.timestamp,
}),
);
}
status(`done, frames=${framesDecoded}`);
(window as any).__framesDecoded = framesDecoded;
(window as any).__decoderOutputs = decoderOutputs;
(window as any).__decoderErrors = decoderErrors;
// Flush any pending decoder output, then signal the WS server we're done.
try {
await decoder.flush();
} catch (e) {
console.warn("[listen] flush:", e);
}
if (decoder.state !== "closed") decoder.close();
consumer.close();
sendDone();
document.body.dataset.state = "done";
status(`done. frames=${framesDecoded}`);
}
main().catch((e) => {
console.error("[listen] fatal:", e);
fail(String(e?.stack ?? e));
});
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>nests browser-interop publisher</title>
<style>
body { font: 14px monospace; padding: 8px; }
body[data-state="error"] { background: #fdd; }
body[data-state="done"] { background: #dfd; }
body[data-state="publishing"] { background: #ffe; }
</style>
</head>
<body data-state="init">
<h1>nests-browser-interop / publish</h1>
<div id="status">init</div>
<script type="module" src="./publish.js"></script>
</body>
</html>
@@ -0,0 +1,292 @@
// Phase 4 (T16) browser-publisher harness.
//
// Inverse of `listen.ts`: drives a sine `OscillatorNode` through the
// WebCodecs `AudioEncoder` (Opus mode) and pushes each encoded packet
// onto a moq-lite track via `Container.Legacy.Producer`, prefixed with
// a Varint-encoded timestamp the watcher (`Container.Legacy.Format`)
// will strip on decode. Also publishes a `catalog.json` track that
// matches `MoqLiteHangCatalog.opus48k` byte-for-byte so the Amethyst
// listener (and `hang-listen` for cross-validation) can discover the
// audio rendition.
//
// Optional `?reconnectAfterMs=N` URL param: cycles the moq session
// at N ms into the broadcast — drops the current `Connection`,
// builds a fresh one, re-publishes the same broadcast suffix. The
// relay sees `Announce::Ended → Active` on the same path. Used by
// the Browser I7 scenario (Chromium publisher reconnect → Kotlin
// listener recovers via `connectReconnectingNestsListener`'s
// re-issuance pump).
import * as Moq from "@moq/lite";
import * as Container from "@moq/hang/container";
const params = new URLSearchParams(location.search);
const relayParam = params.get("relay");
const broadcastParam = params.get("broadcast");
const trackParam = params.get("track") ?? "audio/data";
const catalogTrack = params.get("catalogTrack") ?? "catalog.json";
const freqHz = Number(params.get("freqHz") ?? "440");
const channels = Number(params.get("channels") ?? "1");
const durationSec = Number(params.get("duration") ?? "5");
const wsPort = Number(params.get("wsPort") ?? "0");
const reconnectAfterMs = Number(params.get("reconnectAfterMs") ?? "0");
const certSha256B64 = params.get("certSha256");
function required(v: string | null, name: string): string {
if (!v) throw new Error(`publish.html: missing ?${name}=`);
return v;
}
const relayUrlString = required(relayParam, "relay");
const broadcastName = required(broadcastParam, "broadcast");
const status = (msg: string) => {
const el = document.getElementById("status");
if (el) el.textContent = msg;
console.log("[publish]", msg);
};
const catalogJson = JSON.stringify({
audio: {
renditions: {
[trackParam]: {
codec: "opus",
container: { kind: "legacy" },
sampleRate: 48000,
numberOfChannels: channels,
jitter: 20,
},
},
},
});
const catalogBytes = new TextEncoder().encode(catalogJson);
/**
* Open one moq-lite session + broadcast. Returns the bits the encoder
* pump needs (Connection + audio Track) plus a `close` to tear it down
* cleanly when the reconnect cycle fires.
*/
type Session = {
audioMoqTrack: Moq.Track;
closeAll: () => void;
};
async function openSession(): Promise<Session> {
const relayUrl = new URL(relayUrlString);
status(`connecting to ${relayUrl.toString()}`);
// serverCertificateHashes pinning per the same comment in listen.ts
// — Chromium's --ignore-certificate-errors does NOT bypass QUIC
// cert validation. The test driver passes the SHA-256 of the
// relay's leaf DER cert via ?certSha256=base64.
const webtransportOpts: WebTransportOptions = {};
if (certSha256B64) {
const raw = Uint8Array.from(atob(certSha256B64), (c) => c.charCodeAt(0));
webtransportOpts.serverCertificateHashes = [
{ algorithm: "sha-256", value: raw },
];
}
const conn = await Moq.Connection.connect(relayUrl, {
websocket: { enabled: false },
webtransport: webtransportOpts,
});
(window as any).__moqVersion = conn.version;
status(`connected, alpn=${conn.version}`);
const broadcast = new Moq.Broadcast();
conn.publish(Moq.Path.from(broadcastName), broadcast);
status(`announced ${broadcastName}`);
let audioTrackResolved: Moq.Track | undefined;
const audioTrackResolver = new Promise<Moq.Track>((resolve) => {
const probe = setInterval(() => {
if (audioTrackResolved) {
clearInterval(probe);
resolve(audioTrackResolved);
}
}, 20);
});
// Serve catalog + audio tracks as they're requested by the relay.
const requestPump = (async () => {
for (;;) {
const req = await broadcast.requested();
if (!req) return;
if (req.track.name === catalogTrack) {
const group = req.track.appendGroup();
group.writeFrame(catalogBytes);
group.close();
} else if (req.track.name === trackParam) {
audioTrackResolved = req.track;
}
}
})().catch((e) => console.error("[publish] requests:", e));
const audioMoqTrack = await audioTrackResolver;
const closeAll = () => {
try {
broadcast.close();
} catch (_) {
// ignore
}
try {
conn.close();
} catch (_) {
// ignore
}
// requestPump exits on its own once broadcast.requested()
// returns null after broadcast.close().
void requestPump;
};
return { audioMoqTrack, closeAll };
}
async function main() {
let ws: WebSocket | undefined;
if (wsPort) {
ws = new WebSocket(`ws://127.0.0.1:${wsPort}/pcm`);
await new Promise<void>((resolve) => {
ws!.addEventListener("open", () => resolve(), { once: true });
ws!.addEventListener("error", () => resolve(), { once: true });
});
}
const sendDone = () => {
if (ws?.readyState === WebSocket.OPEN) ws.send("done");
};
// Open the FIRST session.
let session = await openSession();
// -- Audio source pump (single source across reconnect cycles) -----
// Sine osc → MediaStreamAudioDestinationNode → MediaStreamTrack →
// MediaStreamTrackProcessor → AudioData. The osc + processor
// SURVIVE a reconnect — only the moq-lite Producer (which writes
// to the per-cycle session's track) is rebuilt.
const ctx = new AudioContext({ sampleRate: 48_000, latencyHint: "interactive" });
await ctx.resume();
const osc = ctx.createOscillator();
osc.frequency.value = freqHz;
osc.type = "sine";
const dst = ctx.createMediaStreamDestination();
// The destination's channelCount defaults to 2 (stereo); pin it
// to whatever the test configured so the AudioEncoder's
// `numberOfChannels` matches what AudioData carries. Mismatch
// surfaces as `EncodingError: Input audio buffer is incompatible
// with codec parameters` and immediately closes the codec.
dst.channelCount = channels;
dst.channelCountMode = "explicit";
dst.channelInterpretation = "speakers";
osc.connect(dst);
osc.start();
const audioTrack = dst.stream.getAudioTracks()[0];
// @ts-expect-error MediaStreamTrackProcessor is Chrome-only
const processor = new MediaStreamTrackProcessor({ track: audioTrack });
const reader = (processor.readable as ReadableStream<AudioData>).getReader();
// Producer is rebuilt on each reconnect cycle.
let producer = new Container.Legacy.Producer(session.audioMoqTrack);
let producerStarted = false;
let cycleId = 0;
const encoder = new AudioEncoder({
output: (chunk, _meta) => {
const data = new Uint8Array(chunk.byteLength);
chunk.copyTo(data);
// Force a new group at each cycle's start so the first
// post-reconnect frame is a keyframe — Container.Legacy
// requires it. `producerStarted` tracks per-producer.
const isKey = !producerStarted;
producerStarted = true;
try {
producer.encode(data, chunk.timestamp as any, isKey);
} catch (e) {
// The producer can throw if the underlying session
// closed mid-encode (we're between cycles). Swallow
// — the next encoded chunk lands on the new producer.
console.warn("[publish] encoder.output: producer.encode threw", e);
}
},
error: (e) => console.error("[publish] AudioEncoder", e),
});
encoder.configure({
codec: "opus",
sampleRate: 48_000,
numberOfChannels: channels,
bitrate: 32_000,
});
document.body.dataset.state = "publishing";
status("publishing");
// -- Reconnect scheduler (optional) --------------------------------
// If reconnectAfterMs > 0, fire ONCE at that mark to cycle the
// session. We schedule one-shot — the test only needs to assert
// the listener recovers across a single Announce::Ended → Active.
let reconnectFired = false;
const reconnectScheduler = (async () => {
if (reconnectAfterMs <= 0) return;
await new Promise((r) => setTimeout(r, reconnectAfterMs));
if (reconnectFired) return;
reconnectFired = true;
cycleId += 1;
status(`reconnect cycle ${cycleId}: closing session`);
const oldSession = session;
// Close the current session first so the relay sees
// Announce::Ended cleanly. Then open a fresh one.
oldSession.closeAll();
try {
session = await openSession();
} catch (e) {
console.error("[publish] reconnect openSession failed", e);
return;
}
producer = new Container.Legacy.Producer(session.audioMoqTrack);
producerStarted = false;
status(`reconnect cycle ${cycleId}: published fresh session`);
(window as any).__publishCycle = cycleId;
})();
// -- Encoder feed loop --------------------------------------------
const deadline = performance.now() + durationSec * 1000;
let framesIn = 0;
while (performance.now() < deadline) {
const { done, value } = await reader.read();
if (done || !value) break;
try {
encoder.encode(value);
framesIn++;
} finally {
value.close();
}
}
status(`flushing, framesIn=${framesIn}, cycles=${cycleId}`);
try {
await encoder.flush();
} catch (e) {
console.warn("[publish] flush:", e);
}
encoder.close();
osc.stop();
audioTrack.stop();
try {
producer.close();
} catch (_) {
// ignore
}
session.closeAll();
sendDone();
void reconnectScheduler;
document.body.dataset.state = "done";
(window as any).__framesIn = framesIn;
(window as any).__publishCycle = cycleId;
status(`done. framesIn=${framesIn}, cycles=${cycleId}`);
}
main().catch((e) => {
console.error("[publish] fatal:", e);
document.body.dataset.state = "error";
status(`ERROR: ${e?.stack ?? e}`);
});
@@ -0,0 +1,136 @@
// Phase 4 (T16) bun static + WebSocket back-channel server.
//
// One process per Kotlin test, bound to a random port; the PlaywrightDriver
// passes the port back to the harness pages as `?wsPort=…`. PCM frames sent
// over the WS as binary messages get appended to `--out-pcm`. A textual
// `done` message flips the server's `done` flag so the test driver can
// poll it via the `/state` endpoint and tear down cleanly.
//
// Argv:
// --port <int> listen port; 0 picks a random one (logged on stdout)
// --root <dir> directory to serve static files from (= dist/)
// --out-pcm <path> file to append received PCM frames to
//
// Stdout (machine-readable, single line then blank line):
// port=<int>
// ready
//
// Errors go to stderr; non-zero exit code on fatal startup failure.
import { type ServerWebSocket } from "bun";
import { mkdirSync, openSync, closeSync, writeSync, existsSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
interface Args {
port: number;
root: string;
outPcm: string;
}
function parseArgs(): Args {
const args = process.argv.slice(2);
let port = 0;
let root = "";
let outPcm = "";
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--port") port = Number(args[++i]);
else if (a === "--root") root = args[++i];
else if (a === "--out-pcm") outPcm = args[++i];
else throw new Error(`unknown arg: ${a}`);
}
if (!root) throw new Error("--root is required");
if (!outPcm) throw new Error("--out-pcm is required");
return { port, root, outPcm: resolve(outPcm) };
}
const args = parseArgs();
mkdirSync(dirname(args.outPcm), { recursive: true });
// Truncate any prior file: the harness page reopens each run.
const fd = openSync(args.outPcm, "w");
let done = false;
const wsClients = new Set<ServerWebSocket<unknown>>();
const contentType = (path: string): string => {
if (path.endsWith(".html")) return "text/html; charset=utf-8";
if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
if (path.endsWith(".mjs")) return "application/javascript; charset=utf-8";
if (path.endsWith(".json")) return "application/json; charset=utf-8";
if (path.endsWith(".css")) return "text/css; charset=utf-8";
if (path.endsWith(".wasm")) return "application/wasm";
return "application/octet-stream";
};
const server = Bun.serve({
port: args.port,
hostname: "127.0.0.1",
fetch(req, srv) {
const url = new URL(req.url);
if (url.pathname === "/pcm") {
// WebSocket upgrade for the PCM back-channel.
if (srv.upgrade(req)) return;
return new Response("expected websocket upgrade", { status: 400 });
}
if (url.pathname === "/state") {
return new Response(JSON.stringify({ done }), {
headers: { "content-type": "application/json" },
});
}
// Static file serve out of root/.
let path = url.pathname === "/" ? "/listen.html" : url.pathname;
const filePath = join(args.root, path.replace(/^\/+/, ""));
// Reject path traversal attempts.
if (!filePath.startsWith(resolve(args.root))) {
return new Response("forbidden", { status: 403 });
}
if (!existsSync(filePath)) {
return new Response("not found: " + path, { status: 404 });
}
const file = Bun.file(filePath);
return new Response(file, {
headers: {
"content-type": contentType(filePath),
// No-cache so a `bun build` rebuild between Playwright runs
// is picked up immediately.
"cache-control": "no-store",
},
});
},
websocket: {
message(ws, message) {
wsClients.add(ws);
if (typeof message === "string") {
if (message === "done") {
done = true;
console.log("[server] received `done`");
}
return;
}
// Binary PCM frame — append raw bytes to the out file.
const buf = message instanceof ArrayBuffer ? new Uint8Array(message) : new Uint8Array(message.buffer, message.byteOffset, message.byteLength);
writeSync(fd, buf);
},
open(ws) {
wsClients.add(ws);
},
close(ws) {
wsClients.delete(ws);
},
},
});
// Machine-readable handshake for PlaywrightDriver.
process.stdout.write(`port=${server.port}\n`);
process.stdout.write("ready\n");
// Clean up the fd on Ctrl-C / parent kill so we don't leak it.
const shutdown = () => {
try {
closeSync(fd);
} catch { /* ignore */ }
server.stop(true);
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
@@ -0,0 +1,80 @@
import { test, expect } from "@playwright/test";
// Driver test that the Kotlin `PlaywrightDriver` invokes once per
// scenario via `npx playwright test`. Every parameter is passed via
// environment variables (NPM_BROWSER_HARNESS_*) so the same single test
// can serve every BrowserInteropTest scenario without us writing one
// playwright spec per scenario.
//
// Required env:
// NESTS_HARNESS_URL — http://127.0.0.1:<bunPort>/listen.html (or publish.html)
// NESTS_TIMEOUT_MS — overall page timeout (default 60_000)
//
// The test:
// 1. opens the URL,
// 2. waits for `body[data-state="done"]` (or "error", which fails),
// 3. dumps the status text + console logs back as the test failure message
// so `--reporter list` surfaces them in stdout the Kotlin caller reads.
const harnessUrl = process.env.NESTS_HARNESS_URL;
const timeoutMs = Number(process.env.NESTS_TIMEOUT_MS ?? "60000");
test.describe("nests-browser-interop", () => {
test.skip(!harnessUrl, "NESTS_HARNESS_URL not set");
test("harness runs to completion", async ({ page }) => {
const consoleLines: string[] = [];
page.on("console", (msg) => {
consoleLines.push(`[${msg.type()}] ${msg.text()}`);
});
page.on("pageerror", (err) => {
consoleLines.push(`[pageerror] ${err.message}\n${err.stack ?? ""}`);
});
await page.goto(harnessUrl!, { waitUntil: "domcontentloaded" });
// Wait for the harness page to flip to either "done" (success)
// or "error" (page-side fatal). Don't rely on `waitForFunction`'s
// own polling cadence because Chromium on a busy CI runner can
// miss a transient status; spin in 100 ms ticks ourselves.
const finalState = await page.waitForFunction(
() => {
const s = (document.body as HTMLBodyElement).dataset.state;
return s === "done" || s === "error" ? s : null;
},
null,
{ timeout: timeoutMs, polling: 100 },
);
const state = await finalState.evaluate((v) => v as string);
const status = await page.locator("#status").textContent();
const meta = await page.evaluate(() => ({
framesDecoded: (window as any).__framesDecoded,
moqVersion: (window as any).__moqVersion,
// I14 instrumentation: total WebCodecs `output()` callbacks
// (warmup frames included) and total `error()` callbacks.
// A T8 regression that leaks `OpusHead` into a normal audio
// frame trips `decoderErrors` deterministically; the FFT
// peak in I1 catches the silent-tolerance variant.
decoderOutputs: (window as any).__decoderOutputs,
decoderErrors: (window as any).__decoderErrors,
// Browser I7 / publish-baseline instrumentation: total
// encoded frames the publisher pumped, and the count of
// moq-lite session reconnect cycles the page completed.
framesIn: (window as any).__framesIn,
cycles: (window as any).__publishCycle,
}));
// Always print a summary line — Kotlin parses this for follow-up
// assertions (e.g. moq-lite-03 ALPN echo for I15).
console.log(
JSON.stringify({
state,
status,
meta,
logs: consoleLines.slice(-50),
}),
);
if (state === "error") {
throw new Error(`harness reached error state: ${status}\n\nlogs:\n${consoleLines.join("\n")}`);
}
expect(state).toBe("done");
});
});
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
"types": ["@types/bun"],
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*.ts"]
}