From fe00d3361dece3d35f788d3eeb1c750eedbf6294 Mon Sep 17 00:00:00 2001 From: Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> Date: Wed, 22 Jul 2026 00:59:58 +0530 Subject: feat: add forms domain and durable window binding (v0.9.0) (#14) - Add forms domain with a universal field setter that auto-detects element type and fires framework-compatible input events (React native-setter trick) - Add window-binding helper to capture and bind the real Chrome windowId as the session's durable window identity on every new-window code path - Wire supporting changes across CDP session/ownership, client, navigate, isolated-tab, prompt, and state - Add manual tests for forms and window binding - Bump version 0.8.3 -> 0.9.0 Co-authored-by: Claude Opus 4.8 (1M context) --- src/cdp/ownership.ts | 20 ++- src/cdp/session.ts | 55 +++++++ src/cdp/window-binding.ts | 15 ++ src/client.ts | 61 ++++++-- src/domains/forms.ts | 355 ++++++++++++++++++++++++++++++++++++++++++++ src/domains/isolated-tab.ts | 6 +- src/domains/navigate.ts | 35 +++-- src/index.ts | 17 ++- src/prompt.ts | 208 ++++++++++++-------------- src/state.ts | 1 + 10 files changed, 633 insertions(+), 140 deletions(-) create mode 100644 src/cdp/window-binding.ts create mode 100644 src/domains/forms.ts (limited to 'src') diff --git a/src/cdp/ownership.ts b/src/cdp/ownership.ts index f221d6a..727b3d8 100644 --- a/src/cdp/ownership.ts +++ b/src/cdp/ownership.ts @@ -20,14 +20,24 @@ export type OwnershipRegistry = { replaceAll(ids: ReadonlyArray): void; setHarnessWindow(targetId: string | undefined): void; harnessWindow(): string | undefined; + /** The real Chrome windowId of the dedicated harness window — the durable + * identity of "the window this session initialized", surviving seed-tab + * closure. Undefined until the window is created (or after teardown). */ + setHarnessWindowId(windowId: number | undefined): void; + harnessWindowId(): number | undefined; onChange(cb: () => void): void; }; export const createOwnershipRegistry = ( - initial?: { readonly ownedTargetIds?: ReadonlyArray; readonly harnessWindowTargetId?: string }, + initial?: { + readonly ownedTargetIds?: ReadonlyArray; + readonly harnessWindowTargetId?: string; + readonly harnessWindowId?: number; + }, ): OwnershipRegistry => { const owned = new Set(initial?.ownedTargetIds ?? []); let harnessWindow: string | undefined = initial?.harnessWindowTargetId; + let harnessWindowId: number | undefined = initial?.harnessWindowId; let listener: (() => void) | null = null; const notify = (): void => { @@ -65,6 +75,14 @@ export const createOwnershipRegistry = ( harnessWindow() { return harnessWindow; }, + setHarnessWindowId(windowId) { + if (harnessWindowId === windowId) return; + harnessWindowId = windowId; + notify(); + }, + harnessWindowId() { + return harnessWindowId; + }, onChange(cb) { listener = cb; }, diff --git a/src/cdp/session.ts b/src/cdp/session.ts index e090858..03410ba 100644 --- a/src/cdp/session.ts +++ b/src/cdp/session.ts @@ -92,6 +92,17 @@ export const createCdpSession = ( const tab = targetId ? tabs.get(targetId) : undefined; if (tab) tab.pageInfoDirty = true; } + if (ev.method === "Target.targetCreated" && ownership) { + // Adopt tabs opened BY a tab we already own (window.open, target=_blank, + // popups). Chrome sets openerId to the opener target; if that opener is + // owned, the child belongs to this session's window and must be + // controllable + cleaned up. Targets the user opens themselves have no + // owned opener, so they are never adopted. + const info = (ev.params as { targetInfo?: { targetId?: string; type?: string; openerId?: string } } | undefined)?.targetInfo; + if (info?.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) { + ownership.add(info.targetId); + } + } if (ev.method === "Target.targetDestroyed" && ownership) { const params = ev.params as { targetId?: string } | undefined; if (params?.targetId) { @@ -179,6 +190,43 @@ export const createCdpSession = ( if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors); const hw = ownership.harnessWindow(); if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined); + + if (survivors.length === 0) { + // No owned tab survived — the harness window is gone (user closed it, + // or Chrome restarted and reassigned all ids). The persisted windowId + // is now meaningless and could even collide with one of the user's + // windows, so drop it. attachFirstPage will mint a fresh window below. + ownership.setHarnessWindowId(undefined); + } else { + // Self-heal ownership from the window itself. We anchor on a tab we + // KNOW we own (a survivor) and re-adopt only its live window-mates — + // popups/children from last session, tabs reopened in our window. + // Ownership is thus DERIVED from the real window, never from the bare + // persisted integer (which Chrome may reassign to a user window on + // restart — anchoring on a survivor makes that misfire impossible). + const anchorId = survivors[0]!; + const anchorWin = await transport.request("Browser.getWindowForTarget", { targetId: anchorId }, { sessionId: null }); + if (anchorWin.success) { + const anchorWindowId = (anchorWin.data as { windowId?: number }).windowId; + if (typeof anchorWindowId === "number") { + ownership.setHarnessWindowId(anchorWindowId); // refresh to the live id + const windows = await Promise.all( + allPages.map((p) => + p.targetId === anchorId + ? Promise.resolve(anchorWin) + : transport.request("Browser.getWindowForTarget", { targetId: p.targetId }, { sessionId: null }), + ), + ); + allPages.forEach((p, i) => { + const r = windows[i]; + if (r?.success && (r.data as { windowId?: number }).windowId === anchorWindowId) { + ownership.add(p.targetId); + } + }); + } + } + if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId); + } } // Prune per-tab state for pages that no longer exist const liveTargetIds = new Set(allPages.map((p) => p.targetId)); @@ -208,6 +256,13 @@ export const createCdpSession = ( if (ownership) { ownership.setHarnessWindow(c.targetId); ownership.add(c.targetId); + // Capture the real Chrome windowId — the durable identity of the + // window this session initialized (survives seed-tab closure). + const win = await transport.request("Browser.getWindowForTarget", { targetId: c.targetId }, { sessionId: null }); + if (win.success) { + const wid = (win.data as { windowId?: number }).windowId; + if (typeof wid === "number") ownership.setHarnessWindowId(wid); + } } } diff --git a/src/cdp/window-binding.ts b/src/cdp/window-binding.ts new file mode 100644 index 0000000..4ae1ac3 --- /dev/null +++ b/src/cdp/window-binding.ts @@ -0,0 +1,15 @@ +import type { BrowserClient } from "../client"; + +/** + * Capture the real Chrome windowId of a freshly-created harness window and bind + * it as the session's durable window identity. Best-effort: a failed CDP call + * leaves the previous binding untouched. Call this on every code path that + * spawns a NEW dedicated window (newWindow:true), so the windowId never points + * at a window that has since been replaced. + */ +export const bindHarnessWindowId = async (client: BrowserClient, targetId: string): Promise => { + const r = await client.session().callBrowser("Browser.getWindowForTarget", { targetId }); + if (!r.success) return; + const wid = (r.data as { windowId?: number }).windowId; + if (typeof wid === "number") client.ownership().setHarnessWindowId(wid); +}; diff --git a/src/client.ts b/src/client.ts index 724a711..9a97369 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,10 +16,12 @@ export type BrowserClientOptions = { readonly initialOwnership?: { readonly ownedTargetIds?: ReadonlyArray; readonly harnessWindowTargetId?: string; + readonly harnessWindowId?: number; }; readonly onOwnershipChange?: (snapshot: { readonly ownedTargetIds: ReadonlyArray; readonly harnessWindowTargetId: string | undefined; + readonly harnessWindowId: number | undefined; }) => void; }; @@ -32,6 +34,9 @@ export type BrowserClient = { /** Detach from the current page target (removes the "Chrome is being controlled" banner) * while keeping the transport connection alive. Call on session shutdown. */ detach(): Promise; + /** Close every tab this session owns and clear the window binding. Best-effort — + * used on session shutdown so no stale harness tabs survive. */ + closeOwnedTabs(): Promise; evaluateJs(expression: string, sessionId?: string): Promise>; pageInfo(): Promise>; takeDialog(): DialogInfo | null; @@ -82,13 +87,16 @@ const parsePageInfoPayload = (v: unknown): Result => { export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => { const transport = opts.transport ?? createCdpTransport(); - const ownershipInit: { ownedTargetIds?: ReadonlyArray; harnessWindowTargetId?: string } = {}; + const ownershipInit: { ownedTargetIds?: ReadonlyArray; harnessWindowTargetId?: string; harnessWindowId?: number } = {}; if (opts.initialOwnership?.ownedTargetIds !== undefined) { ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds; } if (opts.initialOwnership?.harnessWindowTargetId !== undefined) { ownershipInit.harnessWindowTargetId = opts.initialOwnership.harnessWindowTargetId; } + if (opts.initialOwnership?.harnessWindowId !== undefined) { + ownershipInit.harnessWindowId = opts.initialOwnership.harnessWindowId; + } const ownership = createOwnershipRegistry(ownershipInit); if (opts.onOwnershipChange) { const cb = opts.onOwnershipChange; @@ -96,6 +104,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = cb({ ownedTargetIds: ownership.list(), harnessWindowTargetId: ownership.harnessWindow(), + harnessWindowId: ownership.harnessWindowId(), }); }); } @@ -256,6 +265,15 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = return ok(undefined); }; + // Ask Chrome for the real windowId hosting a target. Undefined on failure — + // callers treat the window binding as best-effort. + const getWindowId = async (targetId: string): Promise => { + const r = await session.callBrowser("Browser.getWindowForTarget", { targetId }); + if (!r.success) return undefined; + const wid = (r.data as { windowId?: number }).windowId; + return typeof wid === "number" ? wid : undefined; + }; + const newTab = async (url?: string): Promise> => { // Verify the recorded harness window still exists. Querying the live // target list also gives listTabs's reconciliation a chance to run. @@ -263,22 +281,35 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = if (!tabsResult.success) return tabsResult; const live = new Set(tabsResult.data.map((t) => t.targetId)); const hw = ownership.harnessWindow(); + // Pick an opener that keeps us inside the harness window. Prefer the seed + // tab; if the user closed it but other owned tabs survive, reuse one of + // those instead of spawning a second window (avoids window scatter). + const opener = hw && live.has(hw) + ? hw + : ownership.list().find((id) => live.has(id)); const params: Record = { url: "about:blank" }; - if (hw && live.has(hw)) { - // Open as a child of the harness window's seed tab — Chrome places - // it in the same window, giving us visual grouping for free. - params["openerId"] = hw; + if (opener) { + // Open as a child of an owned tab — Chrome places it in the same window, + // giving us visual grouping for free and keeping the session single-window. + params["openerId"] = opener; } else { - // Either fresh session or the harness window was closed by the user. - // Spawn a new dedicated window. + // Fresh session, or the whole harness window was closed by the user. + // Spawn a new dedicated window and rebind to it. params["newWindow"] = true; } const created = await session.callBrowser("Target.createTarget", params); if (!created.success) return created; const c = created.data as { targetId: string }; - if (params["newWindow"]) ownership.setHarnessWindow(c.targetId); + if (params["newWindow"]) { + ownership.setHarnessWindow(c.targetId); + const wid = await getWindowId(c.targetId); + if (wid !== undefined) ownership.setHarnessWindowId(wid); + } else if (opener && opener !== hw) { + // Re-seeded onto a surviving owned tab; keep the seed pointer live. + ownership.setHarnessWindow(opener); + } ownership.add(c.targetId); const switched = await switchTab(c.targetId); @@ -297,6 +328,18 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = return ok(undefined); }; + const closeOwnedTabs = async (): Promise => { + // Snapshot first — remove() mutates the list, and targetDestroyed events + // may also prune concurrently. Failures are swallowed: a tab the user + // already closed, or a dead transport, must not block shutdown. + for (const id of ownership.list()) { + await session.callBrowser("Target.closeTarget", { targetId: id }).catch(() => {}); + ownership.remove(id); + } + ownership.setHarnessWindow(undefined); + ownership.setHarnessWindowId(undefined); + }; + const status = (): DaemonStatus => ({ alive: transport.state() === "open" && session.current() !== null, sessionId: session.current()?.sessionId ?? null, @@ -316,7 +359,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = return { namespace: opts.namespace, - ensureAlive, status, start, stop, detach, + ensureAlive, status, start, stop, detach, closeOwnedTabs, evaluateJs, pageInfo, takeDialog: () => session.takeDialog(), listTabs, switchTab, newTab, closeTab, diff --git a/src/domains/forms.ts b/src/domains/forms.ts new file mode 100644 index 0000000..1d83ba6 --- /dev/null +++ b/src/domains/forms.ts @@ -0,0 +1,355 @@ +import { Type } from "typebox"; +import type { BrowserClient } from "../client"; +import { type Result, err, ok } from "../util/result"; +import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; + +// ── Page-side functions ────────────────────────────────────────────────────── +// These run inside the page via Runtime.callFunctionOn with `this` bound to the +// resolved element. They are NOT string-interpolated with user data — the value +// is passed as a structured `arguments` entry, so safeJs is unnecessary here. + +/** + * Universal field setter. Auto-detects the element type and applies the right + * strategy, then reads the resulting value back so the caller can confirm. + * + * The /