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) --- .pi/settings.json | 6 +- package-lock.json | 4 +- package.json | 2 +- 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 + test/manual/forms-test.ts | 149 ++++++++++++++++ test/manual/window-binding-test.ts | 239 +++++++++++++++++++++++++ 15 files changed, 1027 insertions(+), 146 deletions(-) create mode 100644 src/cdp/window-binding.ts create mode 100644 src/domains/forms.ts create mode 100644 test/manual/forms-test.ts create mode 100644 test/manual/window-binding-test.ts diff --git a/.pi/settings.json b/.pi/settings.json index 59c7b93..d2f3b27 100644 --- a/.pi/settings.json +++ b/.pi/settings.json @@ -1,4 +1,4 @@ { - "extensions": ["../src/index.ts"], - "skills": ["../skills"] -} + "extensions": ["./src/index.ts"], + "skills":["./skills"] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6542fd6..25e866c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pi-browser-harness", - "version": "0.8.3", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pi-browser-harness", - "version": "0.8.3", + "version": "0.9.0", "license": "MIT", "dependencies": { "ws": "^8.20.0" diff --git a/package.json b/package.json index 1efe449..780db4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-browser-harness", - "version": "0.8.3", + "version": "0.9.0", "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP", "keywords": [ "pi-package", 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 / + + + + +
+ +`; + +// Resolve a ref by accessible name via the AX tree — the same backendDOMNodeId +// that browser_snapshot prints as [ref=N]. +const refByName = async (client: any, name: string): Promise => { + const ax = await client.session().call("Accessibility.getFullAXTree", {}); + if (!ax.success) return undefined; + for (const n of (ax.data as any).nodes as any[]) { + if (n?.name?.value === name && typeof n.backendDOMNodeId === "number") return n.backendDOMNodeId; + } + return undefined; +}; + +async function main(): Promise { + const transport = createDaemonTransport("pi-forms-test"); + const client = createBrowserClient({ namespace: "pi-forms-test", transport }); + const ctx = mkCtx(client); + + const started = await client.start(); + if (!started.success) { console.error("Could not start client:", started.error.message); process.exit(1); } + + const url = "data:text/html," + encodeURIComponent(FIXTURE); + const tab = await client.newTab(url); + check(tab.success, `Opened fixture tab: ${tab.success ? "ok" : tab.error.message}`); + // Give the inline script a beat to wire up listeners. + await new Promise((r) => setTimeout(r, 300)); + + // ── Resolve refs (the backendDOMNodeId browser_snapshot prints as [ref=N]) ── + const refs = { + plain: await refByName(client, "plain"), + ta: await refByName(client, "ta"), + controlled: await refByName(client, "controlled"), + sel: await refByName(client, "country"), + chk: await refByName(client, "agree"), + editable: await refByName(client, "bio"), + }; + for (const [k, v] of Object.entries(refs)) check(typeof v === "number", `ref present for ${k} (${v})`); + + // ── Batch fill text fields ────────────────────────────────────────────────── + const fill = await fillFormTool.handler({ + fields: [ + { ref: refs.plain!, value: "hello plain" }, + { ref: refs.ta!, value: "multi\nline" }, + { ref: refs.controlled!, value: "ctrl-value" }, + { ref: refs.editable!, value: "my bio" }, + ], + }, ctx); + check(fill.success, "browser_fill_form succeeded"); + if (fill.success) console.log(" " + fill.data.text); + + // ── Select + checkbox ─────────────────────────────────────────────────────── + const sel = await selectOptionTool.handler({ ref: refs.sel!, label: "Canada" }, ctx); + check(sel.success, `browser_select_option by label: ${sel.success ? sel.data.text : (sel as any).error.message}`); + const chk = await setCheckedTool.handler({ ref: refs.chk!, checked: true }, ctx); + check(chk.success, `browser_set_checked: ${chk.success ? chk.data.text : (chk as any).error.message}`); + + // ── Verify committed state via JS reads ───────────────────────────────────── + const read = async (expr: string): Promise => { + const r = await client.evaluateJs(expr); + return r.success ? r.data : `ERR:${r.error.message}`; + }; + check((await read("document.getElementById('plain').value")) === "hello plain", "plain input value committed"); + check((await read("document.getElementById('ta').value")) === "multi\nline", "textarea value committed"); + // The controlled input's MODEL (input-event-driven) must reflect the fill — + // proves browser_fill fired the input event, not just set the DOM value. + check((await read("window.__model()")) === "ctrl-value", "controlled input model committed (input event fired)"); + check((await read("document.getElementById('editable').textContent")) === "my bio", "contenteditable text committed"); + check((await read("document.getElementById('sel').value")) === "ca", "select value committed"); + check((await read("document.getElementById('chk').checked")) === true, "checkbox checked committed"); + + // ── No-matching-option path ───────────────────────────────────────────────── + const badSel = await selectOptionTool.handler({ ref: refs.sel!, label: "Atlantis" }, ctx); + check(!badSel.success && (badSel as any).error.kind === "invalid_state", "select with no match → invalid_state error"); + + // ── Stale ref path ────────────────────────────────────────────────────────── + const staleRef = refs.plain!; + const nav = await client.session().call("Page.navigate", { url: "data:text/html," + encodeURIComponent("gone") }); + check(nav.success, "navigated away to invalidate refs"); + await new Promise((r) => setTimeout(r, 300)); + const stale = await fillTool.handler({ ref: staleRef, value: "x" }, ctx); + check(!stale.success && (stale as any).error.kind === "invalid_state", "fill on stale ref → invalid_state error"); + + await client.closeTab(tab.success ? (tab.data as string) : ""); + await client.stop(); + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/test/manual/window-binding-test.ts b/test/manual/window-binding-test.ts new file mode 100644 index 0000000..d743ae5 --- /dev/null +++ b/test/manual/window-binding-test.ts @@ -0,0 +1,239 @@ +/** + * Unit test (mock transport) for window-scoped session binding + tab teardown. + * + * Covers the two fixes: + * 1. The session binds to a real Chrome windowId. New tabs land in that window; + * tabs opened by an owned page (popups / window.open) are auto-adopted so the + * session controls them; nothing outside the window is ever owned. + * 2. closeOwnedTabs() closes every owned tab and clears the window binding — the + * teardown the extension runs on session shutdown so no stale tabs survive. + * + * Run: npx tsx test/manual/window-binding-test.ts + */ +import { createBrowserClient } from "../../src/client"; +import { createOwnershipRegistry } from "../../src/cdp/ownership"; +import type { CdpTransport } from "../../src/cdp/transport"; +import type { CdpEvent } from "../../src/cdp/types"; +import { ok, type Result } from "../../src/util/result"; +import type { CdpError } from "../../src/cdp/errors"; + +let passed = 0; +let failed = 0; +const check = (cond: boolean, label: string): void => { + if (cond) { passed++; console.log(` ✓ ${label}`); } + else { failed++; console.error(` ✗ ${label}`); } +}; + +// ── A minimal in-memory CDP browser the mock transport drives. ────────────── +type FakeTarget = { targetId: string; type: string; url: string; title: string; windowId: number; openerId?: string }; + +const createMockTransport = (): CdpTransport & { + seed: (t: FakeTarget) => void; + targets: Map; + closed: ReadonlyArray; +} => { + const targets = new Map(); + const closed: string[] = []; + let sessionSeq = 0; + let targetSeq = 0; + let windowSeq = 100; + + // Event queue plumbing so session.ts's consumeEvents() can await targetCreated. + const listeners: Array<() => void> = []; + let buffer: CdpEvent[] = []; + let waiter: (() => void) | null = null; + const push = (ev: CdpEvent): void => { + buffer.push(ev); + if (waiter) { const w = waiter; waiter = null; w(); } + }; + const iterable: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + async next(): Promise> { + if (buffer.length === 0) { + await new Promise((r) => { waiter = r; }); + } + const v = buffer.shift()!; + return { value: v, done: false }; + }, + }; + }, + }; + + const request = async ( + method: string, + params: Record, + ): Promise> => { + switch (method) { + case "Target.setDiscoverTargets": + case "Page.enable": case "DOM.enable": case "Runtime.enable": + case "Network.enable": case "Accessibility.enable": case "Log.enable": + case "Target.activateTarget": + return ok({}); + case "Target.getTargets": + return ok({ targetInfos: [...targets.values()].map((t) => ({ targetId: t.targetId, type: t.type, url: t.url, title: t.title })) }); + case "Browser.getWindowForTarget": { + const t = targets.get(params["targetId"] as string); + return ok({ windowId: t?.windowId ?? -1, bounds: {} }); + } + case "Target.createTarget": { + const id = `tgt-${++targetSeq}`; + const opener = params["openerId"] as string | undefined; + const newWindow = params["newWindow"] === true; + const windowId = newWindow || !opener ? ++windowSeq : (targets.get(opener)?.windowId ?? ++windowSeq); + const t: FakeTarget = { targetId: id, type: "page", url: (params["url"] as string) ?? "about:blank", title: "", windowId, ...(opener ? { openerId: opener } : {}) }; + targets.set(id, t); + // Chrome fires targetCreated for every new target (discover:true). + push({ method: "Target.targetCreated", params: { targetInfo: { targetId: id, type: "page", url: t.url, openerId: opener } } }); + return ok({ targetId: id }); + } + case "Target.attachToTarget": + return ok({ sessionId: `sess-${++sessionSeq}` }); + case "Target.detachFromTarget": + return ok({}); + case "Target.closeTarget": { + const id = params["targetId"] as string; + if (targets.delete(id)) { closed.push(id); push({ method: "Target.targetDestroyed", params: { targetId: id } }); } + return ok({}); + } + case "Runtime.evaluate": + return ok({ result: { value: "complete" } }); + case "Page.navigate": + return ok({ frameId: "f1" }); + default: + return ok({}); + } + }; + + return { + connect: async () => ok(undefined), + close: async () => { for (const l of listeners) l(); }, + request, + events: () => iterable, + state: () => "open", + onClose: (cb) => { listeners.push(cb); return () => {}; }, + seed: (t) => targets.set(t.targetId, t), + targets, + get closed() { return closed; }, + }; +}; + +const tick = (): Promise => new Promise((r) => setTimeout(r, 20)); + +async function main(): Promise { + console.log("window-binding-test\n"); + + // ── Registry: windowId binding is stored + cleared. ─────────────────────── + { + const reg = createOwnershipRegistry(); + check(reg.harnessWindowId() === undefined, "windowId starts undefined"); + reg.setHarnessWindowId(101); + check(reg.harnessWindowId() === 101, "setHarnessWindowId stores the id"); + let notified = 0; + reg.onChange(() => { notified++; }); + reg.setHarnessWindowId(101); + check(notified === 0, "setting the same windowId does not notify"); + reg.setHarnessWindowId(undefined); + check(reg.harnessWindowId() === undefined && notified === 1, "clearing windowId notifies"); + } + + // ── newTab: creates the dedicated window and captures its real windowId. ── + { + const transport = createMockTransport(); + const client = createBrowserClient({ namespace: "t", transport }); + await client.start(); + const wid = client.ownership().harnessWindowId(); + check(typeof wid === "number", "attach captures a real windowId for the harness window"); + + const first = await client.newTab("https://a.test"); + check(first.success, "newTab succeeds"); + if (first.success) { + const t = transport.targets.get(first.data)!; + check(t.windowId === wid, "new tab lands in the harness window (via openerId)"); + check(client.owns(first.data), "new tab is owned"); + } + } + + // ── Popup adoption: a tab opened by an owned page is auto-owned. ────────── + { + const transport = createMockTransport(); + const client = createBrowserClient({ namespace: "t", transport }); + await client.start(); + const cur = client.current()!; + + // A page the session controls opens a child target (window.open / _blank). + // Chrome emits Target.targetCreated with openerId = our owned tab; the + // session must adopt it so the tab is controllable and cleaned up later. + const child = await client.session().callBrowser("Target.createTarget", { url: "https://child.test", openerId: cur.targetId }); + await tick(); + check(child.success, "owned page opens a child target"); + if (child.success) { + const id = (child.data as { targetId: string }).targetId; + check(client.owns(id), "child target opened by an owned tab is auto-adopted"); + } + + // A target the user opens in their own window has no owned opener → never adopted. + const stranger = await client.session().callBrowser("Target.createTarget", { url: "https://user.test", newWindow: true }); + await tick(); + if (stranger.success) { + const id = (stranger.data as { targetId: string }).targetId; + check(!client.owns(id), "target with no owned opener is not adopted"); + } + } + + // ── Reattach self-heal: re-adopt live window-mates of a surviving owned tab. ─ + { + const transport = createMockTransport(); + transport.seed({ targetId: "own-1", type: "page", url: "https://a.test", title: "", windowId: 500 }); + transport.seed({ targetId: "sib-2", type: "page", url: "https://b.test", title: "", windowId: 500, openerId: "own-1" }); + transport.seed({ targetId: "user-9", type: "page", url: "https://user.test", title: "", windowId: 700 }); + const client = createBrowserClient({ + namespace: "t", + transport, + initialOwnership: { ownedTargetIds: ["own-1"], harnessWindowTargetId: "own-1", harnessWindowId: 500 }, + }); + await client.start(); + check(client.owns("own-1"), "reattach: persisted owned tab stays owned"); + check(client.owns("sib-2"), "reattach: live window-mate (last session's popup) is re-adopted"); + check(!client.owns("user-9"), "reattach: a tab in a DIFFERENT window is never adopted"); + } + + // ── Restart-collision guard: stale windowId must not adopt the user's tabs. ─ + { + const transport = createMockTransport(); + // Chrome restarted: our owned tab id is dead; windowId 500 now belongs to + // the user's own window full of their tabs. + transport.seed({ targetId: "user-a", type: "page", url: "https://user-a.test", title: "", windowId: 500 }); + transport.seed({ targetId: "user-b", type: "page", url: "https://user-b.test", title: "", windowId: 500 }); + const client = createBrowserClient({ + namespace: "t", + transport, + initialOwnership: { ownedTargetIds: ["dead-1"], harnessWindowTargetId: "dead-1", harnessWindowId: 500 }, + }); + await client.start(); + check(!client.owns("user-a") && !client.owns("user-b"), "restart: stale windowId does NOT adopt the user's tabs"); + check(client.ownership().harnessWindowId() !== 500, "restart: binds a fresh window instead of the colliding id"); + check(client.current() !== null && !["user-a", "user-b"].includes(client.current()!.targetId), "restart: attaches to a fresh harness tab, not a user tab"); + } + + // ── Teardown: closeOwnedTabs closes every owned tab + clears binding. ───── + { + const transport = createMockTransport(); + const client = createBrowserClient({ namespace: "t", transport }); + await client.start(); + await client.newTab("https://a.test"); + await client.newTab("https://b.test"); + const ownedBefore = client.ownership().list(); + check(ownedBefore.length >= 2, "several owned tabs before teardown"); + + await client.closeOwnedTabs(); + check(client.ownership().list().length === 0, "closeOwnedTabs clears the owned set"); + check(client.ownership().harnessWindowId() === undefined, "closeOwnedTabs clears the windowId binding"); + check(ownedBefore.every((id) => transport.closed.includes(id)), "every owned tab was actually closed in the browser"); + } + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +} + +void main(); -- cgit v1.2.3