cdp-toolkit 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -35,8 +35,34 @@
35
35
  * ATOMICITY. Claims are exclusive creates (writeFile flag "wx"), exactly like
36
36
  * ../leases.ts claimLease, so "is the slot free, and if so take it" is atomic
37
37
  * against a racing process for free, with no registry lock.
38
+ *
39
+ * THE MUX: WHY A LIVE HOLDER IS NO LONGER A DEAD END. Everything above still
40
+ * holds — one process owns Firefox's single BiDi session — but the holder now
41
+ * also hosts a loopback BiDi multiplexer (./mux.ts) over that one real session
42
+ * and ADVERTISES its ws endpoint in this record (`muxEndpoint`). Two consequences
43
+ * for this module, and nothing else changes:
44
+ *
45
+ * - THE HOLDER advertises via advertiseMux() once its mux is listening. That is
46
+ * a rewrite of its OWN record, guarded by the same (pid, createdAt) ownership
47
+ * test release uses, and written atomically (tmp + rename) so a concurrent
48
+ * reader never sees a half-written record. Advertising is best-effort: a
49
+ * refusal (`advertised:false`) means the record is no longer ours and the
50
+ * holder simply runs alone.
51
+ * - A JOINER DOES NOT WAIT. When acquire finds a LIVE holder that carries a
52
+ * muxEndpoint, there is nothing to wait FOR: the slot will not free up (the
53
+ * holder is healthy) and it does not need to, because the mux serves that one
54
+ * session to as many processes as ask. So acquire returns { joinMux }
55
+ * immediately, with NO handle and nothing written to disk — the joiner holds
56
+ * no slot and must never release one. The record is re-read on every poll
57
+ * iteration, so a holder that took the slot a moment ago and advertises a beat
58
+ * later is joined as soon as it does, inside the existing wait window.
59
+ *
60
+ * A live holder with NO muxEndpoint (a foreign client, an older cdp-toolkit, or
61
+ * one running with CDP_FIREFOX_MUX=off) is still plain BUSY and still throws
62
+ * SessionSlotBusyError at the deadline — exactly today's behavior. CDP_FIREFOX_MUX=off
63
+ * makes acquire ignore muxEndpoint entirely, which is the opt-out.
38
64
  */
39
- import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
65
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
40
66
  import { join } from "node:path";
41
67
  import { isPidAlive, leaseDir } from "../leases.ts";
42
68
 
@@ -55,6 +81,12 @@ export interface SessionSlotRecord {
55
81
  createdAt: number;
56
82
  /** Optional Marionette control port bound to this Firefox, when known. */
57
83
  marionettePort?: number;
84
+ /**
85
+ * Loopback ws endpoint of the BiDi multiplexer this holder hosts over the one
86
+ * real session (see ./mux.ts and the header). Absent means "this holder serves
87
+ * nobody but itself", which is what every pre-mux client's record looks like.
88
+ */
89
+ muxEndpoint?: string;
58
90
  }
59
91
 
60
92
  /**
@@ -68,7 +100,11 @@ export interface SessionSlotHandle {
68
100
  }
69
101
 
70
102
  export interface AcquireResult {
71
- handle: SessionSlotHandle;
103
+ /**
104
+ * Present iff WE took the slot. Absent on the joinMux path: a joiner owns no
105
+ * slot, wrote no record, and must never release one.
106
+ */
107
+ handle?: SessionSlotHandle;
72
108
  /**
73
109
  * Set only when we STOLE a dead holder's slot. Its presence tells the caller
74
110
  * that Firefox may still believe an orphaned BiDi session is active, so the
@@ -77,6 +113,12 @@ export interface AcquireResult {
77
113
  * sessions". Absent on a clean first acquire.
78
114
  */
79
115
  staleHolder?: SessionSlotRecord;
116
+ /**
117
+ * Set instead of `handle` when a LIVE holder advertises a BiDi multiplexer.
118
+ * The caller dials `endpoint` exactly as it would dial Firefox; `holder` is the
119
+ * record it came from, for error reporting. Nothing was written to disk.
120
+ */
121
+ joinMux?: { endpoint: string; holder: SessionSlotRecord };
80
122
  }
81
123
 
