a7ea77a35a
Adds the two browser-tier P0 scenarios deferred from Phase 4.C:
I13 (chromium_listener_long_broadcast_60s_tone_440):
- 60 s end-to-end Amethyst speaker -> Chromium @moq/lite + @moq/hang
Container.Legacy.Consumer; assert >= 50 s of decoded PCM, FFT
peak intact at 440 Hz, zero decoder errors.
- Spec asked for framesPerGroup = 50 against actual Container.Consumer.
Two constraints: (1) @moq/hang 0.2.4 doesn't export the high-level
Container.Consumer/Format API (Phase 4 uses Container.Legacy.Consumer
directly), (2) framesPerGroup = 50 against the local moq-relay
0.10.25 --auth-public minimal setup hits the per-subscriber forward
cliff per 2026-05-07-framespergroup-reconciliation.md. Pin 5
locally; production keeps 50.
- Catches what I1 forward (10 s) doesn't: Chromium WebTransport
MAX_STREAMS_UNI credit drift over 600+ uni streams, group-queue
eviction past MAX_GROUP_AGE = 30 s twice over, WebCodecs decoder
pacing/memory pressure at broadcast scale.
I14 (chromium_decoder_no_errors_through_warmup_window):
- Asserts Chromium AudioDecoder.error fires zero times during
a 10 s broadcast. Browser-tier mate of HangInteropTest's I11
(first_audio_frame_is_not_opus_codec_config); together they
cover T8 (BUFFER_FLAG_CODEC_CONFIG skip) on both reference paths.
- Deliberately no decoderOutputs floor: the Phase 4 harness has
a known cold-launch race that occasionally produces 0 frames
(per 2026-05-06-phase4-browser-harness-results.md). Since I14
is an absence assertion, vacuous-zero is safe — a T8 regression
would still trigger on whichever frames arrive in any green run.
- Note: JVM speaker uses libopus directly (no CSD prefix), so
this test path effectively asserts no spurious decode failures.
T8 itself is an Android-MediaCodecOpusEncoder fix; that path
isn't reachable from a JVM-host test.
Wire changes:
- listen.ts gains decoderOutputs + decoderErrors counters,
exposed via window.__decoderOutputs / __decoderErrors.
- tests/harness.spec.ts surfaces both in the meta JSON line.
- BrowserInteropTest.kt adds parseIntMetaFromStdout helper +
the two new scenarios.
Verification: all 4 BrowserInteropTest scenarios green in one
JVM run, no flake under -DnestsHangInterop=true -DnestsBrowserInterop=true.
76 lines
3.3 KiB
TypeScript
76 lines
3.3 KiB
TypeScript
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,
|
|
}));
|
|
// 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");
|
|
});
|
|
});
|