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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user