82
124
  export interface AcquireOptions {
@@ -105,6 +147,16 @@ export function sessionSlotFile(endpoint: string): string {
105
147
  return join(leaseDir(), `ff-session-${safeEndpoint(endpoint)}.json`);
106
148
  }
107
149
 
150
+ /**
151
+ * Where the detached mux DAEMON (./mux-daemon.ts) appends its one-line diagnostics for this
152
+ * endpoint. It is spawned with stdio:"ignore" (no console at all), so this file is the ONLY
153
+ * record of why a daemon refused to start; the driver names it in the error it raises when a
154
+ * daemon it spawned never advertised. Sibling of the slot file, same safeEndpoint key.
155
+ */
156
+ export function muxDaemonLogFile(endpoint: string): string {
157
+ return join(leaseDir(), `ff-mux-${safeEndpoint(endpoint)}.log`);
158
+ }
159
+
108
160
  /** Read per call (like leaseDir/leaseTtlMs), so a test can redirect it. A value
109
161
  * that is not a finite number > 0 falls back to the default. */
110
162
  export function sessionWaitMs(): number {
@@ -236,7 +288,16 @@ export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions)
236
288
  }
237
289
  }
238
290
 
239
- // 4. LIVE holder: legitimate contention. Poll until the deadline, then fail.
291
+ // 4. LIVE holder. If it advertises a mux, JOIN it instead of waiting: the slot
292
+ // is never going to free up, and it does not have to — the holder's mux serves
293
+ // the one real session to every joiner. Return immediately, writing nothing.
294
+ // (The re-read at step 2 happens every iteration, so a holder that advertises a
295
+ // beat after taking the slot is picked up on the next poll.) CDP_FIREFOX_MUX=off
296
+ // opts out and restores the pre-mux busy-wait exactly.
297
+ if (typeof holder.muxEndpoint === "string" && holder.muxEndpoint.length > 0 && process.env.CDP_FIREFOX_MUX !== "off") {
298
+ return { joinMux: { endpoint: holder.muxEndpoint, holder } };
299
+ }
300
+ // No mux: legitimate contention. Poll until the deadline, then fail.
240
301
  lastLiveHolder = holder;
241
302
  const remaining = deadline - Date.now();
242
303
  if (remaining <= 0) throw new SessionSlotBusyError(endpoint, holder);
@@ -244,6 +305,37 @@ export async function acquireSessionSlot(endpoint: string, opts: AcquireOptions)
244
305
  }
245
306
  }
246
307
 
308
+ /**
309
+ * Publish this holder's BiDi multiplexer endpoint into its OWN slot record, so a
310
+ * contending process joins the mux instead of waiting out the slot.
311
+ *
312
+ * Ownership-guarded by the same (pid, createdAt) test releaseSessionSlot uses: if
313
+ * the record on disk is missing or belongs to someone else (we were stolen from
314
+ * as a dead-looking holder, or we already released), we refuse with
315
+ * { advertised: false } and leave the file byte-for-byte untouched rather than
316
+ * stamping our mux onto a stranger's claim.
317
+ *
318
+ * Atomic: the merged record is written to `<file>.tmp-<pid>` and renamed over the
319
+ * original, so a concurrent acquire's read sees either the old record or the new
320
+ * one, never a truncated one. Every other field is preserved.
321
+ */
322
+ export async function advertiseMux(handle: SessionSlotHandle, muxEndpoint: string): Promise<{ advertised: boolean }> {
323
+ const current = await readSessionSlot(handle.endpoint);
324
+ if (!current) return { advertised: false };
325
+ if (current.pid !== process.pid || current.createdAt !== handle.createdAt) return { advertised: false };
326
+ const file = sessionSlotFile(handle.endpoint);
327
+ const tmp = `${file}.tmp-${process.pid}`;
328
+ const next: SessionSlotRecord = { ...current, muxEndpoint };
329
+ try {
330
+ await writeFile(tmp, JSON.stringify(next), "utf8");
331
+ await rename(tmp, file);
332
+ } catch {
333
+ await unlink(tmp).catch(() => undefined);
334
+ return { advertised: false };
335
+ }
336
+ return { advertised: true };
337
+ }
338
+
247
339
  /**
248
340
  * Release the slot, but ONLY if the record on disk is still the one we wrote.
249
341
  *
package/src/cdp/driver.ts CHANGED
@@ -651,7 +651,7 @@ const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
651
651
  "emulate.mediaFeatures", "emulate.deviceMetrics", "emulate.networkConditions",
652
652
  "screenshot.fullPage", "screenshot.element", "screenshot.scale", "screenshot.renderSize", "screenshot.tile", "network.intercept",
653
653
  "snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
654
- "capture.screencast", "input.raw", "input.html5Drag",
654
+ "capture.screencast", "input.raw", "input.html5Drag", "emulate.focus",
655
655
  "browser.downloads", "browser.permissions", "worker.targets",
656
656
  ]);
657
657
  /* ---------------------------------- PageDriver ---------------------------------- */
