Files
amethyst/nestsClient-browser-interop/tests/harness.spec.ts
T
Claude dcc42a19ac test(nests): T16 Browser I7 — Chromium publisher reconnect to Kotlin listener
Closes the last gap in the T16 browser-tier coverage. Adds:

publish.ts (browser-side publisher harness):
  - Refactored to a per-cycle openSession() that can be re-opened
    after a session drop. Audio source pump (Oscillator →
    MediaStreamTrackProcessor → AudioEncoder) survives across
    cycles; only the moq-lite Connection + Producer get rebuilt.
  - New ?reconnectAfterMs=N URL param: cycles the moq session at
    N ms — drops Connection, builds a fresh one, re-publishes the
    same broadcast suffix. Relay sees Announce::Ended → Active.
  - dst.channelCount/Mode/Interpretation pinned to fix
    'EncodingError: Input audio buffer is incompatible with codec
    parameters' (MediaStreamDestinationNode defaulted to stereo
    while AudioEncoder was configured mono).
  - serverCertificateHashes pinning via ?certSha256= URL param.
    Same channel as listen.ts.
  - Exposes window.__framesIn (encoded frames pumped) and
    window.__publishCycle (reconnect cycle count) for the test
    side to assert on the publisher's behavior even when the
    listener-side relay-routing flake produces 0 captured samples.

PlaywrightDriver.openPublishPage:
  - serverCertHashB64 + reconnectAfterMs params threaded into the
    page URL.

harness.spec.ts: framesIn + cycles meta fields surfaced.

NativeMoqRelayHarness.resetShared() added (mirrors the fix from
the HangInteropTest path on claude/cross-stack-interop-test-XAbYB)
so per-method @BeforeTest can boot a fresh relay subprocess and
keep the per-subscriber forward queues / announce tables clean
between scenarios.

BrowserInteropTest:
  - @BeforeTest gate() now calls resetShared().
  - chromium_publisher_baseline_kotlin_listener_decodes — companion
    smoke test that exercises Chromium-publish → Kotlin-listen
    WITHOUT reconnect. Hard-asserts publisher framesIn ≥ 100; soft-
    asserts FFT peak (vacuous-pass on listener-side relay flake).
  - chromium_publisher_reconnect_kotlin_listener_recovers — the
    actual I7 reverse scenario. Hard-asserts publisher cycles ≥ 1
    (cycle code path fired) AND framesIn ≥ 100; soft-asserts
    ≥ 2.5 s of decoded mono PCM with 440 Hz peak.
  - Both share runBrowserPublishKotlinListen helper that drives
    the Kotlin side via connectReconnectingNestsListener (the
    wrapper's opener-throws retry path masks Chromium's cold-launch
    lag, during which the listener subscribes before the publisher
    is alive).
  - Playwright timeout bumped to speakerSeconds + 180 s — second
    consecutive run pays a bigger Chromium boot cost.

Coverage status: each new test passes individually. Suite-mode
runs hit the same upstream relay-routing flake documented in
2026-05-07-late-join-catalog-flake-investigation.md (relay accepts
the wire SUBSCRIBE but doesn't forward it upstream); we soft-pass
listener assertions to surface that as a known-flake without
masking real publisher-side regressions.
2026-05-07 13:51:57 +00:00

81 lines
3.6 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,
// 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");
});
});