diff --git a/README.md b/README.md index beefb2c..409ff66 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,10 @@ The pane falls back automatically when WebSocket support is unavailable, the stream disconnects, or the selected renderer cannot display streamed JPEGs. No feature flag is required. +If a stream connects but sends no usable image within five seconds, the pane +returns to screenshot polling and retries streaming after its cooldown. A +connected WebSocket alone does not establish that image delivery is working. + ### Pane fitting On attach and pane resize, herdr-browser preserves the session's current @@ -337,7 +341,8 @@ name appears in the viewer header. closed with it so the browser daemon is not leaked. - The **Close** action always ends the workspace session and closes its browser panes. -- Plugin-created browser daemons default to a 30-minute idle timeout. +- Daemon idle timeouts follow agent-browser's configuration. The viewer does + not inject a different timeout, which can restart an existing daemon. To watch a differently named agent-browser session, write its name to the plugin configuration directory: @@ -414,7 +419,7 @@ Equivalent environment controls: | `HERDR_BROWSER_LAUNCH` | Unset | `1` launches a Chromium on open instead of waiting for a session | | `HERDR_BROWSER_OBSERVE` | Unset | `1` starts the pane observe-only | | `HERDR_BROWSER_INTERVAL_MS` | `1000` | Polling interval; clamped to safe bounds | -| `AGENT_BROWSER_IDLE_TIMEOUT_MS` | `1800000` | Idle timeout for plugin-created browser daemons | +| `AGENT_BROWSER_IDLE_TIMEOUT_MS` | Engine default | Inherited unchanged; use the same value for the agent and pane to avoid daemon configuration changes | Environment variables take precedence over config files. @@ -458,17 +463,37 @@ needed. ## Development +Use Node 22+ for full browser support (Node 20 supports polling only), Python +3.11+ for manifest validation, and ShellCheck for launcher validation. The +plugin runs its source directly; there is no bundled browser or compilation +step. + ```sh git clone https://github.com/StructuPath/herdr-browser cd herdr-browser -npm test -shellcheck scripts/*.sh +npm run doctor +npm run build +npm run validate +npm run test:integration herdr plugin link . ``` -`npm test` includes unit, launcher, security, rendering, input, recording, and -live-stream coverage. The real agent-browser integration test skips when its -engine is unavailable. +`npm run doctor` checks local prerequisites without starting a browser, +contacting an endpoint, or changing configuration. Optional tools are warnings; +missing prerequisites for the selected backend cause a nonzero exit. It does +not verify Herdr's version, engine downloads, or endpoint reachability. + +`npm run build` checks every JavaScript and shell source file plus the plugin +manifest. `npm run validate` adds ShellCheck and the complete test suite. +`npm test` includes launcher, security, rendering, input, recording, and live +browser coverage; optional browser tests skip when their prerequisites are +unavailable. `npm run test:integration` requires both an installed Chrome or +Chromium and agent-browser with its engine installed, on Node 22+. It fails +instead of silently skipping either real-browser path. Tests use a local HTTP +fixture and isolated browser sessions. + +For backend choices and the remaining readiness work, see the +[readiness assessment](docs/readiness.md). ## License diff --git a/bin/doctor.mjs b/bin/doctor.mjs new file mode 100644 index 0000000..6a7a772 --- /dev/null +++ b/bin/doctor.mjs @@ -0,0 +1,49 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { spawnSync } from "node:child_process"; +import { findChromium, truthyConfig } from "./renderer.mjs"; + +export function diagnose(env = process.env, { + probe = (command) => spawnSync("/bin/sh", ["-c", 'command -v -- "$1"', "sh", command], { + env, timeout: 5_000, stdio: "ignore", + }).status === 0, + readConfig = (name) => { + if (!env.HERDR_PLUGIN_CONFIG_DIR) return ""; + try { return fs.readFileSync(path.join(env.HERDR_PLUGIN_CONFIG_DIR, name), "utf8").split("\n")[0].trim(); } + catch { return ""; } + }, + nodeVersion = process.versions.node, + webSocket = typeof WebSocket === "function", +} = {}) { + const checks = []; + const add = (status, name, detail) => checks.push({ status, name, detail }); + add(Number(nodeVersion.split(".")[0]) >= 20 ? "ok" : "error", "Node.js", `${nodeVersion}; Node 22+ recommended for streaming, attach, and launch`); + add(probe(env.HERDR_BIN_PATH || "herdr") ? "ok" : "error", "Herdr CLI", "requires Herdr >= 0.7.0; set HERDR_BIN_PATH if it is not on PATH"); + const agent = probe("agent-browser"); + const chromium = findChromium(env, readConfig("chromium"), probe); + const launchable = !!chromium && probe(chromium); + const endpoint = String(env.HERDR_BROWSER_CDP_URL || readConfig("cdp-url")).trim(); + const launch = truthyConfig(env.HERDR_BROWSER_LAUNCH || readConfig("launch")); + if (endpoint) { + let valid = false; + try { valid = ["http:", "https:", "ws:", "wss:"].includes(new URL(endpoint).protocol); } catch {} + add(valid && webSocket ? "ok" : "error", "Selected backend: attach", valid ? "endpoint configured (not contacted); requires Node 22+" : "invalid endpoint; use http://host:port or a browser WebSocket URL"); + } else if (launch) { + add(launchable && webSocket ? "ok" : "error", "Selected backend: launch", "requires Node 22+ and an executable Chrome/Chromium; set HERDR_BROWSER_CHROMIUM"); + } else { + add(agent || (launchable && webSocket) ? "ok" : "error", "Browser engine", agent ? "agent-browser available; install its engine with agent-browser install" : launchable && webSocket ? "Chrome/Chromium available; press l or set HERDR_BROWSER_LAUNCH=1" : "install agent-browser and run agent-browser install, or configure Chrome/Chromium with Node 22+"); + } + add(launchable ? "ok" : "warn", "Local Chromium", launchable ? "executable found; launch not attempted" : "not found; optional for shared agent sessions and external attach"); + add(probe("chafa") ? "ok" : "warn", "Image rendering", "chafa enables ANSI images and Kitty JPEGs; macOS: brew install chafa"); + add(probe("carbonyl") ? "ok" : "warn", "Interactive Browse", "Carbonyl is optional and uses a separate browser session"); + return checks; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const checks = diagnose(); + for (const { status, name, detail } of checks) console.log(`${status.toUpperCase()} ${name}: ${detail}`); + console.log("Prerequisite check only. No browsers launched, endpoints contacted, or configuration changed."); + process.exitCode = checks.some((c) => c.status === "error") ? 1 : 0; +} diff --git a/bin/renderer.mjs b/bin/renderer.mjs index b2a2eb2..ce3e5a1 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -427,14 +427,8 @@ export function makeBrowser(session, bin = "agent-browser") { // Console rings on noisy pages reach several MB — Node's 1 MiB default // maxBuffer would throw on every tick and freeze the pane for good. const maxBuffer = 16 * 1024 * 1024; - // If a call is the one that spawns the session daemon, the daemon - // self-reaps after idle instead of living forever. No-op for daemons an - // agent already owns (read at daemon spawn only). - const abEnv = () => ({ - ...process.env, - AGENT_BROWSER_IDLE_TIMEOUT_MS: - process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS || "1800000", - }); + // Inherit the caller's configuration unchanged. Injecting an idle timeout + // makes agent-browser restart an existing daemon and lose its browser. const parse = (stdout, what) => { let parsed; try { @@ -452,7 +446,7 @@ export function makeBrowser(session, bin = "agent-browser") { const { stdout } = await pExecFile( bin, ["--session", session, "batch", "--bail", "--json", ...cmds], - { timeout, maxBuffer, env: abEnv() }, + { timeout, maxBuffer }, ); const arr = parse(stdout, "batch output"); for (const r of arr) { @@ -470,7 +464,6 @@ export function makeBrowser(session, bin = "agent-browser") { { timeout: 10_000, maxBuffer, - env: abEnv(), }, ); const parsed = parse(stdout, "output"); @@ -1937,11 +1930,27 @@ export class Renderer { const port = Number(status?.port); if (!Number.isInteger(port) || port < 1 || port > 65535) return false; let ws; + const initialFrameSeq = this.frameSeq; try { ws = new WebSocket(`ws://127.0.0.1:${port}`); } catch { return false; // malformed URL or constructor failure: just poll } + // Chrome can send its only frame for a quiet page with the handshake. + // Install the listener before awaiting open so those messages survive. + ws.onmessage = (ev) => { + let m; + try { + m = JSON.parse(ev.data); + } catch { + return; + } + try { + this.onStreamMessage(m); + } catch { + this.paintErrors++; + } + }; const ok = await new Promise((resolve) => { const timer = setTimeout(() => { try { @@ -1962,21 +1971,13 @@ export class Renderer { }); if (!ok) return false; this.live = { ws }; - ws.onmessage = (ev) => { - let m; - try { - m = JSON.parse(ev.data); - } catch { - return; + const live = this.live; + live.firstFrameTimer = setTimeout(() => { + if (this.live === live && this.frameSeq === initialFrameSeq) { + this.dropLive("live stream sent no image — polling"); } - // A malformed message must never become an uncaughtException — - // those bypass cleanup() and leave the terminal wedged. - try { - this.onStreamMessage(m); - } catch { - this.paintErrors++; - } - }; + }, 5_000); + live.firstFrameTimer.unref?.(); const drop = () => this.dropLive("live stream dropped — polling"); ws.onclose = drop; ws.onerror = drop; @@ -1992,6 +1993,10 @@ export class Renderer { this.stopNetworkTimer(); const wasLive = !!this.live; if (this.live) { + clearTimeout(this.live.firstFrameTimer); + this.live.ws.onmessage = null; + this.live.ws.onclose = null; + this.live.ws.onerror = null; try { this.live.ws.close(); } catch { @@ -2193,6 +2198,7 @@ export class Renderer { cleanup() { if (this.mode === "kitty") process.stdout.write(KITTY_DELETE_ALL); this.stopNetworkTimer(); + clearTimeout(this.live?.firstFrameTimer); try { this.live?.ws.close(); } catch { diff --git a/docs/readiness.md b/docs/readiness.md new file mode 100644 index 0000000..527d716 --- /dev/null +++ b/docs/readiness.md @@ -0,0 +1,64 @@ +# Browser readiness assessment + +## Engine decision + +Keep the existing Chromium integrations. Current main already supports a +locally launched Chrome/Chromium (`l`), external CDP attach (`a`), shared +agent-browser sessions, and an optional separate Carbonyl browser. Adding an +Electron shell or maintaining a Chromium fork would duplicate the engine and +add packaging and update work without fixing the session and streaming bugs. + +- Use **agent-browser** to share a coding agent's session. +- Use **local Chromium launch** for standalone browsing in the pane. Set + `HERDR_BROWSER_LAUNCH=1` and, if discovery fails, + `HERDR_BROWSER_CHROMIUM=/absolute/path/to/chrome`. +- Use **CDP attach** to watch an existing automation browser. Use observe-only + mode when the pane should not forward user input. +- Use **Carbonyl** when full terminal-native interaction matters more than + sharing the agent's session. + +Upstream installation references: [agent-browser](https://agent-browser.dev/installation) +and [Carbonyl](https://github.com/fathyb/carbonyl). Agent-browser installs its +own Chrome engine; a second bundled distribution is unnecessary. + +## Changes in this PR + +- Preserve daemon configuration: injecting a 30-minute idle timeout into an + existing agent-browser 0.33.2 session restarted its daemon and discarded its + browser during a read-only stream-status request. Both launcher navigation + and renderer commands now inherit the caller's settings unchanged. +- Register the frame listener before awaiting the WebSocket handshake. +- Recover to screenshot polling if a connected stream sends no image within + five seconds; remove stale socket listeners and cancel the watchdog on exit. +- Add build, validation, prerequisite diagnostics, and mandatory real-browser + checks. Real tests cover local Chromium lifecycle, streaming, navigation, + console delivery, reconnect, and screenshot fallback without public sites. +- Limit test discovery to this checkout's `tests/` directory; Node 20 otherwise + traverses hidden nested worktrees and runs unrelated, stale test copies. + +## Next recommendations, in priority order + +1. **Release compatibility matrix.** Exercise macOS and Linux, Node 22/24, + supported agent-browser versions, and Kitty/symbol rendering in Herdr. + Current CI has Node 20/22 but does not install agent-browser, so its passing + status alone cannot prove shared-session integration. Adopting the strict + integration command in hosted CI is a separate workflow change. +2. **CDP transport correctness.** HTTP discovery currently uses `node:http` + even for an HTTPS input and assumes the local debugging port. Implement + actual HTTPS discovery with transport tests before advertising secured + remote HTTP discovery; use a verified browser WebSocket endpoint meanwhile. +3. **Long-session reliability.** Add soak tests for daemon restart, stalled + streams after the first frame, rapid resize, multiple viewers, and reconnect + during navigation. The new watchdog covers initial image delivery, not all + possible later stalls. +4. **Interactive browser completeness.** Prioritize keyboard shortcuts, + downloads, file upload, dialogs, and explicit target selection. Define + behavior separately for owned and externally controlled browsers and prove + each against a local fixture before adding UI controls. +5. **Recording across backends.** Recording currently requires agent-browser. + Direct Chromium/CDP recording should clearly state its capture lifecycle + and avoid altering externally owned browser contexts. + +These are follow-up milestones, not claims that this plugin is a full browser +replacement. The current PR addresses startup verification and the reproduced +shared-session failure while retaining the existing engine architecture. diff --git a/package.json b/package.json index 4291b57..5041f4b 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,10 @@ "type": "module", "engines": { "node": ">=20" }, "scripts": { - "test": "node --test" + "build": "node scripts/build.mjs", + "doctor": "node bin/doctor.mjs", + "test": "node --test tests/*.test.mjs", + "test:integration": "HERDR_BROWSER_REQUIRE_INTEGRATION=1 node --test --test-name-pattern='end to end|e2e:' tests/launch.integration.test.mjs tests/renderer.test.mjs", + "validate": "npm run build && shellcheck scripts/*.sh && npm test" } } diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100755 index 0000000..6ca70c5 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +// This plugin ships source directly; building verifies the runnable package. +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { validateRepository } from "./check-manifest.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +let count = 0; +for (const dir of ["bin", "scripts", "tests"]) { + for (const name of fs.readdirSync(path.join(root, dir)).sort()) { + const file = path.join(dir, name); + const command = name.endsWith(".mjs") ? process.execPath : name.endsWith(".sh") ? "bash" : null; + if (!command) continue; + const result = spawnSync(command, [command === "bash" ? "-n" : "--check", file], { + cwd: root, stdio: "inherit", timeout: 10_000, + }); + if (result.error || result.status !== 0) { + console.error(`Build failed: ${file}${result.error ? `: ${result.error.message}` : ""}`); + process.exit(1); + } + count++; + } +} +const { errors, entrypointCount } = validateRepository(root); +if (errors.length) { + for (const error of errors) console.error(error); + process.exitCode = 1; +} else { + console.log(`Build verified: ${count} source files, ${entrypointCount} manifest entrypoints. No compilation required.`); +} diff --git a/scripts/open.sh b/scripts/open.sh index 397f274..369e2b4 100755 --- a/scripts/open.sh +++ b/scripts/open.sh @@ -55,9 +55,8 @@ elif [ -n "$url" ]; then echo "herdr-browser: refusing URL (must start with http:// or https://, no credentials): $url" >&2 exit 2 fi - # If this call spawns the session daemon, let it self-reap after 30 min - # idle instead of living forever (no-op for daemons agents already own). - export AGENT_BROWSER_IDLE_TIMEOUT_MS="${AGENT_BROWSER_IDLE_TIMEOUT_MS:-1800000}" + # Preserve the caller's daemon configuration. An injected timeout can + # restart an existing agent-owned daemon and discard its browser state. if ! with_timeout 15 agent-browser --session "$session" open "$url" >/dev/null; then echo "herdr-browser: agent-browser failed to open $url" >&2 exit 3 diff --git a/tests/doctor.test.mjs b/tests/doctor.test.mjs new file mode 100644 index 0000000..0798db5 --- /dev/null +++ b/tests/doctor.test.mjs @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { diagnose } from "../bin/doctor.mjs"; + +const check = (env = {}, available = [], options = {}) => diagnose(env, { + probe: (name) => available.includes(name), readConfig: () => "", nodeVersion: "22.0.0", webSocket: true, ...options, +}); +const errors = (checks) => checks.filter((c) => c.status === "error"); + +test("doctor accepts shared sessions and treats rendering tools as optional", () => { + const checks = check({}, ["herdr", "agent-browser"]); + assert.deepEqual(errors(checks), []); + assert.ok(checks.some((c) => c.name === "Image rendering" && c.status === "warn")); +}); + +test("doctor requires a usable engine and Herdr", () => { + assert.equal(errors(check()).length, 2); + assert.equal(errors(check({}, ["herdr", "google-chrome"])).length, 0); + assert.equal(errors(check({}, ["herdr", "google-chrome"], { webSocket: false })).length, 1); +}); + +test("doctor honors selected backend and never prints endpoint credentials", () => { + const env = { HERDR_BROWSER_CDP_URL: "wss://user:secret@example.com/devtools/browser/private-token", HERDR_BROWSER_LAUNCH: "1" }; + const checks = check(env, ["herdr"]); + assert.deepEqual(errors(checks), []); + assert.doesNotMatch(JSON.stringify(checks), /secret|private-token/); + assert.equal(errors(check(env, ["herdr"], { webSocket: false })).length, 1); + assert.equal(errors(check({ HERDR_BROWSER_CDP_URL: "bad" }, ["herdr"])).length, 1); +}); + +test("doctor checks explicit Chromium executability and configuration files", () => { + assert.equal(errors(check({ HERDR_BROWSER_LAUNCH: "1", HERDR_BROWSER_CHROMIUM: "/missing" }, ["herdr", "agent-browser"])).length, 1); + assert.deepEqual(errors(check({}, ["herdr", "/custom/chrome"], { + readConfig: (name) => ({ launch: "true", chromium: "/custom/chrome" })[name] || "", + })), []); +}); diff --git a/tests/launch.integration.test.mjs b/tests/launch.integration.test.mjs index e282908..6935614 100644 --- a/tests/launch.integration.test.mjs +++ b/tests/launch.integration.test.mjs @@ -31,7 +31,11 @@ const until = async (cond, ms) => { return cond(); }; -test("launch mode drives a real Chromium end to end", { skip }, async () => { +test("launch mode drives a real Chromium end to end", { + skip: process.env.HERDR_BROWSER_REQUIRE_INTEGRATION === "1" ? false : skip, + timeout: 60_000, +}, async () => { + assert.equal(skip, false, `integration prerequisites missing: ${skip}`); const r = new Renderer({ HERDR_BROWSER_SESSION: "hb-launch-int", HERDR_PLUGIN_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "hb-int-")), diff --git a/tests/launchers.test.mjs b/tests/launchers.test.mjs index 0b7ac48..88a75a6 100644 --- a/tests/launchers.test.mjs +++ b/tests/launchers.test.mjs @@ -86,6 +86,22 @@ test("open with URL navigates workspace session and opens pane", () => { ); }); +test("open preserves the agent daemon idle-timeout configuration", () => { + const stub = path.join(stubDir, "agent-browser"); + const original = fs.readFileSync(stub, "utf8"); + writeStub("agent-browser", 'printf "timeout=%s\\n" "${AGENT_BROWSER_IDLE_TIMEOUT_MS-unset}" >> "$STUB_LOG"'); + try { + let result = runScript("open.sh", ["http://localhost:3000"]); + assert.equal(result.status, 0, result.stderr); + assert.match(log(), /timeout=unset/); + result = runScript("open.sh", ["http://localhost:3000"], freshEnv({ AGENT_BROWSER_IDLE_TIMEOUT_MS: "900000" })); + assert.equal(result.status, 0, result.stderr); + assert.match(log(), /timeout=900000/); + } finally { + fs.writeFileSync(stub, original); + } +}); + test("open with live pane focuses instead of opening a second pane", () => { fs.writeFileSync(path.join(stateDir, "pane-id-w9"), "w9:p7\n"); const r = runScript( diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 631d53e..130adb4 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -4,6 +4,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import http from "node:http"; import { fileURLToPath } from "node:url"; import { deriveSession, @@ -1067,6 +1068,64 @@ const jpeg = (w, h) => { return b; }; +test("goLive receives frames delivered with the WebSocket handshake", async () => { + const r = quiet(mkRenderer()); + r.fitViewport = async () => false; + r.browser = { + streamEnable: async () => {}, + streamStatus: async () => ({ port: 9222 }), + }; + const saved = global.WebSocket; + global.WebSocket = class { + constructor() { + queueMicrotask(() => { + this.onopen(); + this.onmessage?.({ + data: JSON.stringify({ type: "frame", data: jpeg(1280, 720).toString("base64") }), + }); + }); + } + close() {} + }; + try { + assert.equal(await r.goLive(), true); + assert.equal(r.frameSeq, 1, "the initial frame must not be lost before goLive resumes"); + } finally { + r.dropLive(); + global.WebSocket = saved; + } +}); + +test("a connected stream without an image falls back and ignores late messages", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const r = quiet(mkRenderer()); + r.browser = { + streamEnable: async () => {}, + streamStatus: async () => ({ port: 9222 }), + }; + const saved = global.WebSocket; + let socket; + global.WebSocket = class { + constructor() { + socket = this; + queueMicrotask(() => this.onopen()); + } + close() { this.closed = true; } + }; + try { + assert.equal(await r.goLive(), true); + t.mock.timers.tick(5_000); + assert.equal(r.live, null); + assert.equal(socket.closed, true); + assert.equal(socket.onmessage, null); + assert.equal(r.shotFormat, "png"); + assert.match(r.banner, /no image.*polling/); + } finally { + r.dropLive(); + global.WebSocket = saved; + } +}); + test("jpegDims reads SOF0 dimensions, rejects junk", () => { assert.deepEqual(jpegDims(jpeg(1280, 720)), { w: 1280, h: 720 }); assert.equal(jpegDims(Buffer.from("not a jpeg")), null); @@ -1231,9 +1290,11 @@ test("polling tick tries goLive once, then respects the cooldown", async () => { const hasAgentBrowser = spawnSync("sh", ["-c", "command -v agent-browser"]).status === 0; test("e2e: goLive receives pushed frames and console from a real session", { - skip: !hasAgentBrowser && "agent-browser not installed", + skip: process.env.HERDR_BROWSER_REQUIRE_INTEGRATION !== "1" && + (!hasAgentBrowser ? "agent-browser not installed" : !canCdp && "needs Node 22+"), timeout: 30_000, }, async () => { + assert.ok(hasAgentBrowser && canCdp, "integration requires agent-browser and Node 22+"); const session = `hb-itest-${process.pid}`; const { execFile: ef } = await import("node:child_process"); const ab = (args) => @@ -1245,9 +1306,14 @@ test("e2e: goLive receives pushed frames and console from a real session", { (e, so) => (e ? rej(e) : res(so)), ), ); + const server = http.createServer((_req, res) => { + res.setHeader("Content-Type", "text/html"); + res.end("Browser stream test