package/src/driver.ts CHANGED
@@ -150,6 +150,7 @@ export type Capability =
150
150
  | "locate.xpath" // find a node by xpath
151
151
  | "input.raw" // raw single mouse-event dispatch (dispatch_mouse's move/down/up primitive); Chrome only
152
152
  | "input.html5Drag" // real HTML5 drag events (drag's mode:"html5"); Chrome only, see PageDriver.drag
153
+ | "emulate.focus" // fake page focus without moving the OS window (focus_emulation / click_focus_gated); Chrome only — BiDi's emulation module has no focus primitive
153
154
  | "browser.downloads" // capture a file download to a known path (wait_for_download); Chrome only
154
155
  | "browser.permissions" // grant/reset browser permissions for an origin (grant_permissions); Chrome only
155
156
  | "worker.targets"; // address a service/shared worker as a target ("worker:<substr>"), incl. waking an evicted MV3 worker; Chrome only
@@ -172,6 +173,8 @@ export const REQUIRED_CAPABILITIES: Partial<Record<ToolName, readonly Capability
172
173
  list_mocks: ["network.intercept"],
173
174
  clear_mocks: ["network.intercept"],
174
175
  dispatch_mouse: ["input.raw"],
176
+ focus_emulation: ["emulate.focus"],
177
+ click_focus_gated: ["emulate.focus"],
175
178
  wait_for_download: ["browser.downloads"],
176
179
  grant_permissions: ["browser.permissions"],
177
180
  } as const;
package/src/index.ts CHANGED
@@ -43,6 +43,8 @@ import { lighthouseAudit } from "./tools/lighthouse.ts";
43
43
  import { mockRequest, listMocks, clearMocks } from "./tools/network_mock.ts";
44
44
  // --- raw mouse dispatch (Input.dispatchMouseEvent, one event per call) : chrome-only, capability "input.raw" ---
45
45
  import { dispatchMouse } from "./tools/dispatch-mouse.ts";
46
+ // --- focus emulation + focus-gated click (Emulation.setFocusEmulationEnabled) : chrome-only, capability "emulate.focus" ---
47
+ import { focusEmulation, clickFocusGated } from "./tools/focus-emulation.ts";
46
48
  // --- downloads + permissions (browser-endpoint state on a standing connection) : chrome-only, 1.8.0 Track P3 ---
47
49
  import { waitForDownload } from "./tools/downloads.ts";
48
50
  import { grantPermissions } from "./tools/permissions.ts";
