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..1212e07 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,21 +1409,29 @@ 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 })); + 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", @@ -1331,6 +1440,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 +1635,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 +1644,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 +2190,7 @@ const fakeCdpBackend = (over = {}) => { port: "9222", guid: "guid-1", browser: "Chrome/150", + targetId: "T1", url: "https://x/", title: "X", rediscoverable: true, @@ -2086,6 +2201,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 +2477,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 +2611,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 ---