Local browser fixture

"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const r = quiet(mkRenderer({ HERDR_BROWSER_SESSION: session })); try { - await ab(["open", "https://example.com"]); - const r = quiet(mkRenderer({ HERDR_BROWSER_SESSION: session })); + await ab(["open", `http://127.0.0.1:${server.address().port}`]); // Viewport fitting has dedicated tests; disabling it here prevents its // queued resize command from racing this stream test's session close. r.fitViewport = async () => false; @@ -1261,6 +1327,10 @@ test("e2e: goLive receives pushed frames and console from a real session", { imageDims(fs.readFileSync(r.shotJpg)), "frame is a valid image on disk", ); + const beforeNavigation = r.frameSeq; + const fixtureUrl = `http://127.0.0.1:${server.address().port}`; + await ab(["open", fixtureUrl]); + assert.ok(await until(() => r.frameSeq > beforeNavigation), "navigation produces a fresh streamed frame"); await ab(["eval", 'console.warn("hb-itest-marker")']); const cDeadline = Date.now() + 10_000; while ( @@ -1274,8 +1344,21 @@ test("e2e: goLive receives pushed frames and console from a real session", { "console entry streamed live", ); r.dropLive(); + // A late observer may get a connected socket with no frame from the + // engine. Prove it recovers a real image through polling in that case. + const beforeReconnect = r.frameSeq; + assert.equal(await r.goLive(), true); + assert.ok(await until(() => r.frameSeq > beforeReconnect || !r.live, 7_000)); + if (!r.live) { + r.attached = true; + await r.tick(); + assert.ok(pngComplete(fs.readFileSync(r.shot)), "fallback captures a complete browser screenshot"); + assert.ok(r.lastUrl.startsWith(fixtureUrl)); + } } finally { + r.dropLive(); await ab(["close"]).catch(() => {}); + await new Promise((resolve) => server.close(resolve)); } }); @@ -1641,6 +1724,26 @@ test("network format: sanitizes and hard-caps page-controlled URLs", () => { ); }); +test("makeBrowser preserves the caller's idle timeout for reads and batches", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hb-env-")); + const stub = path.join(dir, "ab-stub"); + const logf = path.join(dir, "timeout"); + fs.writeFileSync(stub, `#!/bin/sh\nprintf '%s\\n' "\${AGENT_BROWSER_IDLE_TIMEOUT_MS-unset}" >> '${logf}'\ncase "$*" in *batch*) echo '[{"success":true,"result":{}},{"success":true,"result":{}},{"success":true,"result":{}},{"success":true,"result":{}}]';; *) echo '{"success":true,"data":{}}';; esac\n`, { mode: 0o755 }); + const saved = process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + try { + delete process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + await makeBrowser("s", stub).streamStatus(); + await makeBrowser("s", stub).snapshot("/tmp/unused"); + process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = "900000"; + await makeBrowser("s", stub).streamStatus(); + assert.equal(fs.readFileSync(logf, "utf8"), "unset\nunset\n900000\n"); + } finally { + if (saved === undefined) delete process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + else process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = saved; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test("makeBrowser.network passes the type filter, never --clear", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hb-net-")); const logf = path.join(dir, "log");