@@ -145,6 +147,10 @@ export const TOOLS = {
145
147
  clear_mocks: clearMocks,
146
148
  // raw mouse dispatch (1) : 1.8.0 Track P1 toolkit addition, chrome-only (capability "input.raw")
147
149
  dispatch_mouse: dispatchMouse,
150
+ // focus emulation (2) : fake page focus without stealing OS focus; trusted click on a focus-gated
151
+ // button (e.g. Claude Code OAuth Authorize). chrome-only (capability "emulate.focus")
152
+ focus_emulation: focusEmulation,
153
+ click_focus_gated: clickFocusGated,
148
154
  // downloads + permissions (2) : 1.8.0 Track P3 toolkit additions, chrome-only (capabilities
149
155
  // "browser.downloads" / "browser.permissions"). Both drive browser-endpoint state that Chrome
150
156
  // reverts when the issuing client disconnects, so both run on tools/browser-session.ts's standing
package/src/manifest.ts CHANGED
@@ -230,6 +230,7 @@ export const MANIFEST: ToolSpec[] = [
230
230
  },
231
231
  "args": {
232
232
  "type": "array",
233
+ "items": {},
233
234
  "description": "Positional JSON arguments."
234
235
  },
235
236
  "savePath": {
@@ -673,6 +674,70 @@ export const MANIFEST: ToolSpec[] = [
673
674
  "additionalProperties": false
674
675
  }
675
676
  },
677
+ {
678
+ "name": "focus_emulation",
679
+ "description": "Toggle page focus emulation: the page reports focused/visible without the OS window moving; Chrome-only.",
680
+ "inputSchema": {
681
+ "type": "object",
682
+ "properties": {
683
+ "lease": {
684
+ "type": "string",
685
+ "description": "Lease token; omit for a tab this process already holds. See server instructions."
686
+ },
687
+ "target": {
688
+ "type": "string",
689
+ "description": "Page selector (index:/url:/title:/label:/targetId, default active); grammar in server instructions."
690
+ },
691
+ "enabled": {
692
+ "type": "boolean",
693
+ "description": "true = page reports focused; false = restore real focus state."
694
+ }
695
+ },
696
+ "required": [
697
+ "enabled"
698
+ ],
699
+ "additionalProperties": false
700
+ }
701
+ },
702
+ {
703
+ "name": "click_focus_gated",
704
+ "description": "Emulate page focus, wait for a focus-gated button to enable, trusted-click it, restore; Chrome-only.",
705
+ "inputSchema": {
706
+ "type": "object",
707
+ "properties": {
708
+ "lease": {
709
+ "type": "string",
710
+ "description": "Lease token; omit for a tab this process already holds. See server instructions."
711
+ },
712
+ "target": {
713
+ "type": "string",
714
+ "description": "Page selector (index:/url:/title:/label:/targetId, default active); grammar in server instructions."
715
+ },
716
+ "selector": {
717
+ "type": "string",
718
+ "description": "CSS selector of the button; exactly one of selector or text."
719
+ },
720
+ "text": {
721
+ "type": "string",
722
+ "description": "Visible-text match for the button (e.g. \"Authorize\"); exactly one of selector or text."
723
+ },
724
+ "timeoutMs": {
725
+ "type": "number",
726
+ "description": "Max ms to poll for the button to become enabled. Default 30000."
727
+ },
728
+ "pollMs": {
729
+ "type": "number",
730
+ "description": "Poll interval ms. Default 500."
731
+ },
732
+ "keepFocus": {
733
+ "type": "boolean",
734
+ "description": "Leave focus emulation on after the click (default false = restored)."
735
+ }
736
+ },
737
+ "required": [],
738
+ "additionalProperties": false
739
+ }
740
+ },
676
741
  {
677
742
  "name": "wait_for_download",
678
743
  "description": "Arm or collect a browser download; arm capture before triggering the download.",
package/src/toolDocs.ts CHANGED
@@ -173,6 +173,26 @@ export const TOOL_DOCS: Record<string, ToolDoc> = {
173
173
  "modifiers": "Modifier keys held for this one event, e.g. ['Shift']."
174
174
  }
175
175
  },
176
+ "focus_emulation": {
177
+ "description": "Toggles CDP Emulation.setFocusEmulationEnabled on a tab: the page then BELIEVES it is focused (document.hasFocus()→true, visibilitychange stays 'visible', focus events fire) while the real OS window and active tab stay put — no focus theft. This is the primitive that unlocks focus-GATED UI (a button the site disables unless its tab is focused, e.g. Anthropic's Claude Code OAuth Authorize button on claude.ai/oauth/*) on a background tab. Chrome-only: WebDriver BiDi's emulation module has no focus primitive, so this tool is absent from tools/list under --browser firefox. Ports the core mechanism of the danbuhler/claude-code-auto-authorize extension. Remember to call with enabled:false to restore, or use click_focus_gated which restores automatically.",
178
+ "params": {
179
+ "lease": "Lease token from claim_page. Omit for a tab this process holds; required for a tab held by another process. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease). See server instructions.",
180
+ "target": "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'.",
181
+ "enabled": "true = page reports focused/visible; false = restore the real focus state."
182
+ }
183
+ },
184
+ "click_focus_gated": {
185
+ "description": "One-call compose of focus emulation + trusted click for a focus-gated button: enables Emulation.setFocusEmulationEnabled, polls until the button (located by CSS selector OR exact visible-text match, e.g. 'Authorize') exists AND is enabled, dispatches a trusted move/press/release click via Input.dispatchMouseEvent (isTrusted:true — a content script's synthesized events can never satisfy a focus gate), then restores focus emulation off unless keepFocus. Built for the Claude Code OAuth Authorize button but works on any focus-gated control on a background tab. Throws with a diagnostic ('never located' vs 'found but stayed disabled') on timeout. Chrome-only; absent under --browser firefox.",
186
+ "params": {
187
+ "lease": "Lease token from claim_page. Omit for a tab this process holds. Auto-acquired under CDP_REQUIRE_LEASE (then pass target, not lease).",
188
+ "target": "Page selector: 'active' (default), 'index:N', 'url:<substr>', 'title:<substr>', 'label:<name>', or a 32-hex '<targetId>'.",
189
+ "selector": "CSS selector of the button. Give exactly one of selector or text.",
190
+ "text": "Exact visible-text match of the button (trims whitespace, matches button/[role=button]/submit inputs), e.g. 'Authorize'. Give exactly one of selector or text.",
191
+ "timeoutMs": "Max ms to poll for the button to appear and become enabled. Default 30000.",
192
+ "pollMs": "Poll interval in ms. Default 500.",
193
+ "keepFocus": "Leave focus emulation ON after the click (default false — restored even on throw, so a failed click never strands the page fake-focused)."
194
+ }
195
+ },
176
196
  "wait_for_download": {
177
197
  "description": "Wait for a file download to finish and return it as a real file on disk: {path,suggestedFilename,bytes,url,target}, written under the artifact dir's downloads/. ORDERING RULE, not optional: capture must be ARMED BEFORE the click that starts the download — call wait_for_download{arm:true} first (returns {armed:true,downloadPath,pending}), then click, then call again to collect the file. The download-behavior override is per-connection state Chrome REVERTS the moment the arming client disconnects, and an unarmed headless Chrome denies the download outright, so one triggered before arming is lost. SIDE EFFECT, browser-global: arming redirects EVERY download in this browser (all tabs, all origins) into the toolkit's downloads dir until this server exits — they no longer land in the user's normal Downloads folder. The arm lives on a connection this server holds open (an MCP-server capability): under the one-shot CLI the connection dies with the process and nothing is captured. Chrome-only (capability 'browser.downloads'): absent from tools/list under Firefox.",
178
198
  "params": {
package/src/toolGroups.ts CHANGED
@@ -14,7 +14,7 @@ export const GROUP_TOOLS: Record<ToolGroup, readonly string[]> = {
14
14
  ],
15
15
  input: [
16
16
  "hover", "drag", "scroll", "dispatch_mouse", "press_key", "fill_form",
17
- "upload_file",
17
+ "upload_file", "focus_emulation", "click_focus_gated",
18
18
  ],
19
19
  cookies: ["list_cookies", "set_cookie", "delete_cookies"],
20
20
  network: ["list_network_requests", "get_network_request"],
@@ -0,0 +1,185 @@
1
+ /**
2
+ * focus-emulation.ts: `focus_emulation` + `click_focus_gated` — the Chrome CDP primitive behind
3
+ * driving a focus-gated page (a button the site disables unless the tab is focused) WITHOUT
4
+ * stealing OS window focus.
5
+ *
6
+ * WHY THIS EXISTS (the mechanism it ports)
7
+ * ========================================
8
+ * Anthropic's Claude Code OAuth page (claude.ai/oauth/*) gates its Authorize button behind
9
+ * document.hasFocus()/visibilitychange: a background tab reports hasFocus()===false and the
10
+ * button stays disabled, so an agent driving claude -p's login has to keep a human clicking.
11
+ * The danbuhler/claude-code-auto-authorize Chrome extension defeats it with two CDP primitives:
12
+ * 1. Emulation.setFocusEmulationEnabled {enabled:true} — the page BELIEVES it is focused
13
+ * (hasFocus()→true, visibilitychange stays "visible", focus events fire) while the real
14
+ * OS window stays put; and
15
+ * 2. Input.dispatchMouseEvent — a TRUSTED click (isTrusted:true) on that background tab,
16
+ * which a content script's synthesized events (isTrusted:false) can never produce.
17
+ * This module is that pair as toolkit tools: focus_emulation is the raw toggle;
18
+ * click_focus_gated composes toggle → locate-button → trusted-click → restore.
19
+ *
20
+ * WHY CHROME-ONLY (capability "emulate.focus", declared in ../driver.ts /
21
+ * ../cdp/driver.ts): WebDriver BiDi has no focus-emulation module (the emulation module covers
22
+ * geolocation/timezone/locale/scripting only), so under --browser firefox BOTH tools are absent
23
+ * from tools/list (ADR-001: absent, never present-and-throwing) — like dispatch_mouse and
24
+ * start_screen_recording.
25
+ *
26
+ * Lease gate: withPage (client.ts) → resolveTarget → assertLeaseOk("chrome", ...), the same
27
+ * choke point as every input tool.
28
+ */
29
+ import { sendInput } from "../activity.ts";
30
+ import { withPage } from "../client.ts";
31
+ import type { TargetSelector } from "../types.ts";
32
+
33
+ export interface FocusEmulationArgs {
34
+ target?: TargetSelector;
35
+ lease?: string;
36
+ /** true → the page reports focused; false → restore real focus state. */
37
+ enabled: boolean;
38
+ }
39
+
40
+ export interface FocusEmulationResult {
41
+ enabled: boolean;
42
+ }
43
+
44
+ export interface ClickFocusGatedArgs {
45
+ target?: TargetSelector;
46
+ lease?: string;
47
+ /** CSS selector of the button to click once focus is emulated. Exactly one of selector/text. */
48
+ selector?: string;
49
+ /** Visible-text match for the button (e.g. "Authorize"); exact-trim match, case-sensitive. */
50
+ text?: string;
51
+ /** Max ms to poll for the button to exist AND become enabled. Default 30000. */
52
+ timeoutMs?: number;
53
+ /** Poll interval ms. Default 500. */
54
+ pollMs?: number;
55
+ /** Leave focus emulation ON after the click (default false — always restored). */
56
+ keepFocus?: boolean;
57
+ }
58
+
59
+ export interface ClickFocusGatedResult {
60
+ clicked: boolean;
61
+ x: number;
62
+ y: number;
63
+ /** How the button was located. */
64
+ matchedBy: "selector" | "text";
65
+ /** ms from call to click. */
66
+ elapsedMs: number;
67
+ }
68
+
69
+ export function validateFocusEmulationArgs(args: FocusEmulationArgs): void {
70
+ if (typeof args.enabled !== "boolean") {
71
+ throw new Error("focus_emulation requires { enabled } of true or false");
72
+ }
73
+ }
74
+
75
+ export function validateClickFocusGatedArgs(args: ClickFocusGatedArgs): void {
76
+ const hasSel = typeof args.selector === "string" && args.selector.length > 0;
77
+ const hasText = typeof args.text === "string" && args.text.length > 0;
78
+ if (hasSel === hasText) {
79
+ throw new Error("click_focus_gated requires exactly one of { selector } or { text }");
80
+ }
81
+ if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
82
+ throw new Error("click_focus_gated: timeoutMs must be a positive number");
83
+ }
84
+ if (args.pollMs !== undefined && (!Number.isFinite(args.pollMs) || args.pollMs <= 0)) {
85
+ throw new Error("click_focus_gated: pollMs must be a positive number");
86
+ }
87
+ }
88
+
89
+ const FIND_BUTTON = `(() => {
90
+ const SEL = %SEL%;
91
+ const TEXT = %TEXT%;
92
+ let btn = null;
93
+ let matchedBy = null;
94
+ if (SEL) {
95
+ btn = document.querySelector(SEL);
96
+ if (btn) matchedBy = "selector";
97
+ }
98
+ if (!btn && TEXT) {
99
+ btn = Array.from(document.querySelectorAll("button,[role=button],input[type=submit]"))
100
+ .find((b) => (b.textContent || b.value || "").trim() === TEXT);
101
+ if (btn) matchedBy = "text";
102
+ }
103
+ if (!btn) return null;
104
+ const r = btn.getBoundingClientRect();
105
+ if (r.width === 0 || r.height === 0) return null;
106
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2,
107
+ disabled: !!btn.disabled || btn.getAttribute("aria-disabled") === "true",
108
+ matchedBy, hasFocus: document.hasFocus(), visibility: document.visibilityState };
109
+ })()`;
110
+
111
+ function findButtonExpression(selector: string | undefined, text: string | undefined): string {
112
+ return FIND_BUTTON.replace("%SEL%", JSON.stringify(selector ?? null)).replace(
113
+ "%TEXT%",
114
+ JSON.stringify(text ?? null),
115
+ );
116
+ }
117
+
118
+ /** Toggle page focus emulation. The page reports focused/visible without the OS window moving. */
119
+ export async function focusEmulation(args: FocusEmulationArgs): Promise<FocusEmulationResult> {
120
+ validateFocusEmulationArgs(args);
121
+ return withPage(
122
+ args.target,
123
+ async (conn) => {
124
+ await conn.send("Emulation.setFocusEmulationEnabled", { enabled: args.enabled });
125
+ return { enabled: args.enabled };
126
+ },
127
+ { lease: args.lease },
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Emulate focus, poll for a focus-gated button to become enabled, trusted-click it, restore.
133
+ * The restore runs in `finally` unless keepFocus — a thrown click never strands the page in a
134
+ * fake-focused state.
135
+ */
136
+ export async function clickFocusGated(args: ClickFocusGatedArgs): Promise<ClickFocusGatedResult> {
137
+ validateClickFocusGatedArgs(args);
138
+ const timeoutMs = args.timeoutMs ?? 30_000;
139
+ const pollMs = args.pollMs ?? 500;
140
+ const start = Date.now();
141
+ return withPage(
142
+ args.target,
143
+ async (conn, target) => {
144
+ await conn.send("Emulation.setFocusEmulationEnabled", { enabled: true });
145
+ try {
146
+ const expression = findButtonExpression(args.selector, args.text);
147
+ for (;;) {
148
+ const { result } = await conn.send<{ result: { value?: unknown } }>("Runtime.evaluate", {
149
+ expression,
150
+ returnByValue: true,
151
+ });
152
+ const btn = (result as { value?: unknown } | undefined)?.value as
153
+ | { x: number; y: number; disabled: boolean; matchedBy: "selector" | "text" }
154
+ | null
155
+ | undefined;
156
+ if (btn && !btn.disabled) {
157
+ const base = { x: btn.x, y: btn.y, button: "left", buttons: 1, clickCount: 1 };
158
+ await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x: btn.x, y: btn.y });
159
+ await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mousePressed" });
160
+ await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased" });
161
+ return {
162
+ clicked: true,
163
+ x: btn.x,
164
+ y: btn.y,
165
+ matchedBy: btn.matchedBy,
166
+ elapsedMs: Date.now() - start,
167
+ };
168
+ }
169
+ if (Date.now() - start >= timeoutMs) {
170
+ throw new Error(
171
+ `click_focus_gated: button never became clickable within ${timeoutMs}ms` +
172
+ (btn ? " (found but stayed disabled — focus emulation may not satisfy this gate)" : " (never located)"),
173
+ );
174
+ }
175
+ await new Promise((r) => setTimeout(r, pollMs));
176
+ }
177
+ } finally {
178
+ if (!args.keepFocus) {
179
+ await conn.send("Emulation.setFocusEmulationEnabled", { enabled: false }).catch(() => {});
180
+ }
181
+ }
182
+ },
183
+ { lease: args.lease, timeoutMs: timeoutMs + 5_000 },
184
+ );
185
+ }
package/src/version.ts CHANGED
@@ -6,4 +6,4 @@
6
6
  * ready line; keeping it in its own module means a release bump touches one
7
7
  * line here plus package.json, not a const buried in the server.
8
8
  */
9
- export const VERSION = "2.1.0";
9
+ export const VERSION = "2.3.0";