aboutsummaryrefslogtreecommitdiff
path: root/test
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 /test
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 'test')
-rw-r--r--test/manual/forms-test.ts149
-rw-r--r--test/manual/window-binding-test.ts239
2 files changed, 388 insertions, 0 deletions
diff --git a/test/manual/forms-test.ts b/test/manual/forms-test.ts
new file mode 100644
index 0000000..f1c79f6
--- /dev/null
+++ b/test/manual/forms-test.ts
@@ -0,0 +1,149 @@
+/**
+ * Manual integration test for the form-filling tools.
+ *
+ * Exercises browser_snapshot (ref surfacing) + browser_fill_form / browser_fill /
+ * browser_select_option / browser_set_checked against a data:-URL fixture that
+ * includes a plain input, a textarea, a "controlled" input (commits its model
+ * only on the `input` event — the realistic failure mode for naive value-setting),
+ * a native <select>, a checkbox, and a contenteditable.
+ *
+ * Requires the daemon running + a real Chrome (same as live-daemon-test.ts).
+ * Run: npx tsx test/manual/forms-test.ts
+ */
+import { createDaemonTransport } from "../../src/cdp/daemon-transport";
+import { createBrowserClient } from "../../src/client";
+import { fillTool, fillFormTool, selectOptionTool, setCheckedTool } from "../../src/domains/forms";
+
+// NOTE: we intentionally do NOT import snapshotTool here — it pulls in pi-tui /
+// pi-coding-agent value imports that don't resolve under bare tsx. Instead we
+// derive refs from Accessibility.getFullAXTree's backendDOMNodeId, which is the
+// exact value browser_snapshot surfaces as [ref=N]. The snapshot's [ref=N]
+// rendering is covered by `npm run typecheck` + the real-page smoke test.
+
+let passed = 0;
+let failed = 0;
+const check = (cond: boolean, label: string): void => {
+ if (cond) { passed++; console.log(` ✓ ${label}`); }
+ else { failed++; console.error(` ✗ ${label}`); }
+};
+
+// Minimal handler context — the form/snapshot handlers only read `client`.
+const mkCtx = (client: any): any => ({
+ client,
+ signal: undefined,
+ onUpdate: () => {},
+ extensionCtx: { cwd: process.cwd() },
+});
+
+const FIXTURE = `
+<!doctype html><html><body>
+ <h1>Form fixture</h1>
+ <input id="plain" aria-label="plain" />
+ <textarea id="ta" aria-label="ta"></textarea>
+ <input id="controlled" aria-label="controlled" />
+ <span id="model"></span>
+ <select id="sel" aria-label="country">
+ <option value="us">United States</option>
+ <option value="uk">United Kingdom</option>
+ <option value="ca">Canada</option>
+ </select>
+ <input type="checkbox" id="chk" aria-label="agree" />
+ <div id="editable" role="textbox" aria-label="bio" contenteditable="true"></div>
+ <script>
+ // Controlled input: its "model" updates ONLY when an input event fires.
+ // A naive value-set that skips the input event leaves the model stale.
+ var model = "";
+ var c = document.getElementById("controlled");
+ c.addEventListener("input", function () { model = c.value; document.getElementById("model").textContent = model; });
+ window.__model = function () { return model; };
+ </script>
+</body></html>`;
+
+// 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<number | undefined> => {
+ 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<void> {
+ 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<unknown> => {
+ 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("<body>gone</body>") });
+ 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<string, FakeTarget>;
+ closed: ReadonlyArray<string>;
+} => {
+ const targets = new Map<string, FakeTarget>();
+ 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<CdpEvent> = {
+ [Symbol.asyncIterator]() {
+ return {
+ async next(): Promise<IteratorResult<CdpEvent>> {
+ if (buffer.length === 0) {
+ await new Promise<void>((r) => { waiter = r; });
+ }
+ const v = buffer.shift()!;
+ return { value: v, done: false };
+ },
+ };
+ },
+ };
+
+ const request = async (
+ method: string,
+ params: Record<string, unknown>,
+ ): Promise<Result<unknown, CdpError>> => {
+ 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<void> => new Promise((r) => setTimeout(r, 20));
+
+async function main(): Promise<void> {
+ 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();