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:
@@ -1,4 +1,5 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.io.File
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlinMultiplatform)
|
||||
@@ -227,3 +228,108 @@ tasks.withType<Test>().configureEach {
|
||||
systemProperty("nestsHangInteropSidecarsDir", sidecarRelease.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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user