From ee80fa3cd317285e134612738914684f67ef108b Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Tue, 15 Sep 2026 17:58:54 -0600 Subject: [PATCH 1/2] feat: select browser tabs explicitly and preserve pasted text Keep input bound to the selected tab and require another selection after target loss. Preserve multiline pasted text until insertion is confirmed, without sending Enter to the page. --- CHANGELOG.md | 8 + README.md | 28 ++- bin/cdp.mjs | 122 +++++++++----- bin/renderer.mjs | 271 ++++++++++++++++++++++-------- docs/readiness.md | 3 +- tests/cdp.test.mjs | 90 +++++++++- tests/launch.integration.test.mjs | 73 ++++++++ tests/renderer.test.mjs | 198 +++++++++++++++++++--- 8 files changed, 644 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d136c41..19cfed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +- Select attached or locally launched browser tabs explicitly from a title/URL + picker. Clear stale frames and pending input when the selected tab closes; + require another selection before forwarding input. +- Preserve whitespace, Unicode, tabs, and newlines in the text prompt with + bracketed paste. Confirm insertion separately without sending Enter to the page. + ## 0.8.0 - Add declarative saved browser QA scenarios with isolated desktop/mobile diff --git a/README.md b/README.md index a33bbd9..873489f 100644 --- a/README.md +++ b/README.md @@ -151,10 +151,10 @@ 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) | +| `t` | Attach/launch mode: open the tab picker; arrows or `j`/`k` highlight, Enter selects, `t` refreshes, Esc cancels | | `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 | +| `i` | Enter or paste text for the focused page element; Enter inserts it, Esc cancels | | `b` / `f` | Navigate backward / forward | | `r` | Reload | | `j` / `k` | Scroll down / up | @@ -167,6 +167,13 @@ Clicks are mapped through the rendered-frame geometry to page pixels, so they work with overlays, canvas content, and shadow DOM. Live sessions usually repaint immediately; polling fallback can take up to the configured interval. +The text prompt preserves leading/trailing spaces and Unicode. In a terminal +that supports bracketed paste, pasted tabs and newlines remain text in the +prompt until you press Enter to insert it. Newlines appear as `\n` in the +preview. Inserting text does not send an Enter key to the page or submit a +form. Paste into the `i` prompt; pasted text outside a prompt is ignored. +Each paste is limited to 1 MiB. URL and endpoint prompts still trim whitespace. + ## Rendering and streaming ### Automatic mode selection @@ -251,8 +258,8 @@ 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. 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 +`host:port` instead of a session name, `t` opens a tab picker with titles and +URLs, 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 @@ -265,6 +272,19 @@ navigates the session daemon directly, outside the pane. Set watch-the-agent workspaces — the pane starts observe-only *and* the open action itself refuses link navigation, closing that gap in both modes. +On first connection, a single tab is selected automatically. With multiple +tabs, input waits for an explicit choice in the picker. Selection uses the +tab's stable identity, so duplicate titles and changing tab order do not +redirect it. The picker changes only what this pane observes; it does not +activate the tab in another client's UI and remains available in observe-only +mode. Shared agent-browser mode continues following that session's active tab. + +If the selected tab closes or detaches, the pane clears its cached image and +pending text, drops queued input, and keeps the browser connection open. +Press `t` to select another tab, even if only one remains. It never switches +automatically to a surviving tab. Reconnection restores the same tab only +when the browser identity and tab identity both still match. + Launcher recipes: Playwright `chromium.launch({args:['--remote-debugging-port=9222']})`, Puppeteer the same `args`, Browser Use its `chrome_remote_debugging_port` option. A default launch often uses a pipe transport with no TCP port — the port has to diff --git a/bin/cdp.mjs b/bin/cdp.mjs index 78f5487..4959ad1 100644 --- a/bin/cdp.mjs +++ b/bin/cdp.mjs @@ -260,6 +260,13 @@ export function makeCdpBrowser(endpointInput, opts = {}) { let lastMeta = null; // latest frame metadata (deviceWidth/Height for input scaling) let handler = null; // onMessage subscriber (the Renderer) let attachTimeMs = 0; + let connectedOnce = false; + let selection = 0; + const targetError = () => Object.assign(new Error("select a tab with t before sending input"), { code: "TARGET_GONE" }); + const requireTarget = (expected = pageSessionId) => { + if (!expected || expected !== pageSessionId || !pinnedTargetId || session?.dead) throw targetError(); + return expected; + }; const emit = (m) => { try { handler?.(m); @@ -272,13 +279,21 @@ export function makeCdpBrowser(endpointInput, opts = {}) { const { targetInfos } = await session.send("Target.getTargets"); return targetInfos.filter((t) => t.type === "page"); }; + const clearTarget = (type) => { + selection++; + gen++; + pinnedTargetId = null; + pageSessionId = null; + lastMeta = null; + emit({ type }); + }; - const startScreencast = async () => { + const startScreencast = async (sid = requireTarget()) => { const g = ++gen; await session.send( "Page.startScreencast", { format: "jpeg", quality, maxWidth: maxDim, maxHeight: maxDim, everyNthFrame: 1 }, - pageSessionId, + requireTarget(sid), ); return g; }; @@ -294,12 +309,18 @@ export function makeCdpBrowser(endpointInput, opts = {}) { await session.send("Runtime.enable", {}, sessionId); }; - const pinTarget = async (targetId) => { + const pinTarget = async (target) => { + const { targetId } = target; + const version = selection; + pinnedTargetId = targetId; const { sessionId } = await session.send("Target.attachToTarget", { targetId, flatten: true, }); - pinnedTargetId = targetId; + if (version !== selection || pinnedTargetId !== targetId) { + await session.send("Target.detachFromTarget", { sessionId }).catch(() => {}); + throw targetError(); + } pageSessionId = sessionId; await session.send("Page.enable", {}, sessionId); await enableFeed(sessionId); @@ -316,6 +337,8 @@ export function makeCdpBrowser(endpointInput, opts = {}) { } catch { /* older engines: page-level feed only */ } + requireTarget(sessionId); + emit({ type: "target_selected", targetId, url: target.url, title: target.title }); try { await startScreencast(); } catch (err) { @@ -330,7 +353,7 @@ export function makeCdpBrowser(endpointInput, opts = {}) { }; const onCdpEvent = (m) => { - if (m.method === "Page.screencastFrame" && m.sessionId === pageSessionId) { + if (m.method === "Page.screencastFrame" && pageSessionId && m.sessionId === pageSessionId) { lastMeta = m.params.metadata ?? null; emit({ type: "frame", @@ -352,16 +375,11 @@ export function makeCdpBrowser(endpointInput, opts = {}) { } if (m.method === "Target.targetDestroyed") { if (m.params.targetId !== pinnedTargetId) return; - pinnedTargetId = null; - pageSessionId = null; - // Re-pin only on destruction of OUR target — never follow creation. - pageTargets() - .then(async (pages) => { - if (!pages.length) return emit({ type: "target_gone" }); - await pinTarget(pages[0].targetId); - emit({ type: "url", url: pages[0].url, title: pages[0].title }); - }) - .catch(() => emit({ type: "target_gone" })); + clearTarget("target_gone"); + return; + } + if (m.method === "Target.detachedFromTarget" && pageSessionId && m.params.sessionId === pageSessionId) { + clearTarget("target_gone"); return; } if (m.method === "Inspector.targetCrashed" && m.sessionId === pageSessionId) { @@ -428,6 +446,12 @@ export function makeCdpBrowser(endpointInput, opts = {}) { // Identity of what we're attached to; the Renderer compares guid across // reattaches so a reused port can't silently swap browsers underneath. async connect() { + const previousTarget = pinnedTargetId; + const previousGuid = endpoint?.guid; + if (session) { + session.close(); + clearTarget("target_changing"); + } endpoint = await discoverEndpoint(endpointInput, opts); session = makeCdpSession(endpoint.wsUrl, opts); await session.opened; @@ -435,17 +459,20 @@ export function makeCdpBrowser(endpointInput, opts = {}) { session.onClose(() => emit({ type: "endpoint_gone" })); await session.send("Target.setDiscoverTargets", { discover: true }); const pages = await pageTargets(); - if (!pages.length) throw new Error("endpoint has no page targets"); attachTimeMs = Date.now(); - await pinTarget(pages[0].targetId); + const target = !connectedOnce && pages.length === 1 ? pages[0] + : previousGuid && previousGuid === endpoint.guid ? pages.find((p) => p.targetId === previousTarget) : null; + connectedOnce = true; + if (target) await pinTarget(target); return { host: endpoint.host, port: endpoint.port, browser: endpoint.browser, guid: endpoint.guid, rediscoverable: endpoint.rediscoverable, - url: pages[0].url, - title: pages[0].title, + targetId: pinnedTargetId, + url: target?.url ?? "", + title: target?.title ?? "", }; }, onMessage(fn) { @@ -464,57 +491,64 @@ export function makeCdpBrowser(endpointInput, opts = {}) { } }, async restartScreencast() { + const sid = requireTarget(); try { - await session.send("Page.stopScreencast", {}, pageSessionId); + await session.send("Page.stopScreencast", {}, sid); } catch { /* already stopped */ } - await startScreencast(); + await startScreencast(sid); }, - async cycleTarget() { + async listTargets() { + return (await pageTargets()).map(({ targetId, url, title }) => ({ targetId, url, title, selected: targetId === pinnedTargetId })); + }, + async selectTarget(targetId) { const pages = await pageTargets(); - if (pages.length < 2) return false; - const i = pages.findIndex((t) => t.targetId === pinnedTargetId); - const next = pages[(i + 1) % pages.length]; - try { - await session.send("Page.stopScreencast", {}, pageSessionId); - } catch { - /* old session may be gone */ + const next = pages.find((p) => p.targetId === targetId); + if (!next) throw Object.assign(new Error("that tab closed — press t to refresh the list"), { code: "TARGET_GONE" }); + if (pinnedTargetId === targetId && pageSessionId) return; + const oldSession = pageSessionId; + clearTarget("target_changing"); + if (oldSession) { + await session.send("Page.stopScreencast", {}, oldSession).catch(() => {}); + await session.send("Target.detachFromTarget", { sessionId: oldSession }).catch(() => {}); } - await pinTarget(next.targetId); - emit({ type: "url", url: next.url, title: next.title }); - return true; + try { await pinTarget(next); } + catch (err) { clearTarget("target_gone"); throw err; } }, async open(u) { - await session.send("Page.navigate", { url: u }, pageSessionId); + await session.send("Page.navigate", { url: u }, requireTarget()); }, async back() { - const h = await session.send("Page.getNavigationHistory", {}, pageSessionId); + const sid = requireTarget(); + const h = await session.send("Page.getNavigationHistory", {}, sid); if (h.currentIndex <= 0) return; await session.send( "Page.navigateToHistoryEntry", { entryId: h.entries[h.currentIndex - 1].id }, - pageSessionId, + requireTarget(sid), ); }, async forward() { - const h = await session.send("Page.getNavigationHistory", {}, pageSessionId); + const sid = requireTarget(); + const h = await session.send("Page.getNavigationHistory", {}, sid); if (h.currentIndex >= h.entries.length - 1) return; await session.send( "Page.navigateToHistoryEntry", { entryId: h.entries[h.currentIndex + 1].id }, - pageSessionId, + requireTarget(sid), ); }, async reload() { - await session.send("Page.reload", {}, pageSessionId); + await session.send("Page.reload", {}, requireTarget()); }, // x/y arrive in page CSS pixels — the Renderer scales pane cells -> // frame pixels -> CSS via the per-frame metadata before calling. async click(x, y) { + const sid = requireTarget(); const base = { x, y, button: "left", clickCount: 1 }; - await session.send("Input.dispatchMouseEvent", { type: "mousePressed", ...base }, pageSessionId); - await session.send("Input.dispatchMouseEvent", { type: "mouseReleased", ...base }, pageSessionId); + await session.send("Input.dispatchMouseEvent", { type: "mousePressed", ...base }, sid); + await session.send("Input.dispatchMouseEvent", { type: "mouseReleased", ...base }, requireTarget(sid)); }, async scroll(dir, px) { const m = lastMeta; @@ -523,24 +557,24 @@ export function makeCdpBrowser(endpointInput, opts = {}) { await session.send( "Input.dispatchMouseEvent", { type: "mouseWheel", x: cx, y: cy, deltaX: 0, deltaY: dir === "down" ? px : -px }, - pageSessionId, + requireTarget(), ); }, async type(text) { - await session.send("Input.insertText", { text }, pageSessionId); + await session.send("Input.insertText", { text }, requireTarget()); }, async screenshot(file) { const { data } = await session.send( "Page.captureScreenshot", { format: "png" }, - pageSessionId, + requireTarget(), 15_000, ); const fs = await import("node:fs"); fs.writeFileSync(file, Buffer.from(data, "base64")); }, async sessionExists() { - if (!session || session.dead || !pinnedTargetId) return false; + if (!session || session.dead) return false; return session.ping(); }, close() { diff --git a/bin/renderer.mjs b/bin/renderer.mjs index ce3e5a1..6783e22 100644 --- a/bin/renderer.mjs +++ b/bin/renderer.mjs @@ -505,10 +505,9 @@ export function makeBrowser(session, bin = "agent-browser") { scroll: async (dir, px) => { await run("scroll", dir, String(px)); }, - // 'keyboard type' takes raw text; plain 'type' expects a selector first, - // so the prompt's free-form input only works through the keyboard path. + // Insert literal text without synthesizing Enter or shortcut key events. type: async (text) => { - await run("keyboard", "type", text); + await run("keyboard", "inserttext", text); }, setViewport: async (w, h) => { await run("set", "viewport", String(w), String(h)); @@ -638,6 +637,10 @@ export class Renderer { truthyConfig(env.HERDR_BROWSER_LAUNCH || this.configValue("launch")); this.launchAttempted = false; this.promptState = null; + this.targetPicker = null; + this.pasteState = null; + this.inputRevision = 0; + this.cdpTargetId = null; this.paintQueue = Promise.resolve(); this.paintErrors = 0; this.chafaFails = 0; @@ -710,13 +713,15 @@ export class Renderer { const cols = process.stdout.columns || 80; const rows = process.stdout.rows || 24; let consoleRows = 0; - if (this.mode === "text") { + if (this.targetPicker) { + consoleRows = 0; + } else if (this.mode === "text") { consoleRows = Math.max(0, rows - 3); } else if (this.consoleLines.length > 0) { consoleRows = Math.max(4, Math.floor(rows * 0.3)); } const imageRows = - this.mode === "text" ? 0 : Math.max(0, rows - consoleRows - 4); + this.mode === "text" && !this.targetPicker ? 0 : Math.max(0, rows - consoleRows - 4); return { cols, rows, @@ -879,7 +884,7 @@ export class Renderer { 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)); + if (this.launchedEndpoint) this.userAction(() => this.attachTo(this.launchedEndpoint), { pageInput: false }); return; } // Refuse before spawning: attaching to the result needs the Node 22 @@ -992,7 +997,7 @@ export class Renderer { // latched. Signal deaths leave exitCode null — check both. if (child.exitCode !== null || child.signalCode !== null) return; return this.attachTo(ep, { note: "— launched Chromium —" }); - }); + }, { pageInput: false }); } catch (err) { // Callers fire-and-forget this promise; an uncaught throw here // (say, an unwritable profile dir) would surface as an unhandled @@ -1050,6 +1055,7 @@ export class Renderer { } this.cdpGuid = id.guid; this.cdpIdentity = id; + this.cdpTargetId = id.targetId; this.attached = true; this.startNavigateWatch(); // A click while the launch was still coming up parked its URL here; @@ -1070,6 +1076,7 @@ export class Renderer { this.loopbackWarned = true; this.banner = `attached to ${id.host}:${id.port} — remote endpoint, traffic is unencrypted`; } + if (!this.cdpTargetId) this.banner = "select a tab — press t"; this.header(); return true; } catch (err) { @@ -1138,17 +1145,51 @@ export class Renderer { } resetBackendState() { + this.invalidateTarget(); this.consoleState = { count: 0, tail: [] }; this.networkState = newNetworkState(); this.lastHash = ""; this.shotFormat = "png"; } + invalidateTarget() { + this.inputRevision++; + this.cdpTargetId = null; + this.promptState = null; + this.lastFrameMeta = null; + this.lastFrameAt = 0; + this.lastImageDims = null; + this.lastUrl = ""; + this.lastTitle = ""; + this.lastHash = ""; + this.staleHandled = false; + for (const file of [this.shot, this.shotJpg]) { + try { fs.unlinkSync(file); } catch { /* no cached frame */ } + } + this.enqueue(() => this.redrawAll()); + } + // Bridge: adapter messages arrive already shaped like stream messages, so // frames/url/page_error reuse onStreamMessage. Frames additionally carry // the integer ack id, acked once the paint enqueue settles. onCdpMessage(m) { + if (m.type === "target_changing" || m.type === "target_gone") { + this.invalidateTarget(); + this.banner = m.type === "target_gone" ? "selected tab closed or detached — press t to select a tab" : "switching tabs…"; + this.header(); + return; + } + if (m.type === "target_selected") { + this.cdpTargetId = m.targetId; + this.lastUrl = sanitizeText(m.url ?? ""); + this.lastTitle = sanitizeText(m.title ?? ""); + this.lastFrameAt = Date.now(); + this.banner = ""; + this.header(); + return; + } if (m.type === "frame") { + if (!this.cdpTargetId) return; this.lastFrameAt = Date.now(); this.lastFrameMeta = m.metadata ?? null; this.onStreamMessage({ type: "frame", data: m.data }); @@ -1174,12 +1215,8 @@ export class Renderer { this.pushLogEntry(m); return; } - if (m.type === "target_gone") { - this.banner = "the observed page closed — waiting"; - this.header(); - return; - } if (m.type === "endpoint_gone") { + this.invalidateTarget(); this.attached = false; this.banner = "browser endpoint closed — waiting"; this.header(); @@ -1224,7 +1261,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.backend !== "attach" || !this.attached || !this.lastFrameAt) return; + if (this.backend !== "attach" || !this.attached || !this.cdpTargetId || !this.lastFrameAt) return; if (now - this.lastFrameAt < 10_000 || this.staleHandled) return; this.staleHandled = true; this.banner = "frame stale (tab hidden or contended)"; @@ -1332,8 +1369,11 @@ export class Renderer { renderBottom() { const { cols, bottomRow } = this.size(); - if (this.promptState) { - const text = ` ${this.promptState.label}${this.promptState.value}█`; + if (this.promptState || this.targetPicker) { + const p = this.promptState; + const value = sanitizeText(p?.literal ? JSON.stringify(p.value).slice(1, -1) : p?.value ?? ""); + const text = this.targetPicker ? " Tabs: ↑/↓ or j/k · Enter:select · t:refresh · Esc:cancel" + : ` ${p.label}${value}█`; process.stdout.write( `${ESC}[${bottomRow};1H${truncate(text, cols)}${ESC}[K`, ); @@ -1341,9 +1381,9 @@ export class Renderer { // 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" + ? " observe-only t:tabs 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" + ? " t:tabs i:text u:url a:attach l:launch o:observe q:quit b/f:hist r:reload j/k:scroll" : " 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`, @@ -1352,6 +1392,7 @@ export class Renderer { } async renderImage() { + if (this.targetPicker) return this.renderTargetPicker(); const { cols, imageRows } = this.size(); const shotPath = this.shotFormat === "jpg" ? this.shotJpg : this.shot; if (this.mode === "text" || imageRows < 3 || !fs.existsSync(shotPath)) @@ -1439,7 +1480,61 @@ export class Renderer { } } + renderTargetPicker() { + const picker = this.targetPicker; + if (!picker) return; + const { cols, imageRows, imageTopRow } = this.size(); + const perPage = Math.max(1, Math.floor((imageRows - 1) / 2)); + const start = Math.floor(picker.index / perPage) * perPage; + const lines = [picker.loading ? " Loading tabs…" : picker.targets.length ? " Select a tab" : " No tabs available — t to refresh"]; + for (const [i, target] of picker.targets.slice(start, start + perPage).entries()) { + lines.push(`${start + i === picker.index ? " ›" : " "} ${start + i + 1}. ${target.selected ? "(selected) " : ""}${sanitizeText(target.title || "Untitled")}`); + lines.push(` ${sanitizeText(target.url ?? "")}`); + } + for (let i = 0; i < imageRows; i++) { + process.stdout.write(`${ESC}[${imageTopRow + i};1H${truncate(lines[i] ?? "", cols)}${ESC}[K`); + } + } + + openTargetPicker() { + if (this.backend !== "attach" || !this.attached) return; + const picker = { targets: [], index: 0, loading: true }; + this.targetPicker = picker; + this.userAction(async () => { + try { + const targets = await this.browser.listTargets(); + if (this.targetPicker !== picker) return; + picker.targets = targets; + picker.index = Math.max(0, targets.findIndex((t) => t.selected)); + } finally { + picker.loading = false; + await this.redrawAll(); + } + }, { pageInput: false }); + } + + targetPickerInput(ch) { + const picker = this.targetPicker; + if (ch === "\x1b") { + this.targetPicker = null; + this.enqueue(() => this.redrawAll()); + } else if (ch === "t") this.openTargetPicker(); + else if (!picker.loading && (ch === "j" || ch === "\x1b[B" || ch === "k" || ch === "\x1b[A")) { + picker.index = Math.max(0, Math.min(picker.targets.length - 1, picker.index + (ch === "j" || ch === "\x1b[B" ? 1 : -1))); + this.enqueue(() => this.renderTargetPicker()); + } else if (!picker.loading && (ch === "\r" || ch === "\n")) { + const target = picker.targets[picker.index]; + if (!target) return; + this.targetPicker = null; + this.userAction(async () => { + try { await this.browser.selectTarget(target.targetId); } + finally { await this.redrawAll(); } + }, { pageInput: false }); + } + } + renderConsole() { + if (this.targetPicker) return; const { cols, rows, consoleRows } = this.size(); if (rows < 8 || consoleRows < 2) return; // below this, lines overpaint the header const top = rows - consoleRows; @@ -1483,6 +1578,7 @@ export class Renderer { if (Date.now() - this.lastLiveCheck > 15_000) { this.lastLiveCheck = Date.now(); if (!(await this.browser.sessionExists())) { + this.invalidateTarget(); this.attached = false; // Re-discovery, not a re-dial: the browser may have restarted // and minted a fresh token, and a raw ws endpoint has none. @@ -1692,36 +1788,64 @@ export class Renderer { // reads and several keypresses can arrive coalesced. Buffer partial // escape tails and split everything into single events before dispatch. feed(s) { + clearTimeout(this.inputTimer); s = this.inputBuf + s; - // Hold a trailing partial escape for the next chunk: ESC-[ alone, or - // an incomplete mouse report. A bare ESC (prompt cancel) dispatches - // immediately — holding it would swallow the cancel until another key. - const tail = /\x1b\[(?:<[\d;]*)?$/.exec(s); - this.inputBuf = tail ? tail[0] : ""; - s = s.slice(0, s.length - this.inputBuf.length); - if (!s) return; - if (this.promptState) { - this.promptInput(s); - return; - } - const mouseRe = /\x1b\[<\d+;\d+;\d+[Mm]/g; - let m; - let last = 0; - const parts = []; - const mice = []; - while ((m = mouseRe.exec(s))) { - parts.push(s.slice(last, m.index)); - mice.push(m[0]); - last = m.index + m[0].length; - } - parts.push(s.slice(last)); - for (let i = 0; i < parts.length; i++) { - for (const ch of parts[i]) this.onKey(ch); - if (i < mice.length) this.onMouse(parseSgrMouse(mice[i])); + this.inputBuf = ""; + while (s) { + if (this.pasteState) { + const endMarker = `${ESC}[201~`; + const end = s.indexOf(endMarker); + let keep = 0; + if (end < 0) { + for (let i = 1; i < endMarker.length; i++) if (s.endsWith(endMarker.slice(0, i))) keep = i; + } + const part = end >= 0 ? s.slice(0, end) : s.slice(0, s.length - keep); + const paste = this.pasteState; + paste.bytes += Buffer.byteLength(part); + if (paste.bytes > 1024 * 1024) paste.overflow = true; + if (!paste.overflow) paste.value += part; + if (end < 0) { + this.inputBuf = keep ? s.slice(-keep) : ""; + return; + } + this.pasteState = null; + if (paste.overflow) this.holdBanner("paste exceeds 1 MiB — nothing inserted"); + else if (paste.prompt && paste.prompt === this.promptState) { + paste.prompt.value += paste.value; + this.renderBottom(); + } + s = s.slice(end + endMarker.length); + continue; + } + if (s.startsWith(`${ESC}[200~`)) { + this.pasteState = { prompt: this.promptState, value: "", bytes: 0, overflow: false }; + if (!this.promptState) this.holdBanner("press i before pasting text"); + s = s.slice(6); + continue; + } + if (/^\x1b(?:\[[\d;<]*)?$/.test(s)) { + this.inputBuf = s; + if (s === ESC) this.inputTimer = setTimeout(() => { + this.inputBuf = ""; + this.onKey(ESC); + }, 50); + return; + } + const escape = /^\x1b\[[\d;<]*[A-Za-z~]/.exec(s)?.[0]; + const ch = escape ?? String.fromCodePoint(s.codePointAt(0)); + s = s.slice(ch.length); + const mouse = parseSgrMouse(ch); + if (mouse) this.onMouse(mouse); + else { + const prompt = this.promptState; + this.onKey(ch); + if (prompt && !this.promptState) return; + } } } onMouse(mouse) { + if (this.promptState || this.targetPicker || (this.backend === "attach" && !this.cdpTargetId)) return; if (!mouse || mouse.release) return; if (this.observeOnly && this.attached) { this.noteObserveBlocked(); @@ -1784,6 +1908,10 @@ export class Renderer { } onKey(ch) { + if (this.targetPicker) { + this.targetPickerInput(ch); + return; + } // A prompt owns the keyboard; clicks while typing must not drive the // page behind the prompt. if (this.promptState) { @@ -1805,6 +1933,11 @@ export class Renderer { // 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; + if (this.backend === "attach" && !this.cdpTargetId && ["u", "i", "b", "f", "r", "j", "k", " "].includes(ch)) { + this.banner = "select a tab — press t"; + this.header(); + return; + } switch (ch) { case "u": this.openPrompt("URL: ", (v) => this.navigate(v)); @@ -1813,31 +1946,18 @@ export class Renderer { // navigation target, so overloading the URL prompt would force a // heuristic that guesses wrong on exactly the common case. case "a": - this.openPrompt("attach to endpoint: ", (v) => this.attachTo(v)); + this.openPrompt("attach to endpoint: ", (v) => this.attachTo(v), { pageInput: false }); 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(); - } - }); + this.openTargetPicker(); break; case "i": - this.openPrompt("type: ", (v) => this.browser.type(v)); + this.openPrompt("text (Enter:insert, Esc:cancel): ", (v) => this.browser.type(v), { literal: true }); break; case "b": this.userAction(() => this.browser.back()); @@ -1862,8 +1982,8 @@ export class Renderer { } } - openPrompt(label, onSubmit) { - this.promptState = { label, value: "", onSubmit }; + openPrompt(label, onSubmit, { literal = false, pageInput = true } = {}) { + this.promptState = { label, value: "", onSubmit, literal, pageInput }; this.renderBottom(); } @@ -1876,8 +1996,8 @@ export class Renderer { if (ch === "\r" || ch === "\n") { this.promptState = null; this.renderBottom(); - const v = p.value.trim(); - if (v) this.userAction(() => p.onSubmit(v)); + const v = p.literal ? p.value : p.value.trim(); + if (v) this.userAction(() => p.onSubmit(v), { pageInput: p.pageInput }); return; } if (ch === "\x1b") { @@ -1885,7 +2005,7 @@ export class Renderer { this.renderBottom(); return; } - if (ch === "\x7f" || ch === "\b") p.value = p.value.slice(0, -1); + if (ch === "\x7f" || ch === "\b") p.value = Array.from(p.value).slice(0, -1).join(""); // Printable chars only; 8-bit C1 controls (0x80-0x9f) are refused — // the value is echoed to the terminal on every keystroke. else if (ch >= " " && !(ch >= "\x7f" && ch <= "\x9f")) p.value += ch; @@ -2089,15 +2209,27 @@ export class Renderer { // User-initiated drive of the shared session: run the action, then refresh // immediately instead of waiting for the next poll tick. - userAction(fn) { + userAction(fn, { pageInput = true } = {}) { + const browser = this.browser; + const revision = this.inputRevision; this.idleTicks = 0; // interaction restores the base poll cadence this.enqueue(async () => { + if (browser !== this.browser) return; + if (pageInput) { + if (revision !== this.inputRevision) return; + if (this.observeOnly) return this.noteObserveBlocked(); + if (this.backend === "attach" && !this.cdpTargetId) { + this.banner = "select a tab — press t"; + this.header(); + return; + } + } try { await fn(); - } catch { + } catch (err) { // Poll mode reports daemon failures via the tick failure counter; // live mode's tick never runs that path, so say it directly. - this.banner = `command failed — ${this.backendName} not responding`; + this.banner = err?.code === "TARGET_GONE" ? sanitizeText(err.message) : `command failed — ${this.backendName} not responding`; this.header(); } }).then(() => this.enqueue(() => this.tick())); @@ -2196,6 +2328,7 @@ export class Renderer { } cleanup() { + clearTimeout(this.inputTimer); if (this.mode === "kitty") process.stdout.write(KITTY_DELETE_ALL); this.stopNetworkTimer(); clearTimeout(this.live?.firstFrameTimer); @@ -2251,7 +2384,7 @@ export class Renderer { } catch { /* fine */ } - process.stdout.write(`${ESC}[?1000l${ESC}[?1006l${ESC}[?1049l${ESC}[?25h`); + process.stdout.write(`${ESC}[?2004l${ESC}[?1000l${ESC}[?1006l${ESC}[?1049l${ESC}[?25h`); } async run() { @@ -2287,7 +2420,7 @@ export class Renderer { ); } await this.redrawAll(); - process.stdout.write(`${ESC}[?1000h${ESC}[?1006h`); + process.stdout.write(`${ESC}[?2004h${ESC}[?1000h${ESC}[?1006h`); let resizeTimer = null; process.stdout.on("resize", () => { @@ -2336,7 +2469,7 @@ if ( } catch { /* never set */ } - process.stdout.write(`${ESC}[?1000l${ESC}[?1006l${ESC}[?1049l${ESC}[?25h`); + process.stdout.write(`${ESC}[?2004l${ESC}[?1000l${ESC}[?1006l${ESC}[?1049l${ESC}[?25h`); console.error("herdr-browser renderer crashed:", err.message); setTimeout(() => process.exit(1), 600_000); }); diff --git a/docs/readiness.md b/docs/readiness.md index 527d716..38afc7f 100644 --- a/docs/readiness.md +++ b/docs/readiness.md @@ -52,7 +52,8 @@ own Chrome engine; a second bundled distribution is unnecessary. 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 + downloads, file upload, and dialogs. Explicit target selection and verbatim + text insertion now have regression coverage. 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. diff --git a/tests/cdp.test.mjs b/tests/cdp.test.mjs index e050892..06c406f 100644 --- a/tests/cdp.test.mjs +++ b/tests/cdp.test.mjs @@ -252,19 +252,22 @@ test("adapter surface: forbidden methods are absent, required ones present", asy const { b } = await attachBrowser(fake); for (const missing of ["setViewport", "network", "snapshot", "streamEnable", "streamStatus"]) assert.equal(b[missing], undefined, `${missing} must not exist — duck-type guards depend on it`); - for (const required of ["open", "back", "forward", "reload", "click", "scroll", "type", "sessionExists", "screenshot", "cycleTarget"]) + for (const required of ["open", "back", "forward", "reload", "click", "scroll", "type", "sessionExists", "screenshot", "listTargets", "selectTarget"]) assert.equal(typeof b[required], "function", `${required} missing — a key handler calls it unguarded`); }); -test("adapter pins the first page target and starts a jpeg screencast", async () => { +test("adapter requires a choice among multiple pages, then pins the exact target", async () => { const fake = makeFakeCdp({ pages: [ { targetId: "T1", type: "page", url: "https://one/", title: "One" }, { targetId: "T2", type: "page", url: "https://two/", title: "Two" }, ], }); - const { id } = await attachBrowser(fake); - assert.equal(id.url, "https://one/"); + const { b, id } = await attachBrowser(fake); + assert.equal(id.targetId, null); + assert.equal(fake.calls("Target.attachToTarget").length, 0); + await assert.rejects(b.type("no target"), { code: "TARGET_GONE" }); + await b.selectTarget("T1"); const att = fake.calls("Target.attachToTarget"); assert.equal(att.length, 1); assert.deepEqual(att[0].params, { targetId: "T1", flatten: true }); @@ -281,9 +284,11 @@ test("adapter never creates or closes targets across its whole lifecycle", async ], }); const { b } = await attachBrowser(fake); + await b.selectTarget("T1"); await b.open("https://elsewhere/"); await b.reload(); - await b.cycleTarget(); + await b.selectTarget("T2"); + assert.deepEqual(fake.calls("Target.detachFromTarget")[0].params, { sessionId: "sess-T1" }); fake.deliver({ method: "Target.targetDestroyed", params: { targetId: "T2" } }); await new Promise((r) => setTimeout(r, 10)); b.close(); @@ -313,14 +318,15 @@ test("adapter frame events carry the integer ack id; stale-generation acks are d assert.equal(fake.calls("Page.screencastFrameAck").length, 1, "stale ack dropped"); }); -test("adapter emits url for the pinned target only; re-pins on destruction only", async () => { +test("adapter emits only the selected URL and stops input on target loss with surviving tabs", async () => { const fake = makeFakeCdp({ pages: [ { targetId: "T1", type: "page", url: "https://one/", title: "One" }, { targetId: "T2", type: "page", url: "https://two/", title: "Two" }, ], }); - const { got } = await attachBrowser(fake); + const { b, got } = await attachBrowser(fake); + await b.selectTarget("T1"); fake.deliver({ method: "Target.targetInfoChanged", params: { targetInfo: { targetId: "T2", url: "https://noise/", title: "n" } }, @@ -342,7 +348,75 @@ test("adapter emits url for the pinned target only; re-pins on destruction only" assert.equal(fake.calls("Target.attachToTarget").length, 1, "creation never re-pins"); fake.deliver({ method: "Target.targetDestroyed", params: { targetId: "T1" } }); await new Promise((r) => setTimeout(r, 10)); - assert.equal(fake.calls("Target.attachToTarget").length, 2, "destruction re-pins"); + assert.equal(fake.calls("Target.attachToTarget").length, 1, "destruction never re-pins"); + assert.ok(got.some((m) => m.type === "target_gone")); + const before = fake.sent.length; + for (const action of [() => b.type("x"), () => b.open("https://two/"), () => b.click(1, 2), () => b.scroll("down", 10), () => b.reload(), () => b.back(), () => b.forward()]) { + await assert.rejects(action(), { code: "TARGET_GONE" }); + } + assert.equal(fake.sent.length, before, "no unscoped input reaches the browser socket"); + assert.equal(await b.sessionExists(), true, "the browser remains connected without a tab"); + await b.selectTarget("T2"); + await b.type("selected explicitly"); + assert.equal(fake.calls("Input.insertText")[0].sessionId, "sess-T2"); +}); + +test("adapter rejects a stale selection even when titles match and ignores late frames", async () => { + const fake = makeFakeCdp(); + const { b, got } = await attachBrowser(fake); + fake.pages.push({ targetId: "T2", type: "page", url: "https://two/", title: "X" }); + assert.deepEqual((await b.listTargets()).map((t) => t.targetId), ["T1", "T2"]); + fake.pages.pop(); + await assert.rejects(b.selectTarget("T2"), { code: "TARGET_GONE" }); + fake.deliver({ method: "Target.detachedFromTarget", params: { sessionId: "sess-T1" } }); + fake.deliver({ method: "Page.screencastFrame", sessionId: "sess-T1", params: { data: "AAAA", sessionId: 1 } }); + assert.equal(got.filter((m) => m.type === "frame").length, 0); + await assert.rejects(b.type("late"), { code: "TARGET_GONE" }); +}); + +test("adapter stops a multi-command action when its tab is lost mid-command", async () => { + const fake = makeFakeCdp(); + const { b } = await attachBrowser(fake); + fake.results["Input.dispatchMouseEvent"] = () => { + fake.deliver({ method: "Target.targetDestroyed", params: { targetId: "T1" } }); + return {}; + }; + await assert.rejects(b.click(10, 10), { code: "TARGET_GONE" }); + assert.equal(fake.calls("Input.dispatchMouseEvent").length, 1, "release is never routed to another tab or the browser"); +}); + +test("adapter refuses a tab that disappears during attachment or screencast restart", async () => { + const fake = makeFakeCdp({ pages: [] }); + const { b } = await attachBrowser(fake); + fake.pages.push({ targetId: "T1", type: "page", url: "https://one/", title: "One" }); + fake.results["Target.attachToTarget"] = () => { + fake.deliver({ method: "Target.targetDestroyed", params: { targetId: "T1" } }); + return { sessionId: "sess-T1" }; + }; + await assert.rejects(b.selectTarget("T1"), { code: "TARGET_GONE" }); + assert.equal(fake.calls("Page.startScreencast").length, 0); + assert.equal(fake.calls("Target.detachFromTarget").length, 1); + delete fake.results["Target.attachToTarget"]; + await b.selectTarget("T1"); + fake.results["Page.stopScreencast"] = () => { + fake.deliver({ method: "Target.targetDestroyed", params: { targetId: "T1" } }); + return {}; + }; + await assert.rejects(b.restartScreencast(), { code: "TARGET_GONE" }); + assert.equal(fake.calls("Page.startScreencast").length, 1, "restart never sends an unscoped command after loss"); +}); + +test("adapter survives zero pages and does not auto-select a replacement on reconnect", async () => { + const fake = makeFakeCdp({ pages: [] }); + const { b, id } = await attachBrowser(fake); + assert.equal(id.targetId, null); + fake.pages.push({ targetId: "T2", type: "page", url: "https://two/", title: "Two" }); + assert.equal((await b.connect()).targetId, null); + await b.selectTarget("T2"); + assert.equal((await b.connect()).targetId, "T2", "same browser and exact target may reconnect"); + fake.deliver({ method: "Target.targetDestroyed", params: { targetId: "T2" } }); + fake.pages[0].targetId = "T3"; + assert.equal((await b.connect()).targetId, null); }); test("adapter destroyed pin with no survivors emits target_gone", async () => { diff --git a/tests/launch.integration.test.mjs b/tests/launch.integration.test.mjs index 6935614..85fbf65 100644 --- a/tests/launch.integration.test.mjs +++ b/tests/launch.integration.test.mjs @@ -10,6 +10,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { Renderer, findChromium } from "../bin/renderer.mjs"; +import { discoverEndpoint, makeCdpSession } from "../bin/cdp.mjs"; const probe = (c) => spawnSync("sh", ["-c", 'command -v -- "$1"', "sh", c], { timeout: 5000 }) @@ -105,3 +106,75 @@ test("launch mode drives a real Chromium end to end", { ); } }); + +test("tab selection, target loss and verbatim paste work end to end in Chromium", { + 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-input-int", + HERDR_PLUGIN_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), "hb-input-int-")), + HERDR_BROWSER_CHROMIUM: chromium, HOME: os.homedir(), PATH: process.env.PATH, + }); + r.header = r.renderConsole = r.renderBottom = () => {}; + r.renderImage = async () => {}; + r.mode = "symbols"; + let controller; + try { + await r.launchChromium(); + assert.ok(await until(() => r.attached, 30_000), r.banner); + const endpoint = await discoverEndpoint(r.launchedEndpoint); + controller = makeCdpSession(endpoint.wsUrl); + await controller.opened; + const originalId = r.cdpTargetId; + const { targetId } = await controller.send("Target.createTarget", { url: "about:blank" }); + const { sessionId } = await controller.send("Target.attachToTarget", { targetId, flatten: true }); + const evaluate = async expression => { + const result = await controller.send("Runtime.evaluate", { expression, returnByValue: true }, sessionId); + assert.equal(result.exceptionDetails, undefined); + return result.result.value; + }; + await evaluate(`document.title = 'Paste fixture'; document.body.innerHTML = '
'; window.enters = 0; window.submits = 0; document.addEventListener('keydown', e => { if (e.key === 'Enter') window.enters++; }); document.querySelector('form').onsubmit = e => { e.preventDefault(); window.submits++; };`); + assert.equal(r.cdpTargetId, originalId, "new tabs do not change the selected tab"); + r.onKey("t"); + assert.ok(await until(() => r.targetPicker && !r.targetPicker.loading, 5_000)); + const index = r.targetPicker.targets.findIndex(t => t.targetId === targetId); + assert.ok(index >= 0); + while (r.targetPicker.index !== index) r.onKey(r.targetPicker.index < index ? "j" : "k"); + r.onKey("\r"); + assert.ok(await until(() => r.cdpTargetId === targetId && r.lastFrameMeta, 10_000)); + const text = ' first\n\tsecond\n"quotes" \\ $` +^%~(){}[] 漢🙂 '; + for (const width of [1440, 390]) { + await controller.send("Emulation.setDeviceMetricsOverride", { width, height: 900, deviceScaleFactor: 1, mobile: false }, sessionId); + await evaluate("document.querySelector('textarea').value = ''; document.querySelector('textarea').focus()"); + r.onKey("i"); + r.feed(`\x1b[200~${text}\x1b[201~`); + assert.equal(await evaluate("document.querySelector('textarea').value"), "", "paste is staged until confirmed"); + r.feed("\r"); + await r.paintQueue; + assert.equal(await evaluate("document.querySelector('textarea').value"), text, `verbatim text at viewport width ${width}`); + assert.equal(await evaluate("window.enters + window.submits"), 0, "insertion never sends Enter or submits the form"); + await r.browser.screenshot(path.join(r.stateDir, `paste-${width}.png`)); + } + await controller.send("Target.closeTarget", { targetId }); + assert.ok(await until(() => r.cdpTargetId === null, 5_000)); + assert.equal(r.lastFrameMeta, null); + assert.equal(fs.existsSync(r.shotJpg), false); + r.onKey("i"); + r.onKey("r"); + assert.equal(r.promptState, null); + await assert.rejects(r.browser.type("must not reach the surviving tab"), { code: "TARGET_GONE" }); + await r.tick(); + assert.equal(r.attached, true, "browser remains connected after losing the selected tab"); + assert.equal(r.cdpTargetId, null); + r.onKey("t"); + assert.ok(await until(() => r.targetPicker && !r.targetPicker.loading, 5_000)); + assert.equal(r.targetPicker.targets.length, 1); + r.onKey("\r"); + assert.ok(await until(() => r.cdpTargetId === originalId, 5_000)); + } finally { + controller?.close(); + r.cleanup(); + } +}); diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index 130adb4..d35c004 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -818,6 +818,107 @@ test("prompt mode swallows mouse reports without cancelling", () => { ); }); +test("literal text preserves whitespace and chunked bracketed paste without submitting", async () => { + const value = ' first\n\tsecond\r\n"quoted" \\ $` +^%~(){}[] 漢🙂 '; + const framed = `\x1b[200~${value}\x1b[201~`; + for (let split = 1; split < framed.length; split++) { + const r = quiet(mkRenderer()); + r.attached = true; + r.tick = async () => {}; + const inserted = []; + r.browser = { type: async text => inserted.push(text) }; + r.onKey("i"); + r.feed(framed.slice(0, split)); + r.feed(framed.slice(split)); + assert.equal(r.promptState.value, value, `paste split at ${split}`); + assert.deepEqual(inserted, [], "paste alone never sends text or Enter"); + r.feed("\r"); + await flush(); + assert.deepEqual(inserted, [value]); + } +}); + +test("pasted commands outside prompts cannot operate the pane; canceled and oversized pastes send nothing", async () => { + const r = quiet(mkRenderer()); + r.attached = true; + r.tick = async () => {}; + const sent = []; + r.browser = { type: async value => sent.push(value), reload: async () => sent.push("reload") }; + r.feed("\x1b[200~rjiu\n\x1b[201~"); + await flush(); + assert.equal(r.promptState, null); + assert.deepEqual(sent, []); + r.onKey("i"); + r.feed("\x1b[200~hello\n\x1b[201~"); + r.onKey("\x1b"); + await flush(); + assert.deepEqual(sent, []); + r.onKey("i"); + r.feed(`\x1b[200~${"x".repeat(1024 * 1024 + 1)}\x1b[201~`); + assert.match(r.banner, /exceeds/); + assert.equal(r.promptState.value, ""); + r.feed("\r"); + await flush(); + assert.deepEqual(sent, []); +}); + +test("literal input accepts whitespace-only values and keeps typed Unicode intact on backspace", async () => { + const r = quiet(mkRenderer()); + r.attached = true; + r.tick = async () => {}; + const values = []; + r.browser = { type: async value => values.push(value) }; + r.onKey("i"); + r.feed(" \r"); + await flush(); + assert.deepEqual(values, [" "]); + r.onKey("i"); + r.feed("🙂\x7f漢\r"); + await flush(); + assert.deepEqual(values, [" ", "漢"]); +}); + +test("tab picker paginates within narrow and wide panes, including text-only mode", () => { + const r = quiet(mkRenderer()); + r.mode = "text"; + r.targetPicker = { loading: false, index: 12, targets: Array.from({ length: 20 }, (_, i) => ({ targetId: `T${i}`, title: `Tab ${i} ${"long title ".repeat(8)}`, url: `https://localhost/${i}` })) }; + assert.ok(r.size().imageRows > 0, "text-only panes reserve room for the picker"); + const write = process.stdout.write; + try { + for (const [cols, rows] of [[40, 12], [100, 30]]) { + const chunks = []; + process.stdout.write = value => { chunks.push(value); return true; }; + r.size = () => ({ cols, rows, imageRows: rows - 4, imageTopRow: 3, bottomRow: rows }); + r.renderTargetPicker(); + assert.equal(chunks.length, rows - 4); + assert.ok(chunks.some(value => value.includes("13. Tab 12")), "highlighted tab stays in view"); + for (const chunk of chunks) { + const match = /^\x1b\[(\d+);1H([^\x1b]*)\x1b\[K$/.exec(chunk); + assert.ok(match, "each row is a single bounded terminal write"); + assert.ok(Number(match[1]) < rows); + assert.ok(match[2].length <= cols); + } + } + } finally { process.stdout.write = write; } +}); + +test("paste preview escapes control characters without altering the inserted text", () => { + const r = quiet(mkRenderer()); + const value = "first\n\tsecond\x1b[2J\x9b\u202e"; + r.openPrompt("text: ", () => {}, { literal: true }); + r.feed(`\x1b[200~${value}\x1b[201~`); + const write = process.stdout.write; + const chunks = []; + process.stdout.write = text => { chunks.push(text); return true; }; + try { Renderer.prototype.renderBottom.call(r); } + finally { process.stdout.write = write; } + assert.equal(r.promptState.value, value); + assert.ok(chunks[0].includes("first\\n\\tsecond\\u001b[2J")); + assert.ok(!chunks[0].includes("\x1b[2J")); + assert.ok(!chunks[0].includes("\x9b")); + assert.ok(!chunks[0].includes("\u202e")); +}); + test("feed gates keys and clicks while unattached", async () => { const r = quiet(mkRenderer()); const calls = []; @@ -1308,7 +1409,7 @@ test("e2e: goLive receives pushed frames and console from a real session", { ); const server = http.createServer((_req, res) => { res.setHeader("Content-Type", "text/html"); - res.end("Browser stream test

Local browser fixture

"); + 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 })); @@ -1331,6 +1432,11 @@ test("e2e: goLive receives pushed frames and console from a real session", { 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(["focus", "textarea"]); + const literal = ' spaces\n\ttabs 漢🙂 "quotes" '; + await r.browser.type(literal); + const value = await ab(["--json", "get", "value", "textarea"]); + assert.equal(JSON.parse(value).data.value, literal, "shared session inserts verbatim multiline text"); await ab(["eval", 'console.warn("hb-itest-marker")']); const cDeadline = Date.now() + 10_000; while ( @@ -1521,7 +1627,7 @@ test('userAction surfaces failures in the banner (live mode has no tick report)' assert.ok(headers >= 1, 'failure banner painted'); }); -test('feed holds a split ESC-[ but a bare ESC still dispatches immediately', () => { +test('feed holds split escape sequences and dispatches a bare ESC after a short delay', async () => { const r = quiet(mkRenderer()); const keys = []; r.onKey = ch => keys.push(ch); @@ -1530,9 +1636,9 @@ test('feed holds a split ESC-[ but a bare ESC still dispatches immediately', () r.feed('<0;10;5M'); assert.deepEqual(keys, [], 'completed report went to the mouse path'); r.feed('\x1b'); - // bare ESC reaches the key path at once (prompt cancel must not lag); - // onKey ignores it when no prompt is open and it is not a mapped key. + await new Promise(resolve => setTimeout(resolve, 70)); assert.equal(r.inputBuf, ''); + assert.deepEqual(keys, ['\x1b']); }); // --- Pane viewport fitting --- @@ -2076,6 +2182,7 @@ const fakeCdpBackend = (over = {}) => { port: "9222", guid: "guid-1", browser: "Chrome/150", + targetId: "T1", url: "https://x/", title: "X", rediscoverable: true, @@ -2086,6 +2193,8 @@ const fakeCdpBackend = (over = {}) => { ackFrame: async (ackId, gen) => calls.push(`ack:${ackId}:${gen}`), restartScreencast: async () => calls.push("restart"), click: async (x, y) => calls.push(`click:${x},${y}`), + listTargets: async () => [{ targetId: "T1", title: "One", url: "https://one/", selected: true }, { targetId: "T2", title: "Two", url: "https://two/", selected: false }], + selectTarget: async (id) => calls.push(`select:${id}`), close: () => calls.push("close"), }; }; @@ -2360,36 +2469,84 @@ test("navigate: baseline stays pending when the busy guard skips the read", asyn ); }); -test("t cycles the pinned page target in attach mode only", { skip: !canCdp }, async () => { +test("t opens a stable tab list and selects only on Enter, 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); + assert.equal(r.targetPicker.targets.length, 2); + assert.ok(!r.browser.calls.some(c => c.startsWith("select:"))); + r.feed("\x1b[B"); + r.feed("\r"); + await flush(); + assert.ok(r.browser.calls.includes("select:T2")); 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"); + assert.equal(plain.targetPicker, null, "agent-browser backend keeps its session's active tab"); }); -test("t with a single page target reports instead of failing silently", { skip: !canCdp }, async () => { +test("empty tab picker can refresh when a new tab arrives", { skip: !canCdp }, async () => { const r = attachRenderer(); r.browser = fakeCdpBackend(); - r.browser.cycleTarget = async () => false; + r.browser.listTargets = async () => []; await r.tick(); r.onKey("t"); await flush(); - assert.equal(r.banner, "no other page targets"); + assert.equal(r.targetPicker.targets.length, 0); + r.browser.listTargets = async () => [{ targetId: "T2", title: "New", url: "https://new/" }]; + r.onKey("t"); + await flush(); + assert.equal(r.targetPicker.targets[0].targetId, "T2"); +}); + +test("target loss cancels pending text, clears frame geometry and drops queued input without reconnecting", { skip: !canCdp }, async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + await r.tick(); + r.browser.type = async () => r.browser.calls.push("type"); + r.lastFrameMeta = { deviceWidth: 800, deviceHeight: 600 }; + fs.writeFileSync(r.shotJpg, jpeg(800, 600)); + r.onKey("i"); + r.feed("\x1b[200~unfinished"); + let release; + r.enqueue(() => new Promise(resolve => { release = resolve; })); + await flush(); + r.userAction(() => r.browser.type("queued")); + r.browser.emit({ type: "target_gone" }); + r.browser.emit({ type: "target_selected", targetId: "T2", url: "https://two/", title: "Two" }); + r.feed("\nrest\x1b[201~"); + release(); + await flush(); + assert.equal(r.promptState, null); + assert.equal(r.lastFrameMeta, null); + assert.equal(fs.existsSync(r.shotJpg), false); + assert.ok(!r.browser.calls.includes("type")); + assert.equal(r.browser.calls.filter(c => c === "connect").length, 1); + r.browser.emit({ type: "target_gone" }); + r.onKey("i"); + r.onKey("u"); + r.onMouse({ button: 0, col: 10, row: 5 }); + r.checkFrameStaleness(Date.now() + 60_000); + await r.tick(); + assert.equal(r.promptState, null); + assert.ok(!r.browser.calls.includes("restart")); + assert.equal(r.attached, true); + assert.match(r.banner, /select a tab/); +}); + +test("observe-only is checked again when queued input executes", { skip: !canCdp }, async () => { + const r = attachRenderer(); + r.browser = fakeCdpBackend(); + await r.tick(); + let sent = false; + r.userAction(async () => { sent = true; }); + r.toggleObserveOnly(); + await flush(); + assert.equal(sent, false); }); test("observe-only: o toggles, page-affecting keys and clicks are dropped", { skip: !canCdp }, async () => { @@ -2446,16 +2603,11 @@ test("observe-only: u prompt is blocked; a Cmd+click handoff is consumed, not fo 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"); + assert.equal(r.targetPicker.targets.length, 2, "selecting the pane's own view stays allowed"); }); // --- Wave 5: Chromium launch mode --- From 859d05e5a85972f0822d652eb86d64e78ef7e431 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Tue, 15 Sep 2026 18:01:34 -0600 Subject: [PATCH 2/2] test: subscribe before navigating the browser stream fixture --- tests/renderer.test.mjs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/renderer.test.mjs b/tests/renderer.test.mjs index d35c004..1212e07 100644 --- a/tests/renderer.test.mjs +++ b/tests/renderer.test.mjs @@ -1413,17 +1413,25 @@ test("e2e: goLive receives pushed frames and console from a real session", { }); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const r = quiet(mkRenderer({ HERDR_BROWSER_SESSION: session })); + const streamEvents = {}; + const onStreamMessage = r.onStreamMessage.bind(r); + r.onStreamMessage = (message) => { + streamEvents[message.type] = (streamEvents[message.type] ?? 0) + 1; + onStreamMessage(message); + }; try { - await ab(["open", `http://127.0.0.1:${server.address().port}`]); + await ab(["open", "about:blank"]); // 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; assert.equal(await r.goLive(), true, "stream connects"); + // Subscribe before navigating so the test observes a new document's paint. + await ab(["open", `http://127.0.0.1:${server.address().port}`]); const deadline = Date.now() + 10_000; while (r.frameSeq === 0 && Date.now() < deadline) { await new Promise((res) => setTimeout(res, 100)); } - assert.ok(r.frameSeq > 0, "a screencast frame arrived"); + assert.ok(r.frameSeq > 0, `a screencast frame arrived: ${JSON.stringify({ streamEvents, banner: r.banner, paintErrors: r.paintErrors })}`); assert.ok( imageDims(fs.readFileSync(r.shotJpg)), "frame is a valid image on disk",