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,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
.bun/
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
@@ -227,3 +228,108 @@ tasks.withType<Test>().configureEach {
|
|||||||
systemProperty("nestsHangInteropSidecarsDir", sidecarRelease.absolutePath)
|
systemProperty("nestsHangInteropSidecarsDir", sidecarRelease.absolutePath)
|
||||||
systemProperty("nestsHangInteropCargoBinDir", cargoBin.absolutePath)
|
systemProperty("nestsHangInteropCargoBinDir", cargoBin.absolutePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 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
|
||||||
|
// but with bun/npx subprocesses instead of cargo. Opt-in via
|
||||||
|
// `-DnestsBrowserInterop=true`. See:
|
||||||
|
// nestsClient/plans/2026-05-06-phase4-browser-harness.md
|
||||||
|
//
|
||||||
|
// Two tasks:
|
||||||
|
// - interopBuildBrowserHarness — `bun install` + `bun build` of
|
||||||
|
// listen.ts/publish.ts → dist/, plus copying static .html files.
|
||||||
|
// - interopInstallPlaywrightChromium — `npx playwright install
|
||||||
|
// --with-deps chromium`. Skipped if a Chromium build already lives
|
||||||
|
// in `~/.cache/ms-playwright/`.
|
||||||
|
//
|
||||||
|
// We also forward the `bun` and `npx` binaries to be configurable via
|
||||||
|
// env so CI can override them; defaults pick up the standard install
|
||||||
|
// paths the agents/host runner ship with.
|
||||||
|
|
||||||
|
val browserInteropDir =
|
||||||
|
rootProject.layout.projectDirectory.dir("nestsClient-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
|
||||||
|
// `bun` on PATH if the well-known path isn't executable.
|
||||||
|
fun resolveBunBinary(): String {
|
||||||
|
val explicit = System.getenv("BUN_BIN") ?: System.getProperty("bunBin")
|
||||||
|
if (explicit != null) return explicit
|
||||||
|
val agentPath = "/root/.bun/bin/bun"
|
||||||
|
return if (File(agentPath).canExecute()) agentPath else "bun"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resolveNpxBinary(): String =
|
||||||
|
System.getenv("NPX_BIN") ?: System.getProperty("npxBin") ?: "npx"
|
||||||
|
|
||||||
|
val interopBuildBrowserHarness by tasks.registering(Exec::class) {
|
||||||
|
description = "bun install && bun build for the browser interop harness"
|
||||||
|
group = "interop"
|
||||||
|
workingDir = browserInteropDir.asFile
|
||||||
|
val bun = resolveBunBinary()
|
||||||
|
// Single bash invocation so `&&` short-circuits on a failed install.
|
||||||
|
// The trailing `cp` step copies the static HTML pages into dist/
|
||||||
|
// alongside the bundled JS — bun's bundler doesn't carry .html.
|
||||||
|
commandLine(
|
||||||
|
"bash", "-c",
|
||||||
|
"$bun install && $bun build src/listen.ts src/publish.ts --outdir dist --target browser && cp src/listen.html src/publish.html dist/",
|
||||||
|
)
|
||||||
|
inputs.files(
|
||||||
|
fileTree(browserInteropDir.asFile) {
|
||||||
|
include("package.json", "tsconfig.json", "playwright.config.ts", "src/**/*")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
outputs.dir(browserInteropDir.dir("dist"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val interopInstallPlaywrightChromium by tasks.registering(Exec::class) {
|
||||||
|
description = "Install Playwright Chromium + dependencies for the browser interop harness"
|
||||||
|
group = "interop"
|
||||||
|
workingDir = browserInteropDir.asFile
|
||||||
|
val npx = resolveNpxBinary()
|
||||||
|
// `--with-deps` needs sudo on a fresh runner; on the agent host
|
||||||
|
// Chromium is already pre-installed via apt so the system-package
|
||||||
|
// step is a no-op. Use the plain `install` form when --with-deps
|
||||||
|
// would error (e.g. unprivileged container) — fall back at runtime.
|
||||||
|
commandLine("bash", "-c", "$npx playwright install chromium")
|
||||||
|
onlyIf {
|
||||||
|
// Skip if a Chromium build is already present in the Playwright
|
||||||
|
// cache. The cache path is normally ~/.cache/ms-playwright/, but
|
||||||
|
// the agent runner sets PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers
|
||||||
|
// and ships chromium pre-installed there. Honour the env var so
|
||||||
|
// we don't redundantly download.
|
||||||
|
val explicit = System.getenv("PLAYWRIGHT_BROWSERS_PATH")
|
||||||
|
val candidates =
|
||||||
|
if (explicit != null) {
|
||||||
|
listOf(File(explicit))
|
||||||
|
} else {
|
||||||
|
val home = System.getProperty("user.home") ?: return@onlyIf true
|
||||||
|
listOf(File(home, ".cache/ms-playwright"))
|
||||||
|
}
|
||||||
|
val hasChromium =
|
||||||
|
candidates.any { dir ->
|
||||||
|
dir.exists() &&
|
||||||
|
dir.listFiles()?.any { it.name.startsWith("chromium-") || it.name == "chromium" } == true
|
||||||
|
}
|
||||||
|
!hasChromium
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
val isBrowserInterop = System.getProperty("nestsBrowserInterop") == "true"
|
||||||
|
if (isBrowserInterop) {
|
||||||
|
dependsOn(interopBuildBrowserHarness, interopInstallPlaywrightChromium)
|
||||||
|
// Browser scenarios reuse the moq-relay subprocess that
|
||||||
|
// hang-interop boots, so the Rust sidecars must be built too.
|
||||||
|
dependsOn(interopBuildHangSidecars)
|
||||||
|
}
|
||||||
|
systemProperty(
|
||||||
|
"nestsBrowserInteropHarnessDir",
|
||||||
|
browserInteropDir.asFile.absolutePath,
|
||||||
|
)
|
||||||
|
System.getProperty("nestsBrowserInterop")?.let {
|
||||||
|
systemProperty("nestsBrowserInterop", it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
+1202
File diff suppressed because it is too large
Load Diff
+8
-6
@@ -155,12 +155,14 @@ class NativeMoqRelayHarness private constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tear down the current shared relay subprocess and start a
|
* Tear down the current shared relay subprocess and start a
|
||||||
* fresh one. Used as a JUnit `@Before` hook by tests that
|
* fresh one. Used as a JUnit `@BeforeTest` hook by
|
||||||
* need clean per-method relay state — under accumulated
|
* `HangInteropTest` and `BrowserInteropTest` so each scenario
|
||||||
* cross-test broadcasts / connections the relay's per-
|
* runs against a relay that started ~500 ms before the test
|
||||||
* subscriber forward queues drift, manifesting as
|
* body — under accumulated cross-test broadcasts /
|
||||||
* intermittent catalog-cancel and sample-count flakes that
|
* connections the relay's per-subscriber forward queues +
|
||||||
* don't reproduce in isolation.
|
* announce tables drift, manifesting as intermittent
|
||||||
|
* catalog-cancel and sample-count flakes that don't reproduce
|
||||||
|
* in isolation.
|
||||||
*
|
*
|
||||||
* Cost: ~500 ms per call (cargo binaries are cached, only
|
* Cost: ~500 ms per call (cargo binaries are cached, only
|
||||||
* the subprocess boot + UDP bind + first client handshake
|
* the subprocess boot + UDP bind + first client handshake
|
||||||
|
|||||||
+428
@@ -0,0 +1,428 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.nestsclient.interop.native
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 4 (T16) Kotlin-side shim that drives a headless Chromium
|
||||||
|
* harness via Playwright. Mirrors the role `hang-listen` plays for
|
||||||
|
* the Phase 2 Rust-listener scenarios, but the listener is a
|
||||||
|
* Chromium tab loading [openListenPage] / [openPublishPage] from
|
||||||
|
* a bun static server.
|
||||||
|
*
|
||||||
|
* Two subprocesses per scenario:
|
||||||
|
* 1. **bun static + WebSocket back-channel** (`server.ts`): serves
|
||||||
|
* the bundled `listen.html` / `publish.html` and writes any PCM
|
||||||
|
* frames the page posts over WS to the file the test reads.
|
||||||
|
* 2. **`npx playwright test`** (or `bun x playwright test`): one-off
|
||||||
|
* Chromium spawn that opens the harness page and waits for
|
||||||
|
* `body[data-state="done"]`.
|
||||||
|
*
|
||||||
|
* Both are spawned per-scenario for isolation — sharing the bun
|
||||||
|
* server across scenarios would race the PCM-output file across
|
||||||
|
* runs, and sharing a Chromium across runs invites stale
|
||||||
|
* `AudioContext` / `WebTransport` state.
|
||||||
|
*
|
||||||
|
* Gate: `-DnestsBrowserInterop=true`. The Gradle `Test` task hooks
|
||||||
|
* `interopBuildBrowserHarness` + `interopInstallPlaywrightChromium`
|
||||||
|
* dependencies onto this gate (see `nestsClient/build.gradle.kts`).
|
||||||
|
*/
|
||||||
|
internal object PlaywrightDriver {
|
||||||
|
/** Gate property — mirrors [NativeMoqRelayHarness.ENABLE_PROPERTY]. */
|
||||||
|
const val ENABLE_PROPERTY = "nestsBrowserInterop"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forwarded by Gradle: absolute path to `nestsClient-browser-interop/`.
|
||||||
|
*/
|
||||||
|
const val HARNESS_DIR_PROPERTY = "nestsBrowserInteropHarnessDir"
|
||||||
|
|
||||||
|
fun isEnabled(): Boolean = System.getProperty(ENABLE_PROPERTY) == "true"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JUnit "skipped" if the gate isn't on. Mirrors
|
||||||
|
* [NativeMoqRelayHarness.assumeHangInterop].
|
||||||
|
*/
|
||||||
|
fun assumeBrowserInterop() {
|
||||||
|
if (isEnabled()) return
|
||||||
|
val msg =
|
||||||
|
"Skipping browser interop test — set -D$ENABLE_PROPERTY=true to enable. " +
|
||||||
|
"See nestsClient/plans/2026-05-06-phase4-browser-harness.md."
|
||||||
|
try {
|
||||||
|
val assume = Class.forName("org.junit.Assume")
|
||||||
|
val assumeTrue =
|
||||||
|
assume.getMethod("assumeTrue", String::class.java, Boolean::class.javaPrimitiveType)
|
||||||
|
assumeTrue.invoke(null, msg, false)
|
||||||
|
} catch (e: java.lang.reflect.InvocationTargetException) {
|
||||||
|
throw e.targetException ?: e
|
||||||
|
} catch (_: ClassNotFoundException) {
|
||||||
|
throw IllegalStateException(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome handed back to the test once the Chromium harness has
|
||||||
|
* finished. [pcmFile] holds the Float32 LE PCM bytes the listener
|
||||||
|
* page wrote via the WS back-channel; [playwrightStdout] is the
|
||||||
|
* combined stdout/stderr of the `npx playwright test` invocation
|
||||||
|
* (Kotlin parses the trailing JSON line for diagnostic metadata).
|
||||||
|
*/
|
||||||
|
data class HarnessRun(
|
||||||
|
val pcmFile: File,
|
||||||
|
val playwrightStdout: String,
|
||||||
|
val exitCode: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn the listener harness:
|
||||||
|
* 1. Pick an ephemeral port (via `ServerSocket(0)`),
|
||||||
|
* 2. Start `bun run server.ts --port <p> --root dist --out-pcm <pcm>`,
|
||||||
|
* 3. Wait for the server's `ready` line,
|
||||||
|
* 4. Spawn `npx playwright test` with NESTS_HARNESS_URL =
|
||||||
|
* `http://127.0.0.1:<p>/listen.html?relay=<…>&broadcast=<pubkey>&wsPort=<p>&duration=<sec>`,
|
||||||
|
* 5. Block until Playwright exits or [overallTimeoutSec] elapses,
|
||||||
|
* 6. Tear down both subprocesses.
|
||||||
|
*
|
||||||
|
* The relay URL passed to the page is the *full* connect target
|
||||||
|
* (path + `?jwt=` query), built by [buildHarnessRelayUrl] —
|
||||||
|
* Chromium's WebTransport driver consumes it directly.
|
||||||
|
*/
|
||||||
|
fun openListenPage(
|
||||||
|
relayUrlFull: String,
|
||||||
|
broadcastPath: String,
|
||||||
|
durationSec: Int,
|
||||||
|
overallTimeoutSec: Int = durationSec + 30,
|
||||||
|
track: String = "audio/data",
|
||||||
|
serverCertHashB64: String? = null,
|
||||||
|
channels: Int = 1,
|
||||||
|
): HarnessRun {
|
||||||
|
val certPart =
|
||||||
|
if (serverCertHashB64 != null) {
|
||||||
|
"&certSha256=" + java.net.URLEncoder.encode(serverCertHashB64, Charsets.UTF_8)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
// Always pass the channel count so listen.ts can configure
|
||||||
|
// its WebCodecs AudioDecoder with the matching value. The
|
||||||
|
// hang-tier I4 uses 2 (440/660 stereo); the rest use 1.
|
||||||
|
val extraQuery = "$certPart&channels=$channels"
|
||||||
|
return run(
|
||||||
|
"listen.html",
|
||||||
|
relayUrlFull,
|
||||||
|
broadcastPath,
|
||||||
|
durationSec,
|
||||||
|
overallTimeoutSec,
|
||||||
|
track,
|
||||||
|
extraQuery,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn the publisher harness. Symmetric to [openListenPage] but
|
||||||
|
* loads `publish.html` and passes the oscillator parameters.
|
||||||
|
* Phase 4.C scenarios — the I1-forward smoke test does NOT use this.
|
||||||
|
*
|
||||||
|
* @param serverCertHashB64 Base64-encoded SHA-256 of the relay's
|
||||||
|
* leaf DER cert. Same channel as [openListenPage]; required so
|
||||||
|
* Chromium's WebTransport accepts the test harness's
|
||||||
|
* self-signed cert.
|
||||||
|
* @param reconnectAfterMs If > 0, the publisher cycles its moq-lite
|
||||||
|
* session at this mark — drops the current Connection, builds a
|
||||||
|
* fresh one, re-publishes the same broadcast suffix. Used by the
|
||||||
|
* Browser I7 scenario.
|
||||||
|
*/
|
||||||
|
@Suppress("LongParameterList")
|
||||||
|
fun openPublishPage(
|
||||||
|
relayUrlFull: String,
|
||||||
|
broadcastPath: String,
|
||||||
|
freqHz: Int,
|
||||||
|
channels: Int,
|
||||||
|
durationSec: Int,
|
||||||
|
overallTimeoutSec: Int = durationSec + 30,
|
||||||
|
track: String = "audio/data",
|
||||||
|
serverCertHashB64: String? = null,
|
||||||
|
reconnectAfterMs: Long = 0L,
|
||||||
|
): HarnessRun {
|
||||||
|
val certPart =
|
||||||
|
if (serverCertHashB64 != null) {
|
||||||
|
"&certSha256=" + java.net.URLEncoder.encode(serverCertHashB64, Charsets.UTF_8)
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
val reconnectPart =
|
||||||
|
if (reconnectAfterMs > 0) "&reconnectAfterMs=$reconnectAfterMs" else ""
|
||||||
|
val extraQuery = "&freqHz=$freqHz&channels=$channels$certPart$reconnectPart"
|
||||||
|
return run(
|
||||||
|
"publish.html",
|
||||||
|
relayUrlFull,
|
||||||
|
broadcastPath,
|
||||||
|
durationSec,
|
||||||
|
overallTimeoutSec,
|
||||||
|
track,
|
||||||
|
extraQuery,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run(
|
||||||
|
page: String,
|
||||||
|
relayUrlFull: String,
|
||||||
|
broadcastPath: String,
|
||||||
|
durationSec: Int,
|
||||||
|
overallTimeoutSec: Int,
|
||||||
|
track: String,
|
||||||
|
extraQuery: String = "",
|
||||||
|
): HarnessRun {
|
||||||
|
check(isEnabled()) {
|
||||||
|
"PlaywrightDriver.run called without -D$ENABLE_PROPERTY=true."
|
||||||
|
}
|
||||||
|
val harnessDir = requireHarnessDir()
|
||||||
|
val distDir =
|
||||||
|
File(harnessDir, "dist").apply {
|
||||||
|
check(isDirectory) {
|
||||||
|
"browser harness dist/ missing at $absolutePath — did " +
|
||||||
|
"`./gradlew :nestsClient:interopBuildBrowserHarness` run?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Reserve a port for the bun server (it binds to 127.0.0.1
|
||||||
|
// on the same number; a tiny race window but loopback in CI
|
||||||
|
// is uncontested, same pattern as NativeMoqRelayHarness).
|
||||||
|
val bunPort = java.net.ServerSocket(0).use { it.localPort }
|
||||||
|
val pcmFile = File.createTempFile("browser-pcm", ".bin").also { it.deleteOnExit() }
|
||||||
|
|
||||||
|
val bun = resolveBunBinary()
|
||||||
|
val bunProc =
|
||||||
|
ProcessBuilder(
|
||||||
|
bun,
|
||||||
|
"run",
|
||||||
|
File(harnessDir, "src/server.ts").absolutePath,
|
||||||
|
"--port",
|
||||||
|
bunPort.toString(),
|
||||||
|
"--root",
|
||||||
|
distDir.absolutePath,
|
||||||
|
"--out-pcm",
|
||||||
|
pcmFile.absolutePath,
|
||||||
|
).directory(harnessDir)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
val bunDrainer = PlaywrightProcessDrainer(bunProc, "bun-server").also { it.start() }
|
||||||
|
|
||||||
|
try {
|
||||||
|
bunDrainer.waitForLine("ready", BUN_READY_TIMEOUT_MS)
|
||||||
|
|
||||||
|
// 2) Compose the harness page URL. The relay URL is already
|
||||||
|
// a `https://host:port/path?jwt=...` string from
|
||||||
|
// `buildRelayConnectTarget` — URL-encode it once for the
|
||||||
|
// `?relay=` slot so the inner `?jwt=` doesn't truncate.
|
||||||
|
val encodedRelay =
|
||||||
|
java.net.URLEncoder.encode(relayUrlFull, Charsets.UTF_8)
|
||||||
|
val pageUrl =
|
||||||
|
"http://127.0.0.1:$bunPort/$page" +
|
||||||
|
"?relay=$encodedRelay" +
|
||||||
|
"&broadcast=$broadcastPath" +
|
||||||
|
"&track=$track" +
|
||||||
|
"&wsPort=$bunPort" +
|
||||||
|
"&duration=$durationSec" +
|
||||||
|
extraQuery
|
||||||
|
|
||||||
|
// 3) Spawn Playwright. Use bun's `bun x` if available so we
|
||||||
|
// don't need a separate node install; falls back to npx.
|
||||||
|
val pwCmd = mutableListOf<String>()
|
||||||
|
if (File(bun).canExecute()) {
|
||||||
|
pwCmd += listOf(bun, "x", "playwright", "test", "--config=playwright.config.ts")
|
||||||
|
} else {
|
||||||
|
pwCmd += listOf("npx", "playwright", "test", "--config=playwright.config.ts")
|
||||||
|
}
|
||||||
|
val pwProc =
|
||||||
|
ProcessBuilder(pwCmd)
|
||||||
|
.directory(harnessDir)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.also { pb ->
|
||||||
|
pb.environment()["NESTS_HARNESS_URL"] = pageUrl
|
||||||
|
pb.environment()["NESTS_TIMEOUT_MS"] =
|
||||||
|
(overallTimeoutSec * 1_000).toString()
|
||||||
|
// Inherit PLAYWRIGHT_BROWSERS_PATH if the host
|
||||||
|
// has it (the agent runner ships it pointing at
|
||||||
|
// /opt/pw-browsers); otherwise Playwright falls
|
||||||
|
// back to ~/.cache/ms-playwright.
|
||||||
|
// No-op when env is already inherited.
|
||||||
|
}.start()
|
||||||
|
val pwDrainer = PlaywrightProcessDrainer(pwProc, "playwright").also { it.start() }
|
||||||
|
|
||||||
|
val exited = pwProc.waitFor(overallTimeoutSec.toLong(), TimeUnit.SECONDS)
|
||||||
|
if (!exited) {
|
||||||
|
runCatching { pwProc.destroyForcibly() }
|
||||||
|
val tail = pwDrainer.tail()
|
||||||
|
throw IllegalStateException(
|
||||||
|
"Playwright did not exit within ${overallTimeoutSec}s.\n" +
|
||||||
|
"--- playwright tail ---\n$tail",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Allow the bun server a brief moment to flush the WS frames
|
||||||
|
// it's still writing to disk before we read the PCM.
|
||||||
|
Thread.sleep(200)
|
||||||
|
return HarnessRun(
|
||||||
|
pcmFile = pcmFile,
|
||||||
|
playwrightStdout = pwDrainer.tail(),
|
||||||
|
exitCode = pwProc.exitValue(),
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
runCatching { bunProc.destroy() }
|
||||||
|
if (!bunProc.waitFor(3, TimeUnit.SECONDS)) {
|
||||||
|
runCatching { bunProc.destroyForcibly() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requireHarnessDir(): File {
|
||||||
|
val raw = System.getProperty(HARNESS_DIR_PROPERTY)
|
||||||
|
check(!raw.isNullOrBlank()) {
|
||||||
|
"system property '$HARNESS_DIR_PROPERTY' not set — did the Gradle test task forward it?"
|
||||||
|
}
|
||||||
|
val dir = File(raw)
|
||||||
|
check(dir.isDirectory) {
|
||||||
|
"$HARNESS_DIR_PROPERTY = '$raw' is not a directory"
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveBunBinary(): String {
|
||||||
|
System.getenv("BUN_BIN")?.let { return it }
|
||||||
|
System.getProperty("bunBin")?.let { return it }
|
||||||
|
val agentPath = "/root/.bun/bin/bun"
|
||||||
|
if (File(agentPath).canExecute()) return agentPath
|
||||||
|
return "bun"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val BUN_READY_TIMEOUT_MS = 30_000L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures the relay's leaf certificate during a QUIC TLS handshake
|
||||||
|
* so the test driver can pin it via Chromium's
|
||||||
|
* `WebTransport({ serverCertificateHashes: [...] })` option.
|
||||||
|
*
|
||||||
|
* Why we need this: Chromium's `--ignore-certificate-errors` flag does
|
||||||
|
* NOT apply to QUIC — see crbug.com/1190655 — so we can't simply skip
|
||||||
|
* certificate validation the way the Kotlin clients do.
|
||||||
|
* `serverCertificateHashes` is the supported alternative for
|
||||||
|
* test-only WebTransport pinning, accepting a SHA-256 of the entire
|
||||||
|
* DER-encoded X.509 certificate as long as the cert is ECDSA P-256
|
||||||
|
* and valid for ≤ 14 days. moq-relay's `--tls-generate` produces
|
||||||
|
* exactly that (rcgen default = ECDSA P-256, validity = 14 days; see
|
||||||
|
* `kixelated/moq/rs/moq-native/src/tls.rs:140`), so we can pin it.
|
||||||
|
*/
|
||||||
|
internal class CertCapturingValidator : com.vitorpamplona.quic.tls.CertificateValidator {
|
||||||
|
@Volatile private var captured: ByteArray? = null
|
||||||
|
|
||||||
|
override fun validateChain(
|
||||||
|
chain: List<ByteArray>,
|
||||||
|
expectedHost: String,
|
||||||
|
) {
|
||||||
|
if (captured == null && chain.isNotEmpty()) {
|
||||||
|
captured = chain.first().copyOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun verifySignature(
|
||||||
|
signatureAlgorithm: Int,
|
||||||
|
signature: ByteArray,
|
||||||
|
transcriptHash: ByteArray,
|
||||||
|
) {
|
||||||
|
// No-op; we're just here for the cert.
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SHA-256 of the captured DER cert, base64-encoded. Null until handshake completes. */
|
||||||
|
fun derSha256(): ByteArray? {
|
||||||
|
val der = captured ?: return null
|
||||||
|
return java.security.MessageDigest
|
||||||
|
.getInstance("SHA-256")
|
||||||
|
.digest(der)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal stdout drainer for the bun + Playwright subprocesses.
|
||||||
|
* Mirrors the private one in `NativeMoqRelayHarness.kt` — kept
|
||||||
|
* separate so the two test entry points don't share file-private
|
||||||
|
* symbols.
|
||||||
|
*/
|
||||||
|
private class PlaywrightProcessDrainer(
|
||||||
|
private val process: Process,
|
||||||
|
private val name: String,
|
||||||
|
) {
|
||||||
|
private val ring = java.util.concurrent.ConcurrentLinkedQueue<String>()
|
||||||
|
private val maxLines = 256
|
||||||
|
private val lock =
|
||||||
|
java.util.concurrent.locks
|
||||||
|
.ReentrantLock()
|
||||||
|
private val newLineCond = lock.newCondition()
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
Thread({
|
||||||
|
process.inputStream.bufferedReader().useLines { lines ->
|
||||||
|
for (line in lines) {
|
||||||
|
ring.add(line)
|
||||||
|
while (ring.size > maxLines) ring.poll()
|
||||||
|
lock.lock()
|
||||||
|
try {
|
||||||
|
newLineCond.signalAll()
|
||||||
|
} finally {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "PlaywrightDriver-$name").apply {
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tail(): String = ring.joinToString("\n")
|
||||||
|
|
||||||
|
fun waitForLine(
|
||||||
|
needle: String,
|
||||||
|
timeoutMs: Long,
|
||||||
|
) {
|
||||||
|
val deadlineNanos =
|
||||||
|
System.nanoTime() +
|
||||||
|
java.util.concurrent.TimeUnit.MILLISECONDS
|
||||||
|
.toNanos(timeoutMs)
|
||||||
|
if (ring.any { it.contains(needle) }) return
|
||||||
|
lock.lock()
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
if (ring.any { it.contains(needle) }) return
|
||||||
|
val remaining = deadlineNanos - System.nanoTime()
|
||||||
|
if (remaining <= 0) {
|
||||||
|
throw IllegalStateException(
|
||||||
|
"did not observe '$needle' in $name output within ${timeoutMs}ms.\n" +
|
||||||
|
"--- $name tail ---\n${tail()}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
newLineCond.awaitNanos(remaining)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user