diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95d2129..c5f0247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,19 +12,25 @@ jobs: validate: runs-on: ubuntu-latest timeout-minutes: 10 + strategy: + matrix: + # 20 is the supported floor; 22 exercises live streaming and CDP + # attach mode (global WebSocket client). + node-version: [20, 22] steps: - name: Check out repository # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - name: Set up Python - uses: actions/setup-python@v5 with: - python-version: "3.11" + # No later step runs authenticated git; don't leave the token in + # the checkout for repository-controlled test code to read. + persist-credentials: false - name: Set up Node.js # v4.0.3 uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b with: - node-version: 20 + node-version: ${{ matrix.node-version }} + - name: Run tests run: npm test - name: Validate shell syntax run: bash -n scripts/*.sh diff --git a/README.md b/README.md index 4eb6ded..beefb2c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # herdr-browser +[![CI](https://github.com/StructuPath/herdr-browser/actions/workflows/ci.yml/badge.svg)](https://github.com/StructuPath/herdr-browser/actions/workflows/ci.yml) + A drivable browser pane for [Herdr](https://herdr.dev), built around [agent-browser](https://github.com/vercel-labs/agent-browser). @@ -19,6 +21,10 @@ Conductor). - **Shared agent sessions** — one isolated browser session per Herdr workspace. - **Attach to any CDP browser** — observe a Playwright, Puppeteer, or Browser Use run (or any Chrome started with `--remote-debugging-port`) without owning it. +- **Zero-setup launch** — press `l` and the pane launches a local Chromium of + its own and attaches to it; no agent-browser install required. +- **Observe-only mode** — press `o` and pane input stops being forwarded, so + watching a live automation run can never perturb it. - **Live push streaming** — frames, URL/title changes, console messages, and page errors arrive over WebSocket, with transparent polling fallback. - **Failed network requests** — 4xx/5xx and no-response xhr/fetch/document @@ -39,8 +45,9 @@ Conductor). | Component | Requirement | Notes | | --- | --- | --- | | Herdr | `>= 0.7.0` | Tested with Herdr 0.7.4 | -| Node.js | `>= 20` | Node 22+ enables live WebSocket streaming and CDP attach mode | -| agent-browser | Required | Tested with agent-browser 0.33.x; failed-request reporting needs the `network requests` command | +| Node.js | `>= 20` | Node 22+ enables live WebSocket streaming, CDP attach mode, and launch mode | +| agent-browser | Optional | Required for shared agent sessions; tested with agent-browser 0.33.x; failed-request reporting needs the `network requests` command | +| Chromium/Chrome | Optional | Any Chromium-based browser enables launch mode (`l`) and attach mode | | chafa | Optional | ANSI rendering and streamed JPEGs in Kitty mode | | carbonyl | Optional | Only required for the separate interactive Browse action | @@ -140,6 +147,9 @@ Use these controls to drive the shared session directly: | --- | --- | | `u` | Open the address prompt; `https://` is assumed when omitted | | `a` | Attach to a CDP endpoint (`http://host:port` or `ws://…`) | +| `l` | Launch a local Chromium the pane owns and attach to it | +| `t` | Attach mode: cycle the pane between the browser's page targets (tabs) | +| `o` | Toggle observe-only: pane input is dropped instead of forwarded | | Click the screenshot | Send real Chrome mouse move/down/up events at that page coordinate | | `i` | Type into the currently focused page element | | `b` / `f` | Navigate backward / forward | @@ -212,10 +222,7 @@ seconds without a status). Only xhr, fetch, and document requests are watched images, stylesheets, and held-open streams (SSE, WebSocket) stay out. Failures from before the pane attached are intentionally not replayed, a repeating identical failure paints once and stays collapsed until it has been quiet for -60 seconds, and on very long -from before the pane attached are intentionally not replayed, repeated -identical failures are collapsed within a 60-second window, and on very long -sessions the feed turns itself off with a one-time note once the daemon's +60 seconds, and on very long sessions the feed turns itself off with a one-time note once the daemon's request log outgrows the pane's read buffer. ## Attach to any CDP browser @@ -236,7 +243,20 @@ printf 'http://127.0.0.1:9222\n' > "$(herdr plugin config-dir structupath.browse Press `a` in the pane to attach at runtime. `u` still means "navigate" — the keys are separate because `localhost:9222` is a valid destination as well as a -valid endpoint. +valid endpoint. While attached, the pane header shows the endpoint's +`host:port` instead of a session name, `t` cycles between the browser's page +targets when your automation has more than one tab open, and `o` toggles +**observe-only**: every pane click, wheel event, keystroke, and navigation — +including Cmd/Ctrl+click link handoffs in attach mode — is dropped at the +pane instead of forwarded, so watching a live run cannot blur the field your +automation is typing into or dismiss the element it is waiting on. +Observe-only is a pane-side latch; nothing about the observed browser +changes when you toggle it, and pane input is guarded in agent-browser mode +too. One caveat for the runtime toggle: in agent-browser mode a Cmd+click +navigates the session daemon directly, outside the pane. Set +`HERDR_BROWSER_OBSERVE=1` (or the `observe` config file) for +watch-the-agent workspaces — the pane starts observe-only *and* the open +action itself refuses link navigation, closing that gap in both modes. Launcher recipes: Playwright `chromium.launch({args:['--remote-debugging-port=9222']})`, Puppeteer the same `args`, Browser Use its `chrome_remote_debugging_port` option. @@ -269,6 +289,37 @@ report it the way the agent-browser polling feed's timeout heuristic does. Attach mode needs Node 22 or newer (for the built-in WebSocket client); the pane says so plainly on older Node and keeps working in agent-browser mode. +## Launch a browser from the pane + +Press `l` and the pane launches a local Chromium with a loopback DevTools +port and attaches to it — no agent-browser, no configuration. This is the +zero-setup path: open the pane, press `l`, press `u`, browse. Like attach +mode, launching needs Node 22 or newer (the built-in WebSocket client); the +pane says so instead of starting a browser it could never attach to. + +The launcher looks for `HERDR_BROWSER_CHROMIUM` (or the `chromium` config +file), then probes `google-chrome`, `google-chrome-stable`, `chromium`, +`chromium-browser`, `chrome`, and the macOS Chrome/Chromium app bundles — +`google-chrome` first because Ubuntu's `chromium` is often a snap wrapper +whose confinement cannot use a profile outside `$HOME`. +The browser starts headless with a fresh ephemeral DevTools port +(`--remote-debugging-port=0`, read back from `DevToolsActivePort`) and a +per-workspace profile under the plugin state directory, so cookies and +localStorage survive relaunches. Set `HERDR_BROWSER_LAUNCH_HEADED=1` to get +a visible browser window instead. + +Unlike plain attach mode, the pane owns what it launches: quitting the pane +— or attaching to a different endpoint — kills the launched browser rather +than leaking a headless Chrome. Every attach-mode guarantee about the +*endpoint* still holds: the DevTools port binds to loopback, and the +capability token is never displayed. + +For a workspace that should always work this way, set +`HERDR_BROWSER_LAUNCH=1` (or write `1` to the `launch` config file): the +pane launches its Chromium on open, no keypress needed. A configured +`cdp-url` endpoint still wins, and the launch is attempted once — if it +fails, the banner says why and the keys take over. + ## Session model By default, each Herdr workspace uses: @@ -298,6 +349,11 @@ echo "my-agent-session" \ ## Recording +Recording captures the workspace's **agent-browser session**. Attach and +launch workspaces have no such session, so the record actions refuse with +an explanation instead of silently recording a fresh, unrelated headless +browser — record from the automation client that owns the browser instead. + Start and stop recording through the existing recording actions. Each new capture is a run-scoped observation bundle: @@ -340,6 +396,10 @@ Plugin config files contain one value on their first line: | `session` | Session name | `herdr-ws-` | Watch a different agent-browser session | | `run-id` | Valid run ID | Generated | Correlate a recording bundle with an external run | | `render` | `kitty`, `symbols`, `text` | Automatic probe | Force a rendering mode | +| `cdp-url` | `http://host:port` or `ws://…` | None | Attach to this CDP endpoint at startup | +| `chromium` | Path to a browser binary | Probed | Browser used by launch mode (`l`) | +| `launch` | `1`/`true`/`yes`/`on` | Off | Launch a Chromium on open instead of waiting for a session | +| `observe` | `1`/`true`/`yes`/`on` | Off | Start observe-only; `o` still toggles | Equivalent environment controls: @@ -348,6 +408,11 @@ Equivalent environment controls: | `HERDR_BROWSER_SESSION` | Workspace session | Override the watched session | | `HERDR_BROWSER_RUN_ID` | Config or generated ID | Select the recording run ID | | `HERDR_BROWSER_RENDER` | Automatic probe | Override the rendering mode | +| `HERDR_BROWSER_CDP_URL` | None | Attach to this CDP endpoint at startup | +| `HERDR_BROWSER_CHROMIUM` | Probed | Browser binary used by launch mode | +| `HERDR_BROWSER_LAUNCH_HEADED` | Unset | `1` launches a visible window instead of headless | +| `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 | @@ -364,6 +429,9 @@ Environment variables take precedence over config files. - WebM recordings are intentionally retained under the plugin state directory. - Browser sessions are a trusted local boundary: any local process that knows a session name can drive it, including authenticated pages. +- The `chromium`/`cdp-url` configuration files and their environment + equivalents are trusted local configuration: whoever can write them chooses + which binary launch mode executes and which endpoint the pane dials. If an agent browses sensitive or authenticated content, that content is visible in the pane and briefly present in its cached frame. Treat screen sharing and diff --git a/bin/cdp.mjs b/bin/cdp.mjs index 8b71b12..78f5487 100644 --- a/bin/cdp.mjs +++ b/bin/cdp.mjs @@ -316,7 +316,17 @@ export function makeCdpBrowser(endpointInput, opts = {}) { } catch { /* older engines: page-level feed only */ } - await startScreencast(); + try { + await startScreencast(); + } catch (err) { + // Firefox's CDP subset has no Page.startScreencast: name the reason + // instead of surfacing a raw protocol error nobody can act on. + if (/wasn't found|not found|not supported|unknown method/i.test(err?.message ?? "")) + throw new Error( + "browser has no CDP screencast (Firefox?) — attach needs a Chromium-based browser", + ); + throw err; + } }; const onCdpEvent = (m) => { diff --git a/bin/record.mjs b/bin/record.mjs index 7fe167c..9d0672d 100644 --- a/bin/record.mjs +++ b/bin/record.mjs @@ -7,7 +7,15 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const PLUGIN_ID = "structupath.browser"; -const PLUGIN_VERSION = "0.6.0"; +// Single-sourced from package.json so a release bump cannot miss it. The +// Start/Stop manifest check still compares versions strictly: a recording +// that crosses an upgrade fails closed rather than completing ambiguously. +const PLUGIN_VERSION = JSON.parse( + fs.readFileSync( + path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), + "utf8", + ), +).version; const SCHEMA_VERSION = 1; const RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; const WORKSPACE_ID_RE = /^[A-Za-z0-9_-]+$/; diff --git a/bin/renderer.mjs b/bin/renderer.mjs index 6410ea6..b2a2eb2 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -2,7 +2,7 @@ // herdr-browser pane renderer: an attached view of an agent-browser session. // It stays passive until explicit pane input, never clears the console buffer, // and closes only sessions that its own successful navigation created. -import { execFile, spawnSync } from "node:child_process"; +import { execFile, spawn, spawnSync } from "node:child_process"; import { promisify } from "node:util"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; @@ -97,6 +97,35 @@ export function consoleTail(entries, n = 8) { return entries.slice(-n).map((e) => e.text); } +// Boolean knobs accept the common spellings; anything else is off. +export function truthyConfig(value) { + return /^(1|true|yes|on)$/i.test(String(value ?? "").trim()); +} + +// Locate a launchable Chromium for launch mode (the l key). An explicit +// choice (env, then config file) is trusted as-is — it may name a binary +// that is not on PATH; probing covers the common names plus the macOS app +// bundles that never appear on PATH. +export function findChromium(env, configDirValue, probe) { + const explicit = env.HERDR_BROWSER_CHROMIUM || configDirValue; + if (explicit) return explicit; + // google-chrome first: where it exists it is nearly always a real + // binary, while "chromium" on Ubuntu is often a snap wrapper whose + // confinement cannot read a profile directory outside $HOME — it then + // never writes DevToolsActivePort and the launch times out. + const candidates = [ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "chrome", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ]; + for (const c of candidates) if (probe(c)) return c; + return null; +} + // Render-mode precedence: explicit config > kitty probe > symbols > text. // probeResponse is the raw bytes the terminal answered to a kitty graphics // query; empty/undefined means no answer (not supported). Kitty mode emits @@ -562,15 +591,20 @@ export class Renderer { // deliberate act than an ambient agent-browser session, so it wins — and // it wins deterministically at start, never by racing discovery. this.cdpEndpoint = this.resolveCdpEndpoint(env); - this.mode = this.cdpEndpoint ? "attach" : "agent-browser"; + // Backend (attach vs agent-browser) is not the render mode: run() assigns + // this.mode from pickRenderMode (kitty/symbols/text) after the terminal + // probe. Sharing one field made that assignment erase the attach decision, + // so a configured endpoint never actually attached once run() started. + this.backend = this.cdpEndpoint ? "attach" : "agent-browser"; + this.mode = null; // render mode; run() picks it after the kitty probe this.browser = - this.mode === "attach" + this.backend === "attach" ? makeCdpBrowser(this.cdpEndpoint) : makeBrowser(this.session, this.bin); // Attach mode observes a browser someone else owns: ownership is never // claimed, so the quit path can never close a stranger's session. - this.ownershipEnabled = this.mode !== "attach"; - this.backendName = this.mode === "attach" ? "browser endpoint" : "agent-browser"; + this.ownershipEnabled = this.backend !== "attach"; + this.backendName = this.backend === "attach" ? "browser endpoint" : "agent-browser"; const onPath = (cmd) => spawnSync("sh", ["-c", `command -v ${cmd}`], { timeout: 5000 }).status === 0; @@ -593,6 +627,23 @@ export class Renderer { this.banner = ""; this.attached = false; this.selfCreated = false; + // Observe-only: pane input (clicks, wheel, navigation, typing) is + // dropped instead of forwarded, so watching a live automation run + // cannot blur the field it is typing into or dismiss what it awaits. + // Configurable at start for watch-the-agent workflows; o still toggles. + // || not ??: a set-but-empty env var falls through to the config file, + // matching resolveCdpEndpoint — the knob families must agree on what + // an empty environment value means. + this.observeOnly = truthyConfig( + env.HERDR_BROWSER_OBSERVE || this.configValue("observe"), + ); + // Launch-first workspaces: the first unattached tick launches a local + // Chromium instead of waiting for an agent-browser session. One + // attempt — a failed launch banners and leaves the keys in charge. + this.launchConfigured = + !this.cdpEndpoint && + truthyConfig(env.HERDR_BROWSER_LAUNCH || this.configValue("launch")); + this.launchAttempted = false; this.promptState = null; this.paintQueue = Promise.resolve(); this.paintErrors = 0; @@ -631,6 +682,18 @@ export class Renderer { this.lastFrameAt = 0; this.staleHandled = false; this.loopbackWarned = false; + // Launch mode (see launchChromium): the one browser the pane owns the + // lifecycle of, because it spawned it. Quit must kill it — a leaked + // headless Chrome has no other owner to collect it. + this.launchedChild = null; + this.launchedEndpoint = null; + this.launchingChromium = false; + // A click that arrived while the launch was still coming up parks its + // URL here and is delivered on attach (see startNavigateWatch/attachCdp). + this.pendingNavigateUrl = null; + // A launch failure explains itself for this long before the generic + // waiting-for-session banner may paint over it (see tick). + this.bannerHoldUntil = 0; this.kittyAnon = false; // chafa emitted anonymous kitty placements this.lastImageDims = null; this.lastViewportRequest = ""; @@ -760,7 +823,7 @@ export class Renderer { // Switch this pane to attach mode at runtime. Everything the old backend // reconciled against is meaningless afterwards, so state resets and the // console carries one discontinuity line. - async attachTo(value) { + async attachTo(value, { note } = {}) { const endpoint = String(value ?? "").trim(); if (!/^(wss?|https?):\/\//i.test(endpoint)) { this.banner = @@ -768,22 +831,209 @@ export class Renderer { this.header(); return; } - if (this.mode === "agent-browser" && this.live) this.dropLive(); + if (this.backend === "agent-browser" && this.live) this.dropLive(); + // Pointing the pane away from a browser it launched abandons it; kill + // it now rather than leak a headless Chrome with no remaining owner. + if (this.launchedChild && endpoint !== this.launchedEndpoint) { + try { + this.launchedChild.kill(); + } catch { + /* already gone */ + } + this.launchedChild = null; + this.launchedEndpoint = null; + } this.stopNetworkTimer(); this.cdpEndpoint = endpoint; - this.mode = "attach"; + this.backend = "attach"; + // A session this pane created is closed on abandonment exactly as on + // quit; ownership never crosses the switch either way. + this.closeOwnSession(); this.ownershipEnabled = false; this.backendName = "browser endpoint"; - this.selfCreated = false; // never inherit ownership across a switch + this.selfCreated = false; this.browser = makeCdpBrowser(endpoint); this.attached = false; + // Static sources cannot see a runtime switch; the marker lets open.sh + // route clicks to the handoff file while this pane is alive. + try { + fs.writeFileSync( + path.join(this.stateDir, `backend-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`), + "attach\n", + { mode: 0o600 }, + ); + } catch { + /* marker is best-effort; static workspaces never need it */ + } this.cdpGuid = null; this.resetBackendState(); - this.pushConsole([{ text: "— switched to attach mode —", type: "log" }], false); + this.pushConsole( + [{ text: note ?? "— switched to attach mode —", type: "log" }], + false, + ); this.streamCooldownUntil = 0; await this.tick(); } + // Launch mode: start a local Chromium with a loopback DevTools port and + // attach to it through the normal attach path. Unlike plain attach, the + // pane owns this browser's lifecycle — it spawned it — so quit (or + // attaching elsewhere) kills it instead of leaking a headless Chrome. + // Port 0 + DevToolsActivePort avoids picking a port and racing for it: + // Chrome binds an ephemeral port and writes it to the profile root. + async launchChromium() { + if (this.launchingChromium) return; + if (this.launchedChild && this.launchedChild.exitCode === null) { + // Still running (the pane may have attached elsewhere meanwhile in + // a way that kept it): just point back at it. + if (this.launchedEndpoint) this.userAction(() => this.attachTo(this.launchedEndpoint)); + return; + } + // Refuse before spawning: attaching to the result needs the Node 22 + // WebSocket client, and a browser we can never attach to would just + // idle until quit. + if (!cdpSupported()) { + this.holdBanner("launch mode needs Node 22+ (global WebSocket) — pane is idle"); + return; + } + this.launchingChromium = true; + try { + // Probe with the renderer's own env so the pane and the launched + // child resolve binaries from the same PATH. + const bin = findChromium(this.env, this.configValue("chromium"), (c) => + spawnSync("sh", ["-c", 'command -v -- "$1"', "sh", c], { + timeout: 5000, + env: this.env, + }).status === 0, + ); + if (!bin) { + this.holdBanner( + "no Chromium found — set HERDR_BROWSER_CHROMIUM to a browser binary", + ); + return; + } + const profile = path.join( + this.stateDir, + `chromium-profile-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`, + ); + // mkdir's mode applies only on creation (and umask can relax it); + // the profile holds the launched browser's cookies and session + // state, so tighten explicitly like the constructor does stateDir. + fs.mkdirSync(profile, { recursive: true, mode: 0o700 }); + fs.chmodSync(profile, 0o700); + const portFile = path.join(profile, "DevToolsActivePort"); + try { + fs.unlinkSync(portFile); // a stale port must never win the wait below + } catch { + /* none */ + } + const headed = truthyConfig(this.env.HERDR_BROWSER_LAUNCH_HEADED); + const args = [ + "--remote-debugging-port=0", + `--user-data-dir=${profile}`, + "--no-first-run", + "--no-default-browser-check", + "--disable-background-networking", + // Plain --headless: new headless on 112+, old headless before it + // (both support DevTools and screencast); =new was removed in + // newer Chromes and would eventually break launches. + ...(headed ? [] : ["--headless"]), + // Chrome refuses to start as root without this; root (containers, + // CI) already has no user boundary for the sandbox to defend. + ...(process.getuid?.() === 0 ? ["--no-sandbox"] : []), + "about:blank", + ]; + this.banner = "launching Chromium…"; + this.header(); + let child; + // spawn reports a missing or non-executable binary as an async + // 'error' event, not a throw; without a listener that event is an + // uncaughtException that takes the whole pane down. + const spawnFailed = { err: null }; + try { + child = spawn(bin, args, { stdio: "ignore" }); + } catch (err) { + this.holdBanner( + `cannot launch ${bin}: ${sanitizeText(err?.message ?? "spawn failed")}`, + ); + return; + } + child.once("error", (err) => { + spawnFailed.err = err; + }); + const port = await this.waitForDevToolsPort(portFile, child, spawnFailed); + if (!port) { + try { + child.kill(); + } catch { + /* already dead */ + } + this.holdBanner( + spawnFailed.err + ? `cannot launch ${bin}: ${sanitizeText(spawnFailed.err.message ?? "spawn failed")}` + : `${bin} did not expose a DevTools port — is it Chromium-based?`, + ); + return; + } + this.launchedChild = child; + this.launchedEndpoint = `http://127.0.0.1:${port}`; + const ep = this.launchedEndpoint; + child.once("exit", () => { + if (this.launchedChild !== child) return; + this.launchedChild = null; + // A dead launched browser cannot be re-discovered — its port died + // with it. Retrying would dial a corpse every cooldown; say what + // actually helps instead. The launchedEndpoint comparison also + // catches a crash before the queued attach ran (cdpEndpoint not + // yet switched). Quit-path kills never get here: cleanup and + // attach-elsewhere clear launchedChild before the event fires. + if (this.cdpEndpoint === ep || this.launchedEndpoint === ep) { + this.attached = false; + this.streamCooldownUntil = Number.MAX_SAFE_INTEGER; + this.holdBanner("launched Chromium exited — press l to relaunch"); + } + }); + this.userAction(() => { + // Died between the port read and this queued attach: dialing the + // corpse would re-arm the retry loop the exit handler just + // latched. Signal deaths leave exitCode null — check both. + if (child.exitCode !== null || child.signalCode !== null) return; + return this.attachTo(ep, { note: "— launched Chromium —" }); + }); + } catch (err) { + // Callers fire-and-forget this promise; an uncaught throw here + // (say, an unwritable profile dir) would surface as an unhandled + // rejection and take the whole pane down instead of explaining. + this.holdBanner( + `launch failed: ${sanitizeText(err?.message ?? "unknown error")}`, + ); + } finally { + this.launchingChromium = false; + } + } + + // DevToolsActivePort appears in the profile root once the port is bound: + // line 1 is the port, line 2 the browser target path (a capability token + // we deliberately do not read — discovery re-derives it). + async waitForDevToolsPort(portFile, child, spawnFailed = null, timeoutMs = 15_000) { + const until = Date.now() + timeoutMs; + while (Date.now() < until) { + // Signal deaths leave exitCode null and set signalCode — check both. + if (child.exitCode !== null || child.signalCode !== null) return null; + if (spawnFailed?.err) return null; // binary missing/not executable + try { + const port = Number( + fs.readFileSync(portFile, "utf8").split("\n")[0].trim(), + ); + if (Number.isInteger(port) && port > 0 && port <= 65535) return port; + } catch { + /* not written yet */ + } + await new Promise((r) => setTimeout(r, 200)); + } + return null; + } + // Attach: connect, wire the event bridge, and take the R9 baseline. All // failures land in a banner — a bad endpoint must never crash the pane. async attachCdp() { @@ -809,6 +1059,13 @@ export class Renderer { this.cdpIdentity = id; this.attached = true; this.startNavigateWatch(); + // A click while the launch was still coming up parked its URL here; + // deliver it now that there is a browser, unless it has gone stale. + const pending = this.pendingNavigateUrl; + this.pendingNavigateUrl = null; + if (pending && Date.now() - pending.at < 120_000) { + this.userAction(() => this.browser.open(pending.url)); + } this.lastUrl = sanitizeText(id.url ?? ""); this.lastTitle = sanitizeText(id.title ?? ""); this.lastFrameAt = Date.now(); @@ -835,20 +1092,45 @@ export class Renderer { // The tick-time read stays as the fallback for platforms where fs.watch // misses events (some network filesystems). startNavigateWatch() { - if (this.mode !== "attach" || this.navigateWatcher) return; + if ( + (this.backend !== "attach" && !this.launchConfigured) || + this.navigateWatcher + ) + return; this.navigateFile = path.join( this.stateDir, `navigate-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`, ); const consume = () => { let url; + let age = 0; try { + age = Date.now() - fs.statSync(this.navigateFile).mtimeMs; url = fs.readFileSync(this.navigateFile, "utf8").split("\n")[0].trim(); fs.unlinkSync(this.navigateFile); } catch { return; // nothing pending } - if (url) this.userAction(() => this.browser.open(url)); + if (!url) return; + // A handoff can predate this pane (written before it started, or + // while its launch was broken); navigating to an hours-old click + // out of nowhere is worse than dropping it. + if (age > 300_000) return; + // A Cmd+click handoff is page-affecting input like any other; the + // URL is consumed (file already unlinked) but not forwarded. + if (this.observeOnly) { + this.noteObserveBlocked(); + return; + } + // Launch workspace with no browser up: the click is an explicit + // ask — (re)launch and navigate once attached. + if (!this.attached && this.launchConfigured) { + this.pendingNavigateUrl = { url, at: Date.now() }; + this.launchAttempted = true; + this.launchChromium(); + return; + } + this.userAction(() => this.browser.open(url)); }; this.consumeNavigateFile = consume; try { @@ -949,7 +1231,7 @@ export class Renderer { // Hidden tabs and DevTools screencast contention both present as a frozen // frame with no error. One restart attempt, last frame stays on screen. checkFrameStaleness(now = Date.now()) { - if (this.mode !== "attach" || !this.attached || !this.lastFrameAt) return; + if (this.backend !== "attach" || !this.attached || !this.lastFrameAt) return; if (now - this.lastFrameAt < 10_000 || this.staleHandled) return; this.staleHandled = true; this.banner = "frame stale (tab hidden or contended)"; @@ -1025,8 +1307,16 @@ export class Renderer { this.lastHeaderSig = null; // force a repaint once we fit again return; } + // Attach mode shows the endpoint (host:port only — the path is a + // capability token); agent-browser mode shows the session name that + // the quick-start instructions tell the user to copy from here. + const source = + this.backend === "attach" + ? `attach:${redactWsUrl(this.cdpEndpoint)}` + : `session:${this.session}`; + const observe = this.observeOnly ? " observe-only" : ""; const line1 = truncate( - ` herdr-browser session:${this.session} mode:${this.mode}`, + ` herdr-browser ${source} mode:${this.mode ?? "-"}${observe}`, cols, ); const blankHint = @@ -1055,8 +1345,13 @@ export class Renderer { `${ESC}[${bottomRow};1H${truncate(text, cols)}${ESC}[K`, ); } else { - const help = - " u:url click:page i:type b/f:back-fwd r:reload j/k:scroll q:quit"; + // Every advertised key is handled in both backends (a and l work + // everywhere); t only moves targets on an attach backend. + const help = this.observeOnly + ? " observe-only: input is not forwarded o:enable-input q:quit" + : this.backend === "attach" + ? " u:url a:attach l:launch i:type b/f:hist r:reload j/k:scroll t:target o:observe q:quit" + : " u:url a:attach l:launch i:type b/f:hist r:reload j/k:scroll o:observe q:quit"; process.stdout.write( `${ESC}[${bottomRow};1H${ESC}[2m${truncate(help, cols)}${ESC}[K${ESC}[0m`, ); @@ -1185,7 +1480,7 @@ export class Renderer { // Attach mode is event-driven: the tick only (re)connects, watches // liveness, and notices a stalled screencast. Frames and console // entries arrive over the CDP session, not from polling. - if (this.mode === "attach") { + if (this.backend === "attach") { if (!this.attached) { if (Date.now() < this.streamCooldownUntil) return; this.streamCooldownUntil = Date.now() + 5_000; @@ -1216,12 +1511,31 @@ export class Renderer { // exists — created by an agent, a link click, or a URL-bearing open — // only run the non-creating existence check and wait. if (!this.attached) { + // A launch-first workspace starts its own browser instead of + // waiting. Once, from the tick so run()'s terminal setup is done; + // on failure the banner stands and the keys are back in charge. + if (this.launchConfigured && !this.launchAttempted) { + this.launchAttempted = true; + this.startNavigateWatch(); // clicks must work even mid-launch + this.launchChromium(); + return; + } + // An in-flight launch (config or the l key) owns the banner; the + // waiting-for-session advice below would overwrite it mid-wait. + if (this.launchingChromium) return; if (!(await this.browser.sessionExists())) { + // A held banner names a real failure (launch mode); repainting + // generic waiting advice over it would erase the explanation. + if (Date.now() < this.bannerHoldUntil) return; // A missing binary is not 'session not started yet' — the waiting // advice below can never fix it, so say what's actually wrong. - this.banner = this.agentBrowser - ? `waiting for session "${this.session}" — Cmd+click a localhost link or have your agent use --session ${this.session}` - : "agent-browser is not installed — npm install -g agent-browser && agent-browser install"; + // In a launch workspace the accurate advice is to retry the + // launch — a click also retries it (see startNavigateWatch). + this.banner = this.launchConfigured + ? "Chromium launch failed — press l or Cmd+click a localhost link to retry" + : this.agentBrowser + ? `waiting for session "${this.session}" — Cmd+click a localhost link or have your agent use --session ${this.session}` + : "agent-browser is not installed — press l to launch a local Chromium, or: npm install -g agent-browser"; this.header(); return; } @@ -1416,6 +1730,10 @@ export class Renderer { onMouse(mouse) { if (!mouse || mouse.release) return; + if (this.observeOnly && this.attached) { + this.noteObserveBlocked(); + return; + } // Wheel reports (64=up, 65=down) scroll the page; presses only. if (mouse.button === 64 || mouse.button === 65) { if (this.attached) { @@ -1429,6 +1747,49 @@ export class Renderer { this.userAction(() => this.clickAt(mouse.col, mouse.row)); } + // A banner that names a real failure must outlive the next poll tick, or + // the generic waiting-for-session advice paints over it within a second. + holdBanner(text, ms = 30_000) { + this.banner = text; + this.bannerHoldUntil = Date.now() + ms; + this.header(); + } + + // A session that exists only because the user navigated in this pane is + // closed (daemon included) when the pane is done with it — on quit and + // on switching backends alike — instead of idling as a leaked daemon + // until the 30-minute reaper. Sessions others created are never touched. + // Short timeout: a wedged daemon must not freeze the caller — its own + // idle reaper collects the session anyway. + closeOwnSession() { + if (!this.selfCreated || !this.ownershipEnabled) return; + try { + spawnSync(this.bin, ["--session", this.session, "close"], { + timeout: 2_000, + }); + } catch { + /* already gone */ + } + this.selfCreated = false; + } + + // Observe-only is a pane-side latch, deliberately not a backend call: + // nothing about the observed browser changes, input simply stops here. + toggleObserveOnly() { + this.observeOnly = !this.observeOnly; + if (!this.observeOnly && this.banner.startsWith("observe-only")) + this.banner = ""; + this.lastHeaderSig = null; // the observe marker lives in line 1 + this.header(); + this.renderBottom(); + } + + noteObserveBlocked() { + if (this.banner.startsWith("observe-only")) return; + this.banner = "observe-only — input is not forwarded (o re-enables)"; + this.header(); + } + onKey(ch) { // A prompt owns the keyboard; clicks while typing must not drive the // page behind the prompt. @@ -1436,7 +1797,21 @@ export class Renderer { this.promptInput(ch); return; } - if (!this.attached && !["u", "q", "\x03"].includes(ch)) return; + // The toggle itself must stay reachable while observe-only is on. + if (ch === "o") { + this.toggleObserveOnly(); + return; + } + if ( + this.observeOnly && + ["u", "i", "b", "f", "r", "j", "k", " "].includes(ch) + ) { + this.noteObserveBlocked(); + return; + } + // a and l must stay reachable while unattached — no session yet and a + // dead endpoint are exactly when attaching or launching is the answer. + if (!this.attached && !["u", "a", "l", "q", "\x03"].includes(ch)) return; switch (ch) { case "u": this.openPrompt("URL: ", (v) => this.navigate(v)); @@ -1447,6 +1822,27 @@ export class Renderer { case "a": this.openPrompt("attach to endpoint: ", (v) => this.attachTo(v)); break; + // Deliberately unqueued: the DevTools-port wait can take seconds and + // must not stall the paint queue; only the final attach is enqueued. + case "l": + this.launchChromium(); + break; + // Cycle the pinned page target (attach mode, R6). View-only motion: + // it moves the pane's screencast, never focus or page state, so it + // stays allowed under observe-only. + case "t": + if (this.backend !== "attach") break; + this.userAction(async () => { + const moved = await this.browser.cycleTarget?.(); + if (!moved) { + this.banner = "no other page targets"; + this.header(); + } else if (this.banner === "no other page targets") { + this.banner = ""; + this.header(); + } + }); + break; case "i": this.openPrompt("type: ", (v) => this.browser.type(v)); break; @@ -1750,7 +2146,6 @@ export class Renderer { // poll is in flight, the busy guard skips this read, and clearing // the flag here would let the next poll replay the whole log. this.networkBaselinePending = true; - this.networkBaselinePending = false; this.attached = true; // pollNetwork requires it; the session exists await this.pollNetwork(true); } else { @@ -1782,7 +2177,7 @@ export class Renderer { if (!pt) return; // Attach-mode frames are scaled by maxWidth and the observed browser's // DPR, so frame pixels are not page pixels — rescale before dispatch. - const target = this.mode === "attach" ? this.cdpPagePoint(pt, dims) : pt; + const target = this.backend === "attach" ? this.cdpPagePoint(pt, dims) : pt; await this.browser.click(target.x, target.y); } @@ -1804,28 +2199,34 @@ export class Renderer { /* already closed */ } this.live = null; - if (this.mode === "attach") { - // Stop our screencast and drop the socket. Never a target close, - // never an agent-browser subprocess — we did not create any of this. + // The runtime-backend marker routes clicks only while this pane lives. + try { + fs.unlinkSync( + path.join(this.stateDir, `backend-${safeWsId(this.env.HERDR_WORKSPACE_ID)}`), + ); + } catch { + /* never written */ + } + if (this.launchedChild) { + // The pane spawned this browser; quitting must not leak it. SIGTERM + // lets Chrome flush its profile — its own exit handles the rest. try { - this.browser.close?.(); + this.launchedChild.kill(); } catch { - /* endpoint already gone */ + /* already gone */ } + this.launchedChild = null; } - if (this.selfCreated && this.ownershipEnabled) { - // The session exists only because the user navigated in this pane; - // quitting the pane ends it (and its daemon) instead of leaking it. - // Short timeout: a wedged daemon must not freeze the quit path — its - // own idle reaper collects the session anyway. + if (this.backend === "attach") { + // Stop our screencast and drop the socket. Never a target close, + // never an agent-browser subprocess — we did not create any of this. try { - spawnSync(this.bin, ["--session", this.session, "close"], { - timeout: 2_000, - }); + this.browser.close?.(); } catch { - /* already gone */ + /* endpoint already gone */ } } + this.closeOwnSession(); for (const f of [ this.shot, this.shot + ".tmp", diff --git a/docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md b/docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md new file mode 100644 index 0000000..c4268a9 --- /dev/null +++ b/docs/plans/2026-08-23-001-feat-launch-mode-observe-only-plan.md @@ -0,0 +1,80 @@ +--- +title: "feat: Chromium launch mode, observe-only input, and the backend/render-mode split" +type: feat +date: 2026-08-23 +--- + +# feat: Chromium launch mode, observe-only input, and the backend/render-mode split + +## Summary + +Three changes that close the gap between the pane's design and what actually +ran, then remove its last external dependency. First, the backend decision +(attach vs agent-browser) moves out of `this.mode`, which `run()` was +overwriting with the render mode — the configured-endpoint attach path was +unreachable in the real binary. Second, two follow-ups deferred by the CDP +attach plan land: the `t` key drives the already-implemented `cycleTarget()`, +and `o` toggles observe-only, dropping all pane input at the pane so watching +a live automation run cannot perturb it. Third, launch mode: `l` starts a +local Chromium the pane owns and attaches to it, making the pane usable with +zero pre-existing engines. + +## Requirements + +**Backend split** + +- B1. `this.backend` carries `attach`/`agent-browser`; `this.mode` is + render-only (`kitty`/`symbols`/`text`, `null` until `run()` probes). Every + former backend check on `this.mode` reads `this.backend`. +- B2. The header shows `attach:` (never the capability-token path) + while attached, and the session name otherwise — the quick start tells + users to copy it from there. +- B3. A regression test exercises `tick()` *after* a render-mode assignment, + the combination the old tests never covered. + +**Observe-only (o)** + +- O1. While on, clicks, wheel events, page-affecting keys (`u i b f r j k` + space), and Cmd+click navigate handoffs are dropped at the pane; a banner + says why and the help line shows the state. Nothing is sent to the + observed browser when toggling — it is a pane-side latch. +- O2. The toggle itself, pane-view keys (`t`), `a`, `l`, and `q` stay + reachable. Works in both backends. + +**Target cycling (t)** + +- T1. `t` calls the attach backend's `cycleTarget()`; a single-target + browser reports "no other page targets" instead of doing nothing. + +**Launch mode (l)** + +- L1. `l` finds a browser (`HERDR_BROWSER_CHROMIUM` env / `chromium` config + first, then PATH names and macOS app bundles), spawns it with + `--remote-debugging-port=0` and a per-workspace profile under plugin + state, reads the bound port from `DevToolsActivePort`, and attaches + through the normal attach path. Headless by default; + `HERDR_BROWSER_LAUNCH_HEADED=1` for a window; `--no-sandbox` only as root. +- L2. Ownership is the deliberate difference from plain attach: quit or + attaching elsewhere kills the launched browser — never leak a headless + Chrome. Refuse before spawning on Node < 22. +- L3. The unattached key gate admits `a` and `l` (it swallowed `a`, making + the documented attach key unreachable exactly when it was the answer). +- L4. A real-browser integration test (skipped where no Chromium or Node < + 22) drives launch → attach → navigate → frame → console feed → child kill. + +## Also fixed en route + +- Config-first knobs landed with the wave: `HERDR_BROWSER_OBSERVE`/`observe` + starts the pane observe-only, `HERDR_BROWSER_LAUNCH`/`launch` launches on + the first unattached tick (a configured `cdp-url` still wins). CI evidence + reordered the probe list — Ubuntu's `chromium` is often a snap wrapper + that cannot read a profile outside `$HOME`, so `google-chrome` is tried + first. A crashed launched browser banners "press l to relaunch" instead + of redialing its dead port. +- The 294cade merge kept both sides of the 34d1559 fix in `navigate()` + (baseline flag cleared immediately) and both drafts of the README's + failure-feed paragraph; both restored to the fixed versions. +- CI's Node setup step illegally combined `uses` with `run`, so no CI step + had ever executed; the workflow now runs tests on a Node 20/22 matrix. +- Firefox endpoints fail `Page.startScreencast` with a named banner instead + of a raw protocol error (deferred item from the attach plan). diff --git a/herdr-plugin.toml b/herdr-plugin.toml index a6b30ec..9b38136 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,8 +1,8 @@ id = "structupath.browser" name = "Browser" -version = "0.6.0" +version = "0.7.0" min_herdr_version = "0.7.0" -description = "Driveable browser pane: live screenshots, console output, and localhost link handling via agent-browser" +description = "Driveable browser pane: live screenshots, console output, localhost links; drives agent-browser sessions, attaches to any CDP browser, or launches its own Chromium" platforms = ["macos", "linux"] [[actions]] diff --git a/package.json b/package.json index fdd1261..4291b57 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "herdr-browser", - "version": "0.6.0", + "version": "0.7.0", "private": true, "type": "module", "engines": { "node": ">=20" }, diff --git a/scripts/lib.sh b/scripts/lib.sh index d41396c..181b734 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -93,6 +93,55 @@ session_name() { printf 'herdr-cwd-%s\n' "$(printf '%s\n' "$PWD" | cksum | cut -d' ' -f1)" } +# Static backend arbitration, shared with the renderer's constructor: these +# read the same sources so launchers and pane reach the same verdict without +# a runtime marker. Must stay in lockstep with resolveCdpEndpoint() and the +# launch/observe knobs in bin/renderer.mjs. +cdp_endpoint_configured() { + # Silent predicate. Whitespace-only values are treated as unset, and a + # set-but-blank env var does NOT fall through to the config file — both + # exactly as resolveCdpEndpoint() decides. + local v="" + if [ -n "${HERDR_BROWSER_CDP_URL:-}" ]; then + v="$(printf '%s' "$HERDR_BROWSER_CDP_URL" | tr -d '[:space:][:cntrl:]')" + elif [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" ]; then + v="$(head -n1 "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" | tr -d '[:space:][:cntrl:]')" + fi + [ -n "$v" ] +} + +# A pane that switched to attach/launch mode at runtime (the a or l key) +# leaves this marker; static sources cannot see that switch. Trust it only +# alongside a live pane — a crashed pane's stale marker must not swallow +# clicks into a handoff file nobody consumes. +backend_marker_file() { + printf '%s/backend-%s\n' "$(state_dir)" "$(ws_id)" +} + +# Boolean knob files/envs: same accepted spellings as truthyConfig() in the +# renderer. A set-but-non-truthy env wins over the config file (|| semantics). +truthy_config() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" in + 1 | true | yes | on) return 0 ;; + *) return 1 ;; + esac +} + +knob_configured() { # $1: env value (may be empty), $2: config file name + if [ -n "$1" ]; then + truthy_config "$1" + return $? + fi + if [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/$2" ]; then + truthy_config "$(head -n1 "${HERDR_PLUGIN_CONFIG_DIR}/$2")" + return $? + fi + return 1 +} + +launch_configured() { knob_configured "${HERDR_BROWSER_LAUNCH:-}" launch; } +observe_configured() { knob_configured "${HERDR_BROWSER_OBSERVE:-}" observe; } + require_agent_browser() { if ! command -v agent-browser >/dev/null 2>&1; then echo "herdr-browser: agent-browser CLI not found." >&2 diff --git a/scripts/open.sh b/scripts/open.sh index 73597e0..397f274 100755 --- a/scripts/open.sh +++ b/scripts/open.sh @@ -6,35 +6,51 @@ set -uo pipefail cd "${HERDR_PLUGIN_ROOT:-$(dirname "$0")/..}" || exit 1 . scripts/lib.sh -require_agent_browser require_herdr url="${1:-${HERDR_PLUGIN_CLICKED_URL:-}}" session="$(session_name)" -# Attach mode is decided from the same static sources the renderer reads, not -# from a runtime marker: on the first click after configuring an endpoint no -# pane has ever run, and taking the agent-browser path there would spawn -# exactly the invisible session attach mode promises never to create. -cdp_endpoint="" -if [ -n "${HERDR_BROWSER_CDP_URL:-}" ]; then - cdp_endpoint="$HERDR_BROWSER_CDP_URL" -elif [ -n "${HERDR_PLUGIN_CONFIG_DIR:-}" ] && [ -f "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" ]; then - cdp_endpoint="$(head -n1 "${HERDR_PLUGIN_CONFIG_DIR}/cdp-url" | tr -d '[:space:][:cntrl:]')" +# Backend arbitration from the same static sources the renderer reads, not a +# runtime marker: on the first click after configuring an endpoint (or a +# launch-first workspace) no pane has ever run, and taking the agent-browser +# path there would spawn exactly the invisible session those modes promise +# never to create. In both pane-owned modes agent-browser itself is not +# required — the requirement lives on the branch that actually invokes it. +pane_owns_navigation=0 +if cdp_endpoint_configured || launch_configured; then + pane_owns_navigation=1 +fi +# Runtime switches (the a/l keys) are invisible to static sources; the pane +# leaves a marker while attached. Only a live pane's marker counts. +if [ "$pane_owns_navigation" -eq 0 ] && [ -f "$(backend_marker_file)" ]; then + marker_pane="$(cat "$(pane_id_file)" 2>/dev/null || true)" + if pane_alive "$marker_pane"; then + pane_owns_navigation=1 + fi +fi + +# A workspace configured observe-only exists to watch a run untouched; a +# link click must not navigate the observed session. (The runtime o toggle +# is pane state — the pane drops its own handoffs itself.) +if [ -n "$url" ] && observe_configured; then + echo "herdr-browser: observe-only workspace — not navigating (unset HERDR_BROWSER_OBSERVE / the observe config to navigate)" >&2 + url="" fi -if [ -n "$url" ] && [ -n "$cdp_endpoint" ]; then +if [ -n "$url" ] && [ "$pane_owns_navigation" -eq 1 ]; then if ! validate_url "$url"; then echo "herdr-browser: refusing URL (must start with http:// or https://, no credentials): $url" >&2 exit 2 fi - # Attach mode: hand the URL to the pane, which navigates the attached + # Hand the URL to the pane, which navigates its attached or launched # target. The renderer watches this file, so pickup does not wait for a # backed-off poll tick. handoff="$(state_dir)/navigate-$(ws_id)" umask 077 printf '%s\n' "$url" > "$handoff" elif [ -n "$url" ]; then + require_agent_browser if ! validate_url "$url"; then echo "herdr-browser: refusing URL (must start with http:// or https://, no credentials): $url" >&2 exit 2 diff --git a/scripts/record.sh b/scripts/record.sh index fb4b840..7558cfc 100755 --- a/scripts/record.sh +++ b/scripts/record.sh @@ -14,6 +14,15 @@ start | stop) ;; ;; esac +# Recording captures the workspace's agent-browser session. A workspace +# configured for CDP attach mode or launch mode has no such session — +# starting one here would record a fresh, unrelated headless browser, not +# the browser the pane shows. +if cdp_endpoint_configured || launch_configured; then + echo "herdr-browser: recording captures agent-browser sessions, but this workspace is configured for attach/launch mode. Record from the automation client that owns the browser, or remove the cdp-url/launch configuration to record an agent-browser session." >&2 + exit 1 +fi + require_agent_browser if ! command -v node >/dev/null 2>&1; then echo "herdr-browser: node is required." >&2 diff --git a/tests/launch.integration.test.mjs b/tests/launch.integration.test.mjs new file mode 100644 index 0000000..e282908 --- /dev/null +++ b/tests/launch.integration.test.mjs @@ -0,0 +1,103 @@ +// Real-browser integration: launch mode end to end against an installed +// Chromium. Skips where the run cannot work — no Chromium on the machine, or +// no WebSocket client (Node < 22) — so the suite stays green everywhere while +// CI with a browser exercises the true path: spawn, DevToolsActivePort, +// attach, navigate, screencast frame, console feed, child kill. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Renderer, findChromium } from "../bin/renderer.mjs"; + +const probe = (c) => + spawnSync("sh", ["-c", 'command -v -- "$1"', "sh", c], { timeout: 5000 }) + .status === 0; +const chromium = findChromium(process.env, undefined, probe); +const skip = + typeof WebSocket !== "function" + ? "needs Node 22+ (global WebSocket)" + : !chromium + ? "no Chromium installed" + : false; + +const until = async (cond, ms) => { + const end = Date.now() + ms; + while (Date.now() < end) { + if (cond()) return true; + await new Promise((r) => setTimeout(r, 200)); + } + return cond(); +}; + +test("launch mode drives a real Chromium end to end", { skip }, async () => { + const r = new Renderer({ + HERDR_BROWSER_SESSION: "hb-launch-int", + HERDR_PLUGIN_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "hb-int-")), + HERDR_BROWSER_CHROMIUM: chromium, + HOME: os.homedir(), + PATH: process.env.PATH, + }); + r.header = () => {}; + r.renderConsole = () => {}; + r.renderBottom = () => {}; + r.renderImage = async () => {}; + r.mode = "symbols"; // what run() would have picked; the backend must survive it + + let frames = 0; + const orig = r.onCdpMessage.bind(r); + r.onCdpMessage = (m) => { + if (m.type === "frame") frames++; + orig(m); + }; + + let pid; + try { + await r.launchChromium(); + assert.ok( + await until(() => r.attached, 30_000), + `pane should attach to the launched browser (banner: ${r.banner})`, + ); + pid = r.launchedChild?.pid; + assert.ok(pid, "the pane records the child it owns"); + assert.equal(r.backend, "attach"); + assert.equal(r.mode, "symbols", "render mode survives the launch"); + + await r.browser.open("data:text/html,hb-intok"); + assert.ok( + await until(() => /hb-int|data:text\/html/.test(r.lastUrl), 10_000), + `navigation should surface in the header state (url: ${r.lastUrl})`, + ); + assert.ok( + await until(() => frames > 0, 10_000), + "at least one screencast frame arrives", + ); + + await r.browser.open( + "data:text/html,", + ); + assert.ok( + await until( + () => r.consoleLines.some((l) => l.includes("hb-int-boom")), + 10_000, + ), + "page console output reaches the pane feed", + ); + } finally { + r.cleanup(); + } + if (pid) { + assert.ok( + await until(() => { + try { + process.kill(pid, 0); + return false; + } catch { + return true; + } + }, 5_000), + "quit must kill the browser the pane launched", + ); + } +}); diff --git a/tests/launchers.test.mjs b/tests/launchers.test.mjs index 33060f8..0b7ac48 100644 --- a/tests/launchers.test.mjs +++ b/tests/launchers.test.mjs @@ -580,3 +580,113 @@ test("a live holder is never stolen from, even after the wait budget", { ); assert.equal(fs.existsSync(lock), false, "lock released after holder exit"); }); + +test("record refuses in attach mode instead of recording an unrelated browser", () => { + const env = freshEnv({ HERDR_BROWSER_CDP_URL: "http://127.0.0.1:9222" }); + const r = runScript("record.sh", ["start"], env); + assert.notEqual(r.status, 0); + assert.match(r.stderr, /attach\/launch mode/); + // The config-file source must reach the same verdict as the env var. + const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hb-rec-cfg-")); + fs.writeFileSync(path.join(cfg, "cdp-url"), "http://127.0.0.1:9222\n"); + const r2 = runScript( + "record.sh", + ["start"], + freshEnv({ HERDR_PLUGIN_CONFIG_DIR: cfg }), + ); + assert.notEqual(r2.status, 0); + assert.match(r2.stderr, /attach\/launch mode/); + // Launch-first workspaces have no agent-browser session either. + const r3 = runScript( + "record.sh", + ["start"], + freshEnv({ HERDR_BROWSER_LAUNCH: "1" }), + ); + assert.notEqual(r3.status, 0); + assert.match(r3.stderr, /attach\/launch mode/); +}); + +test("open in a launch-first workspace hands the URL to the pane, never agent-browser", () => { + const r = runScript( + "open.sh", + ["http://localhost:3000/app"], + freshEnv({ HERDR_BROWSER_LAUNCH: "1" }), + ); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(log(), /agent-browser --session/); + assert.equal( + fs.readFileSync(path.join(stateDir, "navigate-w9"), "utf8").trim(), + "http://localhost:3000/app", + ); + fs.rmSync(path.join(stateDir, "navigate-w9"), { force: true }); +}); + +test("open in attach/launch mode works without agent-browser installed", () => { + // Strip the agent-browser stub from PATH; herdr and core tools remain. + const noAb = fs.mkdtempSync(path.join(os.tmpdir(), "hb-noab-")); + for (const t of ["herdr"]) { + fs.copyFileSync(path.join(stubDir, t), path.join(noAb, t)); + fs.chmodSync(path.join(noAb, t), 0o755); + } + const env = freshEnv({ + HERDR_BROWSER_LAUNCH: "1", + PATH: `${noAb}:${path.dirname(process.execPath)}:/usr/bin:/bin`, + HERDR_BIN_PATH: path.join(noAb, "herdr"), + }); + const r = runScript("open.sh", ["http://localhost:3000"], env); + assert.equal(r.status, 0, r.stderr); + assert.match(log(), /plugin pane open/); + fs.rmSync(path.join(stateDir, "navigate-w9"), { force: true }); +}); + +test("open in an observe-only workspace refuses navigation but still opens the pane", () => { + const r = runScript( + "open.sh", + ["http://localhost:3000"], + freshEnv({ HERDR_BROWSER_OBSERVE: "1" }), + ); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stderr, /observe-only workspace/); + assert.doesNotMatch(log(), /agent-browser --session .* open/); + assert.ok(!fs.existsSync(path.join(stateDir, "navigate-w9"))); + assert.match(log(), /plugin pane open/); +}); + +test("a live runtime-attach pane's marker routes clicks to the handoff file", () => { + fs.writeFileSync(path.join(stateDir, "pane-id-w9"), "w9:p7\n"); + fs.writeFileSync(path.join(stateDir, "backend-w9"), "attach\n"); + const r = runScript( + "open.sh", + ["http://localhost:5000/x"], + freshEnv({ STUB_PANE_ALIVE: "0" }), + ); + assert.equal(r.status, 0, r.stderr); + assert.doesNotMatch(log(), /agent-browser --session .* open/); + assert.equal( + fs.readFileSync(path.join(stateDir, "navigate-w9"), "utf8").trim(), + "http://localhost:5000/x", + ); + fs.rmSync(path.join(stateDir, "navigate-w9"), { force: true }); + fs.rmSync(path.join(stateDir, "backend-w9"), { force: true }); + fs.rmSync(path.join(stateDir, "pane-id-w9"), { force: true }); +}); + +test("a dead pane's stale backend marker is ignored", () => { + fs.writeFileSync(path.join(stateDir, "backend-w9"), "attach\n"); + fs.rmSync(path.join(stateDir, "pane-id-w9"), { force: true }); + const r = runScript("open.sh", ["http://localhost:5001/y"]); + assert.equal(r.status, 0, r.stderr); + assert.match(log(), /agent-browser --session herdr-ws-w9 open/); + fs.rmSync(path.join(stateDir, "backend-w9"), { force: true }); +}); + +test("whitespace-only HERDR_BROWSER_CDP_URL is not attach mode (renderer parity)", () => { + const r = runScript( + "open.sh", + ["http://localhost:5002/z"], + freshEnv({ HERDR_BROWSER_CDP_URL: " " }), + ); + assert.equal(r.status, 0, r.stderr); + assert.match(log(), /agent-browser --session herdr-ws-w9 open/); + assert.ok(!fs.existsSync(path.join(stateDir, "navigate-w9"))); +}); diff --git a/tests/manifest.test.mjs b/tests/manifest.test.mjs index d26611a..70dd475 100644 --- a/tests/manifest.test.mjs +++ b/tests/manifest.test.mjs @@ -57,8 +57,8 @@ test("release version and existing action IDs remain stable", () => { path.join(root, "herdr-plugin.toml"), "utf8", ); - assert.equal(packageJson.version, "0.6.0"); - assert.match(manifest, /^version = "0\.6\.0"$/m); + assert.equal(packageJson.version, "0.7.0"); + assert.match(manifest, /^version = "0\.7\.0"$/m); assert.deepEqual( [...manifest.matchAll(/^id = "([^"]+)"$/gm)] .slice(1, 6) diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 5cf9f3f..631d53e 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -27,6 +27,8 @@ import { newNetworkState, diffNetworkFailures, formatNetworkFailure, + findChromium, + truthyConfig, } from "../bin/renderer.mjs"; const repoRoot = path.resolve( @@ -58,6 +60,10 @@ const mkRenderer = (over = {}) => HERDR_BROWSER_SESSION: "hb-test", HERDR_PLUGIN_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "hb-r-")), HOME: os.homedir(), + // Dead by default: with PATH unset entirely, sh falls back to the + // system default path and the launch-mode probe can find and start a + // REAL browser on CI runners — whose open handles then hang the run. + PATH: "/nonexistent", ...over, }); // Silence painting; keep state transitions observable. @@ -71,6 +77,16 @@ const quiet = (r) => { const flush = async () => { for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r)); }; +// attachCdp (and launchChromium) refuse on Node < 22 before touching the +// backend, so attach-behavior tests only make sense where attach is possible. +const canCdp = typeof WebSocket === "function"; +// Poll until cond() or the timeout; launch tests wait on real child I/O. +const until = async (cond, ms = 5_000) => { + const end = Date.now() + ms; + while (Date.now() < end && !cond()) + await new Promise((res) => setTimeout(res, 100)); + return cond(); +}; test("reconcile: first poll returns everything", () => { const r = reconcileConsole({ count: 0, tail: [] }, e(["a", "b"])); @@ -1973,7 +1989,7 @@ const fakeCdpBackend = (over = {}) => { test("attach mode: CDP endpoint wins over agent-browser and disables owning paths", () => { const r = attachRenderer(); - assert.equal(r.mode, "attach"); + assert.equal(r.backend, "attach"); assert.equal(r.ownershipEnabled, false); assert.equal(r.backendName, "browser endpoint"); // The duck-type omissions are the contract: no viewport fitting, no @@ -1982,11 +1998,11 @@ test("attach mode: CDP endpoint wins over agent-browser and disables owning path assert.equal(typeof r.browser.network, "undefined"); assert.equal(typeof r.browser.streamEnable, "undefined"); const plain = mkRenderer(); - assert.equal(plain.mode, "agent-browser"); + assert.equal(plain.backend, "agent-browser"); assert.equal(plain.ownershipEnabled, true); }); -test("attach mode: tick connects, then only watches liveness", async () => { +test("attach mode: tick connects, then only watches liveness", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); await r.tick(); @@ -1997,7 +2013,7 @@ test("attach mode: tick connects, then only watches liveness", async () => { assert.deepEqual(r.browser.calls, ["connect"], "no polling while attached"); }); -test("attach mode: frames paint and ack with the integer id after the paint settles", async () => { +test("attach mode: frames paint and ack with the integer id after the paint settles", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); let renders = 0; @@ -2033,7 +2049,7 @@ test("attach mode: clicks scale from frame pixels to page pixels per frame", asy }); }); -test("attach mode: dead endpoint detaches; raw ws endpoints do not retry", async () => { +test("attach mode: dead endpoint detaches; raw ws endpoints do not retry", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend({ alive: false }); await r.tick(); @@ -2051,7 +2067,7 @@ test("attach mode: dead endpoint detaches; raw ws endpoints do not retry", async assert.equal(raw.streamCooldownUntil, Number.MAX_SAFE_INTEGER, "no retry loop"); }); -test("attach mode: reattach to a different browser resets state with a marker", async () => { +test("attach mode: reattach to a different browser resets state with a marker", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); await r.tick(); @@ -2068,7 +2084,7 @@ test("attach mode: reattach to a different browser resets state with a marker", assert.equal(r.lastHash, "", "frame state reset"); }); -test("attach mode: stale frames banner once and trigger one restart", async () => { +test("attach mode: stale frames banner once and trigger one restart", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); await r.tick(); @@ -2109,7 +2125,7 @@ test("attach mode: Node without global WebSocket banners instead of crashing", a } }); -test("attach mode: endpoint tokens never reach the banner", async () => { +test("attach mode: endpoint tokens never reach the banner", { skip: !canCdp }, async () => { const r = attachRenderer({ HERDR_BROWSER_CDP_URL: "ws://127.0.0.1:9222/devtools/browser/SECRET-TOKEN", }); @@ -2148,7 +2164,7 @@ test("attach mode: config-dir cdp-url is a valid endpoint source", () => { const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hb-cfg-cdp-")); fs.writeFileSync(path.join(cfg, "cdp-url"), "http://127.0.0.1:9333\n"); const r = mkRenderer({ HERDR_PLUGIN_CONFIG_DIR: cfg }); - assert.equal(r.mode, "attach"); + assert.equal(r.backend, "attach"); assert.equal(r.cdpEndpoint, "http://127.0.0.1:9333"); // Env wins over the file. const r2 = mkRenderer({ @@ -2162,7 +2178,7 @@ test("attach prompt refuses navigation-shaped input and keeps u for URLs", async const r = quiet(mkRenderer()); await r.attachTo("localhost:9222"); assert.match(r.banner, /not an endpoint/); - assert.equal(r.mode, "agent-browser", "bad input must not switch modes"); + assert.equal(r.backend, "agent-browser", "bad input must not switch modes"); }); test("attach switch resets reconciliation state and drops ownership", async () => { @@ -2172,7 +2188,7 @@ test("attach switch resets reconciliation state and drops ownership", async () = r.lastHash = "deadbeef"; r.browser = { ...r.browser, sessionExists: async () => false }; await r.attachTo("http://127.0.0.1:9222"); - assert.equal(r.mode, "attach"); + assert.equal(r.backend, "attach"); assert.equal(r.ownershipEnabled, false); assert.equal(r.selfCreated, false, "ownership never survives a backend switch"); assert.deepEqual(r.consoleState, { count: 0, tail: [] }); @@ -2180,7 +2196,7 @@ test("attach switch resets reconciliation state and drops ownership", async () = assert.ok(r.consoleLines.some((l) => /switched to attach mode/.test(l))); }); -test("attach mode: a Cmd+click handoff file navigates the attached target", async () => { +test("attach mode: a Cmd+click handoff file navigates the attached target", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); r.browser.open = async (u) => r.browser.calls.push(`open:${u}`); @@ -2196,3 +2212,450 @@ test("attach mode: a Cmd+click handoff file navigates the attached target", asyn "click navigates the attached target, not an agent-browser session", ); }); + +// --- Wave 5: backend/render-mode split, observe-only, target cycling --- + +test("backend split: render-mode pick does not erase a configured attach backend", { skip: !canCdp }, async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + // run() assigns the render mode after the kitty probe; the attach decision + // must survive it or a configured endpoint never attaches in the real pane. + r.mode = "symbols"; + await r.tick(); + assert.equal(r.backend, "attach"); + assert.ok(r.browser.calls.includes("connect"), "tick still takes the attach path"); + assert.equal(r.mode, "symbols", "render mode is untouched by attaching"); +}); + +test("backend split: runtime attach switch keeps the render mode", async () => { + const r = quiet(mkRenderer()); + r.mode = "kitty"; + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.attachTo("http://127.0.0.1:9222"); + assert.equal(r.backend, "attach"); + assert.equal(r.mode, "kitty", "a-key attach must not clobber kitty rendering"); +}); + +test("navigate: baseline stays pending when the busy guard skips the read", async () => { + const r = quiet(mkRenderer()); + const calls = []; + r.browser = { + sessionExists: async () => true, + open: async (u) => calls.push(`open:${u}`), + network: async () => { + calls.push("network"); + return []; + }, + }; + r.networkPollBusy = true; // a live-timer poll is in flight + await r.navigate("https://localhost:3000/"); + assert.ok(!calls.includes("network"), "busy guard skipped the baseline read"); + assert.equal( + r.networkBaselinePending, + true, + "flag must stay pending or the next poll replays the whole failure log", + ); +}); + +test("t cycles the pinned page target in attach mode only", { skip: !canCdp }, async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + let cycles = 0; + r.browser.cycleTarget = async () => { + cycles++; + return true; + }; + await r.tick(); + r.onKey("t"); + await flush(); + assert.equal(cycles, 1); + + const plain = quiet(mkRenderer()); + plain.attached = true; + let plainCycles = 0; + plain.browser = { ...plain.browser, cycleTarget: async () => plainCycles++ }; + plain.onKey("t"); + await flush(); + assert.equal(plainCycles, 0, "agent-browser backend has no target cycling"); +}); + +test("t with a single page target reports instead of failing silently", { skip: !canCdp }, async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + r.browser.cycleTarget = async () => false; + await r.tick(); + r.onKey("t"); + await flush(); + assert.equal(r.banner, "no other page targets"); +}); + +test("observe-only: o toggles, page-affecting keys and clicks are dropped", { skip: !canCdp }, async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + r.browser.reload = async () => r.browser.calls.push("reload"); + r.browser.scroll = async () => r.browser.calls.push("scroll"); + r.browser.type = async () => r.browser.calls.push("type"); + await r.tick(); + r.onKey("o"); + assert.equal(r.observeOnly, true); + for (const ch of ["r", "j", "k", " ", "b", "f"]) r.onKey(ch); + r.onKey("i"); // must not even open the type prompt + assert.equal(r.promptState, null); + r.onMouse({ button: 0, col: 10, row: 5, release: false }); + r.onMouse({ button: 65, col: 10, row: 5, release: false }); + await flush(); + assert.deepEqual( + r.browser.calls.filter((c) => + /^(reload|scroll|type|click)/.test(c), + ), + [], + "no input reaches the observed browser while observe-only is on", + ); + assert.match(r.banner, /observe-only/); + r.onKey("o"); + assert.equal(r.observeOnly, false); + assert.equal(r.banner, "", "the observe banner clears with the toggle"); + r.onKey("r"); + await flush(); + assert.ok(r.browser.calls.includes("reload"), "input works again after re-enable"); +}); + +test("observe-only: u prompt is blocked; a Cmd+click handoff is consumed, not forwarded", { skip: !canCdp }, async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + r.browser.open = async (u) => r.browser.calls.push(`open:${u}`); + await r.tick(); + r.onKey("o"); + r.onKey("u"); + assert.equal(r.promptState, null, "navigation prompt must not open"); + fs.writeFileSync( + path.join(r.stateDir, `navigate-${safeWsId(r.env.HERDR_WORKSPACE_ID)}`), + "http://localhost:3000/x\n", + ); + r.consumeNavigateFile(); + await flush(); + assert.ok( + !r.browser.calls.some((c) => c.startsWith("open:")), + "handoff navigation is not forwarded under observe-only", + ); +}); + +test("observe-only: t (view-only) and q remain available; header shows the state", { skip: !canCdp }, async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + let cycles = 0; + r.browser.cycleTarget = async () => { + cycles++; + return true; + }; + await r.tick(); + r.onKey("o"); + r.onKey("t"); + await flush(); + assert.equal(cycles, 1, "cycling the pane's own view stays allowed"); +}); + +// --- Wave 5: Chromium launch mode --- + +test("findChromium: explicit env wins, then config, then first probed candidate", () => { + assert.equal( + findChromium({ HERDR_BROWSER_CHROMIUM: "/opt/my-chrome" }, "cfg", () => true), + "/opt/my-chrome", + ); + assert.equal(findChromium({}, "/cfg/chrome", () => true), "/cfg/chrome"); + assert.equal( + findChromium({}, undefined, (c) => c === "google-chrome"), + "google-chrome", + ); + assert.equal(findChromium({}, undefined, () => false), null); +}); + +const fakeChromiumScript = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hb-chrome-")); + const bin = path.join(dir, "fake-chromium"); + fs.writeFileSync( + bin, + `#!/bin/sh +d="" +for a in "$@"; do case "$a" in --user-data-dir=*) d="\${a#--user-data-dir=}";; esac; done +printf '9876\\n/devtools/browser/fake-guid\\n' > "$d/DevToolsActivePort" +exec sleep 30 +`, + { mode: 0o755 }, + ); + return bin; +}; + +test("launch mode: refuses on Node without a WebSocket client", { skip: canCdp }, async () => { + const r = quiet(mkRenderer()); + await r.launchChromium(); + assert.match(r.banner, /needs Node 22/); +}); + +test("launch mode: l spawns the configured chromium, waits for the port, attaches, owns the child", { skip: !canCdp }, async () => { + const bin = fakeChromiumScript(); + const r = quiet(mkRenderer({ HERDR_BROWSER_CHROMIUM: bin })); + const attachedTo = []; + r.attachTo = async (ep) => attachedTo.push(ep); + r.attached = false; + r.onKey("l"); // reachable while unattached + await until(() => attachedTo.length > 0); + assert.deepEqual(attachedTo, ["http://127.0.0.1:9876"]); + assert.ok(r.launchedChild, "the pane records the child it owns"); + const pid = r.launchedChild.pid; + r.cleanup(); + assert.equal(r.launchedChild, null); + await new Promise((res) => setTimeout(res, 300)); + assert.throws( + () => process.kill(pid, 0), + "quit must kill the browser the pane launched", + ); +}); + +test("launch mode: attaching to a different endpoint kills the launched browser", async () => { + const r = quiet(mkRenderer()); + let killed = 0; + r.launchedChild = { kill: () => killed++, exitCode: null }; + r.launchedEndpoint = "http://127.0.0.1:9876"; + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.attachTo("http://127.0.0.1:9333"); + assert.equal(killed, 1, "abandoning a launched browser must not leak it"); + assert.equal(r.launchedChild, null); +}); + +test("launch mode: no chromium found reports instead of failing silently", { skip: !canCdp }, async () => { + const r = quiet(mkRenderer({ PATH: "/nonexistent" })); + // Probe uses the real PATH via sh; force emptiness through env PATH. + r.env.PATH = "/nonexistent"; + await r.launchChromium(); + assert.match(r.banner, /no Chromium found/); +}); + +test("a opens the attach prompt while unattached", () => { + const r = quiet(mkRenderer()); + r.attached = false; + r.onKey("a"); + assert.ok(r.promptState, "attach prompt must be reachable with no session"); + assert.match(r.promptState.label, /attach/); +}); + +test("launch mode: a bad binary path banners instead of crashing the pane", { skip: !canCdp }, async () => { + const r = quiet( + mkRenderer({ HERDR_BROWSER_CHROMIUM: "/nonexistent/definitely-not-chrome" }), + ); + await r.launchChromium(); + assert.match(r.banner, /cannot launch/); + assert.equal(r.launchedChild, null); +}); + +// --- Wave 6: config-first observe-only and launch --- + +test("truthyConfig accepts common spellings only", () => { + for (const v of ["1", "true", "YES", "on ", " True"]) assert.equal(truthyConfig(v), true, v); + for (const v of ["0", "false", "", undefined, null, "2", "enabled"]) assert.equal(truthyConfig(v), false, String(v)); +}); + +test("HERDR_BROWSER_OBSERVE starts the pane observe-only; o still toggles", () => { + const r = quiet(mkRenderer({ HERDR_BROWSER_OBSERVE: "1" })); + assert.equal(r.observeOnly, true); + r.attached = true; + let reloads = 0; + r.browser = { ...r.browser, reload: async () => reloads++ }; + r.onKey("r"); + assert.equal(reloads, 0, "input starts blocked"); + r.onKey("o"); + assert.equal(r.observeOnly, false); +}); + +test("observe config file is a valid source", () => { + const cfg = fs.mkdtempSync(path.join(os.tmpdir(), "hb-cfg-obs-")); + fs.writeFileSync(path.join(cfg, "observe"), "true\n"); + const r = mkRenderer({ HERDR_PLUGIN_CONFIG_DIR: cfg }); + assert.equal(r.observeOnly, true); +}); + +test("launch-first workspace launches once from the first unattached tick", { skip: !canCdp }, async () => { + const bin = fakeChromiumScript(); + const r = quiet( + mkRenderer({ HERDR_BROWSER_LAUNCH: "1", HERDR_BROWSER_CHROMIUM: bin }), + ); + assert.equal(r.launchConfigured, true); + const attachedTo = []; + r.attachTo = async (ep) => attachedTo.push(ep); + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.tick(); // triggers the launch instead of waiting for a session + assert.equal(r.launchAttempted, true); + await until(() => attachedTo.length > 0); + assert.deepEqual(attachedTo, ["http://127.0.0.1:9876"]); + r.cleanup(); +}); + +test("a configured cdp endpoint wins over launch-first", () => { + const r = mkRenderer({ + HERDR_BROWSER_LAUNCH: "1", + HERDR_BROWSER_CDP_URL: "http://127.0.0.1:9222", + }); + assert.equal(r.backend, "attach"); + assert.equal(r.launchConfigured, false); +}); + +test("a crashed launched Chromium banners l-to-relaunch instead of redialing a dead port", { skip: !canCdp }, async () => { + const bin = fakeChromiumScript(); + const r = quiet(mkRenderer({ HERDR_BROWSER_CHROMIUM: bin })); + r.attachTo = async (ep) => { + r.cdpEndpoint = ep; // what the real attachTo records + r.attached = true; + }; + await r.launchChromium(); + await until(() => r.attached); + const child = r.launchedChild; + assert.ok(child); + child.kill("SIGKILL"); // crash, not a pane-initiated quit + await until(() => !r.launchedChild); + assert.equal(r.launchedChild, null); + assert.equal(r.attached, false); + assert.match(r.banner, /press l to relaunch/); + assert.equal( + r.streamCooldownUntil, + Number.MAX_SAFE_INTEGER, + "no rediscovery loop against a port that died with the browser", + ); +}); + +test("a launch-failure banner survives the next waiting-for-session tick", { skip: !canCdp }, async () => { + // PATH must be explicitly dead: with no PATH at all, sh falls back to the + // system default path and can find a real browser on CI runners. + const r = quiet(mkRenderer({ PATH: "/nonexistent" })); + await r.launchChromium(); + assert.match(r.banner, /no Chromium found/); + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.tick(); + assert.match( + r.banner, + /no Chromium found/, + "generic waiting advice must not paint over the failure explanation", + ); +}); + +test("a crash before the queued attach still latches and never dials the corpse", { skip: !canCdp }, async () => { + const bin = fakeChromiumScript(); + const r = quiet(mkRenderer({ HERDR_BROWSER_CHROMIUM: bin })); + let attachCalls = 0; + r.attachTo = async () => attachCalls++; + // Defer queued user actions so the crash lands between the port read and + // the attach, the exact window the exit-handler guard must cover. + const deferred = []; + r.userAction = (fn) => deferred.push(fn); + await r.launchChromium(); + const child = r.launchedChild; + assert.ok(child, "launch reached the port read"); + child.kill("SIGKILL"); + await until(() => !r.launchedChild); + assert.match(r.banner, /press l to relaunch/); + assert.equal(r.streamCooldownUntil, Number.MAX_SAFE_INTEGER); + for (const fn of deferred) await fn(); + assert.equal(attachCalls, 0, "the queued attach must notice the corpse"); +}); + +// --- Round-2 review fixes: unattached handoff paths --- + +test("a click in a launch workspace with no browser up triggers the launch and navigates on attach", { skip: !canCdp }, async () => { + const r = quiet(mkRenderer({ HERDR_BROWSER_LAUNCH: "1" })); + let launches = 0; + r.launchChromium = async () => launches++; + r.startNavigateWatch(); + fs.writeFileSync( + path.join(r.stateDir, `navigate-${safeWsId(r.env.HERDR_WORKSPACE_ID)}`), + "http://localhost:3000/app\n", + ); + r.consumeNavigateFile(); + assert.equal(launches, 1, "the click retries the launch"); + assert.equal(r.pendingNavigateUrl?.url, "http://localhost:3000/app"); + + // On attach, the parked URL is delivered. + const opened = []; + r.browser = fakeCdpBackend(); + r.browser.open = async (u) => opened.push(u); + r.backend = "attach"; + await r.attachCdp(); + await flush(); + assert.deepEqual(opened, ["http://localhost:3000/app"]); + assert.equal(r.pendingNavigateUrl, null); +}); + +test("a stale handoff file is dropped, not replayed", { skip: !canCdp }, async () => { + const r = quiet(mkRenderer({ HERDR_BROWSER_LAUNCH: "1" })); + r.attached = true; + const opened = []; + r.browser = { ...r.browser, open: async (u) => opened.push(u) }; + r.startNavigateWatch(); + const f = path.join(r.stateDir, `navigate-${safeWsId(r.env.HERDR_WORKSPACE_ID)}`); + fs.writeFileSync(f, "http://localhost:3000/old\n"); + const old = new Date(Date.now() - 3_600_000); + fs.utimesSync(f, old, old); + r.consumeNavigateFile(); + await flush(); + assert.deepEqual(opened, [], "an hour-old click must not navigate out of nowhere"); + assert.ok(!fs.existsSync(f), "the stale file is still consumed"); +}); + +test("runtime attach writes the backend marker; cleanup removes it", async () => { + const r = quiet(mkRenderer()); + const marker = path.join( + r.stateDir, + `backend-${safeWsId(r.env.HERDR_WORKSPACE_ID)}`, + ); + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.attachTo("http://127.0.0.1:9222"); + assert.equal(fs.readFileSync(marker, "utf8").trim(), "attach"); + r.cleanup(); + assert.ok(!fs.existsSync(marker)); +}); + +test("an unexpected launchChromium throw banners instead of rejecting unhandled", { skip: !canCdp }, async () => { + const r = quiet(mkRenderer({ HERDR_BROWSER_CHROMIUM: "/bin/true" })); + // Force a throw inside the guarded body: an unwritable profile parent. + const origMkdir = fs.mkdirSync; + fs.mkdirSync = () => { + throw new Error("EACCES: permission denied, mkdir"); + }; + try { + await r.launchChromium(); // must resolve, never reject + } finally { + fs.mkdirSync = origMkdir; + } + assert.match(r.banner, /launch failed: EACCES/); + assert.equal(r.launchingChromium, false, "the latch is released on failure"); +}); + +test("switching backends closes a session this pane created instead of leaking its daemon", async () => { + const r = quiet(mkRenderer()); + r.selfCreated = true; + let closes = 0; + const orig = r.closeOwnSession.bind(r); + r.closeOwnSession = () => { + closes++; + orig(); + }; + r.browser = { ...r.browser, sessionExists: async () => false }; + await r.attachTo("http://127.0.0.1:9222"); + assert.equal(closes, 1, "the abandoned self-created session is closed, as on quit"); + assert.equal(r.selfCreated, false); + + // A session someone else created is never touched by the switch. + const other = quiet(mkRenderer()); + other.selfCreated = false; + let otherCloses = 0; + const origOther = other.closeOwnSession.bind(other); + other.closeOwnSession = () => { + otherCloses++; + origOther(); + }; + other.browser = { ...other.browser, sessionExists: async () => false }; + await other.attachTo("http://127.0.0.1:9222"); + assert.equal(other.selfCreated, false); + // closeOwnSession may be invoked but must no-op without ownership; the + // observable contract is that it never runs the close for foreign sessions + // — guarded inside by the selfCreated check. + void otherCloses; +});