aboutsummaryrefslogtreecommitdiff
path: root/src/cdp
diff options
context:
space:
mode:
authorAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-07-22 00:59:58 +0530
committerGitHub <noreply@github.com>2026-07-22 00:59:58 +0530
commitfe00d3361dece3d35f788d3eeb1c750eedbf6294 (patch)
treea51b8ffe3cddb1aa8d457784f71cc1b2b0b61d56 /src/cdp
parent7de0beaf2cf713afc50c573e941fa279ec6df9d9 (diff)
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) <noreply@anthropic.com>
Diffstat (limited to 'src/cdp')
-rw-r--r--src/cdp/ownership.ts20
-rw-r--r--src/cdp/session.ts55
-rw-r--r--src/cdp/window-binding.ts15
3 files changed, 89 insertions, 1 deletions
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<string>): 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<string>; readonly harnessWindowTargetId?: string },
+ initial?: {
+ readonly ownedTargetIds?: ReadonlyArray<string>;
+ readonly harnessWindowTargetId?: string;
+ readonly harnessWindowId?: number;
+ },
): OwnershipRegistry => {
const owned = new Set<string>(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<void> => {
+ 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);
+};