feat(nests): T16 Phase 4.A+B — browser-side cross-stack interop scaffold + I1 forward

Lands the bun + Playwright + headless Chromium harness for the
T16 cross-stack interop suite, parallel to the existing Rust
hang-listen tier. New top-level `nestsClient-browser-interop/`
directory with `@moq/lite` + `@moq/hang` 0.2.x pinned, a bun
static + WebSocket back-channel server, and a Playwright runner
that opens `listen.html` against the same `NativeMoqRelayHarness`
moq-relay subprocess the Rust scenarios use.

Kotlin side: `PlaywrightDriver` shells out to `bun x playwright
test`, forwards the relay URL + leaf-cert SHA-256 (captured via
a custom `CertCapturingValidator` during the speaker's QUIC
handshake), and reads back Float32 LE PCM frames from a tempfile
the bun WS server appends to. `BrowserInteropTest` ships I1
forward — Amethyst Kotlin speaker → Chromium `@moq/lite`
listener, asserting FFT 440 Hz on the captured tail.

Why pin via `serverCertificateHashes` instead of
`--ignore-certificate-errors`: Chromium's flag does NOT bypass
QUIC cert validation (crbug.com/1190655). `serverCertificateHashes`
is the supported path; moq-relay's `--tls-generate` produces a
14-day ECDSA P-256 cert that satisfies the spec.

Two Gradle tasks added: `interopBuildBrowserHarness` (bun
install + bun build → dist/) and `interopInstallPlaywrightChromium`
(skipped when `PLAYWRIGHT_BROWSERS_PATH` already has a chromium
build, as on the agent runner).

Verification:
- `./gradlew :nestsClient:jvmTest --tests
   com.vitorpamplona.nestsclient.interop.native.BrowserInteropTest
   -DnestsHangInterop=true -DnestsBrowserInterop=true` green.
- I1 forward asserts ≥ 1 s of decoded PCM with 440 Hz FFT peak.
  Looser sample-count bound than the hang-tier I1 because
  Chromium cold-launch + WebTransport handshake (3–5 s) + the
  publisher's `framesPerGroup = 5` per-subscriber cache cliff
  means the page captures only the broadcast tail.

Phase 4.C (I2/I3/I4/I13/I14/I15) and 4.D (CI) are separate
follow-up commits per the plan's per-scenario commit guidance.

See: nestsClient/plans/2026-05-06-phase4-browser-harness.md

https://claude.ai/code/session_01ERJPUYfdLPwZ99pr5EcEcV
This commit is contained in:
Claude
2026-05-07 00:44:42 +00:00
parent ced9025cff
commit e0a9332498
15 changed files with 1726 additions and 0 deletions
@@ -0,0 +1,68 @@
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,
}));
// 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");
});
});