surfagent 1.1.2 → 1.2.1

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.
package/AGENT.md CHANGED
@@ -82,6 +82,12 @@ curl -X POST localhost:3456/type -H 'Content-Type: application/json' -d '{"tab":
82
82
  # Captcha detection and interaction (experimental)
83
83
  curl -X POST localhost:3456/captcha -H 'Content-Type: application/json' -d '{"tab":"0","action":"detect"}'
84
84
 
85
+ # Dispatch DOM events — for React SPAs where /click or /fill submit fails
86
+ curl -X POST localhost:3456/dispatch -H 'Content-Type: application/json' -d '{"tab":"0","selector":"form[role=search]","event":"submit"}'
87
+
88
+ # React debug — find event handlers on element and ancestors
89
+ curl -X POST localhost:3456/dispatch -H 'Content-Type: application/json' -d '{"tab":"0","selector":"[role=option]","reactDebug":true}'
90
+
85
91
  # List tabs
86
92
  curl localhost:3456/tabs
87
93
 
@@ -154,7 +160,7 @@ surfagent start
154
160
  └── Starts API Server (:3456)
155
161
 
156
162
  ├── src/api/recon.ts (page reconnaissance)
157
- ├── src/api/act.ts (fill, click, scroll, read, navigate, eval, captcha)
163
+ ├── src/api/act.ts (fill, click, scroll, read, navigate, eval, dispatch, captcha)
158
164
  └── src/api/server.ts (HTTP routing)
159
165
 
160
166
  └── src/chrome/ (CDP connection layer)
@@ -183,6 +189,12 @@ surfagent start
183
189
  - Use the API `/fill` endpoint — it uses real CDP keystrokes
184
190
  - For SPAs, use `"submit": "enter"` instead of clicking submit buttons
185
191
 
192
+ **React SPA — click/submit doesn't trigger navigation**
193
+ - This happens when React event handlers are on ancestor elements, not the element you're clicking
194
+ - Use `"submit": "form"` in `/fill` — dispatches a native submit event on the nearest `<form>`, which React picks up
195
+ - For non-form cases, use `/dispatch` with `"reactDebug": true` to find which ancestor has the handler, then `/dispatch` the right event on it
196
+ - Example (X.com search): `/fill` with `submit:"form"` works where `submit:"enter"` fails because the autocomplete combobox swallows the Enter key
197
+
186
198
  **Links opening new tabs instead of navigating**
187
199
  - The API `/click` handles `target="_blank"` automatically
188
200
  - It overrides the target and navigates in the same tab
package/API.md CHANGED
@@ -324,6 +324,7 @@ Fill form fields using real CDP keyboard input. This simulates actual keystrokes
324
324
 
325
325
  **Submit options:**
326
326
  - `"enter"` — press Enter key via CDP. Best option for single-page apps (SPAs).
327
+ - `"form"` — dispatch a native `submit` event on the nearest `<form>` ancestor. **Use this for React SPAs** where Enter is intercepted by autocomplete/combobox widgets (e.g. X.com search, GitHub search).
327
328
  - `"auto"` — finds and clicks the nearest `button[type="submit"]` or `input[type="submit"]`.
328
329
  - `"#my-button"` — clicks a specific selector.
329
330
 
@@ -447,6 +448,55 @@ Check if the API can connect to Chrome.
447
448
 
448
449
  ---
449
450
 
451
+ ### POST /dispatch
452
+
453
+ Dispatch any DOM event on any element. Built to solve React/Vue/Angular SPAs where `.click()` and CDP key events don't trigger framework event handlers.
454
+
455
+ **Dispatch an event:**
456
+ ```json
457
+ { "tab": "0", "selector": "form[role=search]", "event": "submit" }
458
+ ```
459
+
460
+ **With options:**
461
+ ```json
462
+ { "tab": "0", "selector": "#my-input", "event": "keydown", "eventInit": { "key": "Enter", "code": "Enter" } }
463
+ ```
464
+
465
+ **React debug mode** — find all React event handlers on an element and its ancestors:
466
+ ```json
467
+ { "tab": "0", "selector": "[role=option]", "reactDebug": true }
468
+ ```
469
+
470
+ **Parameters:**
471
+ - `selector` (string, required) — CSS selector for target element
472
+ - `event` (string, required unless reactDebug) — Event type: `"submit"`, `"click"`, `"input"`, `"change"`, `"keydown"`, `"pointerdown"`, etc.
473
+ - `bubbles` (boolean) — Default: `true`. Set to `false` to prevent event bubbling.
474
+ - `cancelable` (boolean) — Default: `true`
475
+ - `detail` (any) — Payload for CustomEvent
476
+ - `eventInit` (object) — Extra properties merged into the event constructor (e.g. `{key: "Enter"}` for KeyboardEvent)
477
+ - `reactDebug` (boolean) — Instead of dispatching, return all React event handlers found walking up the DOM tree from the selector
478
+
479
+ **Event response:**
480
+ ```json
481
+ { "success": true, "dispatched": "submit on FORM[role=search]", "_dispatchMs": 25 }
482
+ ```
483
+
484
+ **React debug response:**
485
+ ```json
486
+ {
487
+ "success": true,
488
+ "reactHandlers": [
489
+ { "tag": "FORM", "role": "search", "testid": null, "className": "...", "handlers": ["onSubmit"] },
490
+ { "tag": "DIV", "role": null, "testid": null, "className": "...", "handlers": ["onKeyDown"] },
491
+ { "tag": "DIV", "role": null, "testid": null, "className": "...", "handlers": ["onClick"] }
492
+ ]
493
+ }
494
+ ```
495
+
496
+ **When to use:** When `/click` or `/fill` submit doesn't trigger navigation or actions on React SPAs. Use `reactDebug` first to find which ancestor has the handler, then dispatch the right event on it.
497
+
498
+ ---
499
+
450
500
  ### POST /type
451
501
 
452
502
  Raw CDP key typing without clearing the field first. Use this for apps like **Google Sheets**, contenteditable elements, or any context where `/fill`'s Ctrl+A clear step causes side effects (e.g., selecting all cells instead of clearing a field).
@@ -613,9 +663,32 @@ const CDP = require('chrome-remote-interface');
613
663
 
614
664
  **Using the menu search:** Google Sheets has a menu search box (`input[aria-label="Menus"]` or `input[aria-label="Menus (Option+/)"]`). Use `/fill` to type a command (e.g., "Insert chart"), then `/click` on the matching result.
615
665
 
666
+ **Clearing/removing wrong cell entries:** You cannot send Delete or Backspace keys to Google Sheets via CDP — they don't reach the grid. Instead, overwrite the cell with a space:
667
+
668
+ ```
669
+ 1. POST /click { "tab": "sheets", "selector": "#t-name-box" }
670
+ 2. POST /fill { "tab": "sheets", "fields": [{ "selector": "#t-name-box", "value": "F1", "clear": true }], "submit": "enter" }
671
+ → Navigate to the cell you want to clear
672
+ 3. POST /type { "tab": "sheets", "keys": " ", "submit": "enter" }
673
+ → Overwrite with a space (effectively blanks the cell)
674
+ ```
675
+
676
+ Repeat for each cell. Do NOT try `Delete`, `Backspace`, or `Cmd+Z` via CDP key events — they are silently ignored by the Sheets grid. The `/type` space-overwrite is the only reliable method.
677
+
678
+ **Avoiding wrong entries in the first place:** When entering rows of data, always navigate to the first cell of each new row via the name box. Pressing Enter after the last column does NOT return to column A — it moves down within the same column. So after completing a row with Tab across columns:
679
+
680
+ ```
681
+ # Wrong: pressing Enter after last column stays in that column
682
+ # Right: use name box to jump to start of next row
683
+ POST /click { "tab": "sheets", "selector": "#t-name-box" }
684
+ POST /fill { "tab": "sheets", "fields": [{ "selector": "#t-name-box", "value": "A3", "clear": true }], "submit": "enter" }
685
+ ```
686
+
616
687
  **Key gotchas:**
617
688
  - Never use `/fill` directly on Google Sheets cells — it will wipe data via Ctrl+A
618
689
  - Always navigate to a cell via the name box first, then `/type`
690
+ - Always use the name box to navigate to the start of each new row — Tab+Enter does not wrap back to column A
691
+ - CDP keyboard events (Delete, Backspace, Cmd+Z) do not work on the Sheets grid — use space-overwrite instead
619
692
  - Some buttons (Add Sheet, menu items) only respond to CDP mouse events, not DOM clicks
620
693
  - Navigating away from unsaved Sheets triggers a native Chrome dialog — see the "Native Chrome Dialogs" section below
621
694
 
@@ -758,6 +831,70 @@ if let windows = windowsRef as? [AXUIElement] {
758
831
 
759
832
  ---
760
833
 
834
+ ## React SPAs — When `/click` and `/fill` Submit Don't Work
835
+
836
+ React, Vue, and Angular use synthetic event systems with event delegation. Sometimes `.click()` and CDP key events don't trigger framework handlers — especially on comboboxes, autocomplete widgets, and custom dropdowns.
837
+
838
+ **Symptoms:**
839
+ - `/click` returns `success: true` but nothing happens
840
+ - `/fill` with `submit: "enter"` fills the input but doesn't navigate
841
+ - CDP `Input.dispatchMouseEvent` / `Input.dispatchKeyEvent` are silently ignored
842
+
843
+ **Diagnosis — use `/dispatch` with `reactDebug`:**
844
+
845
+ ```bash
846
+ # Find which elements have React handlers and what events they listen for
847
+ curl -X POST localhost:3456/dispatch -d '{"tab":"0","selector":"[role=option]","reactDebug":true}'
848
+ ```
849
+
850
+ This walks up the DOM from your target element, inspecting `__reactProps$*` on each ancestor, and returns every React event handler it finds. The response tells you exactly which element to target and which event to dispatch.
851
+
852
+ **Fix — dispatch the right event on the right element:**
853
+
854
+ ```bash
855
+ # Dispatch a submit event on a form (most common fix for search boxes)
856
+ curl -X POST localhost:3456/dispatch -d '{"tab":"0","selector":"form[role=search]","event":"submit"}'
857
+
858
+ # Or dispatch a click on the ancestor that has the onClick handler
859
+ curl -X POST localhost:3456/dispatch -d '{"tab":"0","selector":"div[data-testid=wrapper]","event":"click"}'
860
+ ```
861
+
862
+ **Or use `/fill` with `submit: "form"` (one-step shortcut):**
863
+
864
+ ```bash
865
+ curl -X POST localhost:3456/fill -d '{"tab":"0","fields":[{"selector":"input[aria-label=\"Search query\"]","value":"my query"}],"submit":"form"}'
866
+ ```
867
+
868
+ ### X.com (Twitter) Search — worked example
869
+
870
+ X.com's search combobox is a textbook case. The `role="option"` autocomplete suggestions have **zero event handlers** — the `onClick` lives on a distant ancestor DIV, `onKeyDown` on a separate container, and `onSubmit` on the form.
871
+
872
+ **What works:**
873
+ ```
874
+ POST /fill { "tab": "0", "fields": [{ "selector": "input[aria-label=\"Search query\"]", "value": "query" }], "submit": "form" }
875
+ ```
876
+
877
+ **Fallback — URL navigation:**
878
+ ```
879
+ POST /navigate { "tab": "0", "url": "https://x.com/search?q=your%20query&src=typed_query&f=top" }
880
+ ```
881
+ Query parameters: `q` (query), `f` (`top`, `latest`, `people`, `photos`, `videos`).
882
+
883
+ ### General debugging workflow for any React SPA
884
+
885
+ ```
886
+ 1. Try /click or /fill with submit:"enter" first — it works on most sites
887
+ 2. If it fails:
888
+ POST /dispatch { "tab": "0", "selector": "THE_STUCK_ELEMENT", "reactDebug": true }
889
+ → Read the handler tree to find which ancestor has which handler
890
+ 3. Dispatch the right event:
891
+ POST /dispatch { "tab": "0", "selector": "THE_ANCESTOR", "event": "THE_EVENT" }
892
+ 4. If it's a form with an input, use submit:"form" shortcut:
893
+ POST /fill { "tab": "0", "fields": [...], "submit": "form" }
894
+ ```
895
+
896
+ ---
897
+
761
898
  ## Important Notes
762
899
 
763
900
  - **Always recon before acting.** The selectors you need come from the recon response.
package/CLAUDE.md CHANGED
@@ -82,6 +82,12 @@ curl -X POST localhost:3456/type -H 'Content-Type: application/json' -d '{"tab":
82
82
  # Captcha detection and interaction (experimental)
83
83
  curl -X POST localhost:3456/captcha -H 'Content-Type: application/json' -d '{"tab":"0","action":"detect"}'
84
84
 
85
+ # Dispatch DOM events (React SPA workaround when /click or /fill submit fails)
86
+ curl -X POST localhost:3456/dispatch -H 'Content-Type: application/json' -d '{"tab":"0","selector":"form[role=search]","event":"submit"}'
87
+
88
+ # React debug — find event handlers on element ancestors
89
+ curl -X POST localhost:3456/dispatch -H 'Content-Type: application/json' -d '{"tab":"0","selector":"[role=option]","reactDebug":true}'
90
+
85
91
  # List tabs
86
92
  curl localhost:3456/tabs
87
93
 
package/dist/api/act.d.ts CHANGED
@@ -99,3 +99,22 @@ export declare function typeKeys(tabPattern: string, keys: string, options: {
99
99
  typed: number;
100
100
  submitted?: boolean;
101
101
  }>;
102
+ export interface DispatchRequest {
103
+ tab: string;
104
+ selector: string;
105
+ event: string;
106
+ bubbles?: boolean;
107
+ cancelable?: boolean;
108
+ detail?: any;
109
+ eventInit?: Record<string, any>;
110
+ reactDebug?: boolean;
111
+ }
112
+ export declare function dispatchEvent(request: DispatchRequest, options: {
113
+ port?: number;
114
+ host?: string;
115
+ }): Promise<{
116
+ success: boolean;
117
+ dispatched?: string;
118
+ reactHandlers?: any[];
119
+ error?: string;
120
+ }>;
package/dist/api/act.js CHANGED
@@ -142,6 +142,29 @@ export async function fillFields(request, options) {
142
142
  await cdp.Input.dispatchKeyEvent({ type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
143
143
  await cdp.Input.dispatchKeyEvent({ type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
144
144
  }
145
+ else if (request.submit === 'form') {
146
+ // Dispatch native submit event on nearest form — works on React SPAs where Enter is intercepted
147
+ // (e.g. X.com search combobox, autocomplete widgets that swallow Enter key)
148
+ await client.Runtime.evaluate({
149
+ expression: `
150
+ (function() {
151
+ // Find the last filled field and its nearest form ancestor
152
+ const lastSelector = ${JSON.stringify(request.fields.length > 0 ? request.fields[request.fields.length - 1].selector : null)};
153
+ let form;
154
+ if (lastSelector) {
155
+ const field = document.querySelector(lastSelector);
156
+ form = field ? field.closest('form') : null;
157
+ }
158
+ if (!form) {
159
+ form = document.querySelector('form');
160
+ }
161
+ if (!form) throw new Error('No form found');
162
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
163
+ })()
164
+ `,
165
+ returnByValue: true
166
+ });
167
+ }
145
168
  else {
146
169
  const submitSelector = request.submit === 'auto'
147
170
  ? 'button[type="submit"], input[type="submit"]'
@@ -707,3 +730,80 @@ export async function typeKeys(tabPattern, keys, options) {
707
730
  throw error;
708
731
  }
709
732
  }
733
+ export async function dispatchEvent(request, options) {
734
+ const port = options.port || 9222;
735
+ const host = options.host || 'localhost';
736
+ const tab = await resolveTab(request.tab, port, host);
737
+ const client = await connectToTab(tab.id, port, host);
738
+ try {
739
+ const result = await client.Runtime.evaluate({
740
+ expression: `
741
+ (function() {
742
+ const selector = ${JSON.stringify(request.selector)};
743
+ const eventType = ${JSON.stringify(request.event)};
744
+ const bubbles = ${request.bubbles !== false};
745
+ const cancelable = ${request.cancelable !== false};
746
+ const detail = ${JSON.stringify(request.detail || null)};
747
+ const extraInit = ${JSON.stringify(request.eventInit || {})};
748
+ const reactDebug = ${JSON.stringify(!!request.reactDebug)};
749
+
750
+ const el = document.querySelector(selector);
751
+ if (!el) return { success: false, error: 'Element not found: ' + selector };
752
+
753
+ // React debug: walk up tree and find all React event handlers
754
+ if (reactDebug) {
755
+ const handlers = [];
756
+ let current = el;
757
+ while (current && current !== document.documentElement) {
758
+ const propsKey = Object.keys(current).find(k => k.startsWith('__reactProps'));
759
+ if (propsKey) {
760
+ const props = current[propsKey] || {};
761
+ const reactHandlers = Object.keys(props).filter(k => typeof props[k] === 'function' && k.startsWith('on'));
762
+ if (reactHandlers.length > 0) {
763
+ handlers.push({
764
+ tag: current.tagName,
765
+ role: current.getAttribute('role'),
766
+ testid: current.getAttribute('data-testid'),
767
+ className: (current.className || '').toString().substring(0, 60),
768
+ handlers: reactHandlers
769
+ });
770
+ }
771
+ }
772
+ current = current.parentElement;
773
+ }
774
+ return { success: true, reactHandlers: handlers };
775
+ }
776
+
777
+ // Build the event object
778
+ let event;
779
+ const init = { bubbles, cancelable, ...extraInit };
780
+
781
+ // Use specific event constructors for better compatibility
782
+ if (eventType === 'click' || eventType === 'mousedown' || eventType === 'mouseup' || eventType === 'dblclick') {
783
+ event = new MouseEvent(eventType, init);
784
+ } else if (eventType === 'keydown' || eventType === 'keyup' || eventType === 'keypress') {
785
+ event = new KeyboardEvent(eventType, init);
786
+ } else if (eventType === 'input' || eventType === 'change') {
787
+ event = new Event(eventType, init);
788
+ } else if (eventType === 'pointerdown' || eventType === 'pointerup' || eventType === 'pointermove') {
789
+ event = new PointerEvent(eventType, init);
790
+ } else if (detail !== null) {
791
+ event = new CustomEvent(eventType, { ...init, detail });
792
+ } else {
793
+ event = new Event(eventType, init);
794
+ }
795
+
796
+ el.dispatchEvent(event);
797
+ return { success: true, dispatched: eventType + ' on ' + el.tagName + (el.getAttribute('role') ? '[role=' + el.getAttribute('role') + ']' : '') };
798
+ })()
799
+ `,
800
+ returnByValue: true
801
+ });
802
+ await client.close();
803
+ return result.result.value;
804
+ }
805
+ catch (error) {
806
+ await client.close();
807
+ throw error;
808
+ }
809
+ }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import http from 'node:http';
3
3
  import { reconUrl, reconTab } from './recon.js';
4
- import { fillFields, clickElement, scrollPage, navigatePage, evalInTab, focusTab, readPage, captchaInteract, dismissOverlays, typeKeys } from './act.js';
4
+ import { fillFields, clickElement, scrollPage, navigatePage, evalInTab, focusTab, readPage, captchaInteract, dismissOverlays, typeKeys, dispatchEvent } from './act.js';
5
5
  import { getAllTabs } from '../chrome/tabs.js';
6
6
  const PORT = parseInt(process.env.API_PORT || '3456', 10);
7
7
  const CDP_PORT = parseInt(process.env.CDP_PORT || '9222', 10);
@@ -150,6 +150,16 @@ const server = http.createServer(async (req, res) => {
150
150
  const result = await typeKeys(body.tab, body.keys, { port: CDP_PORT, host: CDP_HOST, submit: body.submit });
151
151
  return json(res, 200, result);
152
152
  }
153
+ // POST /dispatch — dispatch DOM events on elements (React SPA workaround)
154
+ if (path === '/dispatch' && req.method === 'POST') {
155
+ const body = parseBody(await readBody(req));
156
+ if (!body.tab || !body.selector || (!body.event && !body.reactDebug)) {
157
+ return json(res, 400, { error: 'Provide "tab", "selector", and "event" (e.g. "submit", "click"). Add "reactDebug":true to inspect React handlers instead.' });
158
+ }
159
+ const start = Date.now();
160
+ const result = await dispatchEvent(body, { port: CDP_PORT, host: CDP_HOST });
161
+ return json(res, 200, { ...result, _dispatchMs: Date.now() - start });
162
+ }
153
163
  // POST /navigate — go to url, back, or forward in same tab
154
164
  if (path === '/navigate' && req.method === 'POST') {
155
165
  const body = parseBody(await readBody(req));
@@ -180,7 +190,7 @@ const server = http.createServer(async (req, res) => {
180
190
  return json(res, 503, { status: 'error', cdpConnected: false });
181
191
  }
182
192
  }
183
- json(res, 404, { error: 'Not found. Endpoints: POST /recon, /read, /fill, /click, /type, /scroll, /navigate, /eval, /dismiss, /captcha, /focus | GET /tabs, /health' });
193
+ json(res, 404, { error: 'Not found. Endpoints: POST /recon, /read, /fill, /click, /type, /scroll, /navigate, /eval, /dispatch, /dismiss, /captcha, /focus | GET /tabs, /health' });
184
194
  }
185
195
  catch (error) {
186
196
  const message = error instanceof Error ? error.message : String(error);
@@ -212,6 +222,7 @@ server.listen(PORT, () => {
212
222
  console.log(` POST /recon — { url: "..." } or { tab: "0" }`);
213
223
  console.log(` POST /fill — { tab, fields: [{ selector, value }], submit? }`);
214
224
  console.log(` POST /click — { tab, selector? , text? }`);
225
+ console.log(` POST /dispatch— { tab, selector, event, reactDebug? }`);
215
226
  console.log(` GET /tabs — list open Chrome tabs`);
216
227
  console.log(` GET /health — check CDP connection`);
217
228
  });
package/dist/cli.js CHANGED
@@ -28,6 +28,12 @@ function detectOS() {
28
28
  return 'linux';
29
29
  }
30
30
  function getChromePath() {
31
+ if (process.env.BROWSER_PATH) {
32
+ if (fs.existsSync(process.env.BROWSER_PATH))
33
+ return process.env.BROWSER_PATH;
34
+ console.error(`[surfagent] BROWSER_PATH set but not found: ${process.env.BROWSER_PATH}`);
35
+ return null;
36
+ }
31
37
  const os = detectOS();
32
38
  const paths = {
33
39
  mac: [
@@ -118,6 +124,7 @@ Usage:
118
124
  Environment variables:
119
125
  CDP_PORT Chrome debug port (default: 9222)
120
126
  API_PORT API server port (default: 3456)
127
+ BROWSER_PATH Path to any Chromium-based browser (Arc, Brave, Edge, etc.)
121
128
  CHROME_USER_DATA_DIR Chrome profile directory (default: /tmp/surfagent-chrome)
122
129
 
123
130
  After starting, your AI agent can call http://localhost:3456
@@ -148,7 +155,7 @@ Full API docs: https://github.com/AllAboutAI-YT/surfagent#readme
148
155
  }
149
156
  const chromePath = getChromePath();
150
157
  if (!chromePath) {
151
- console.error('[surfagent] Chrome not found. Install Google Chrome and try again.');
158
+ console.error('[surfagent] Chrome not found. Install Google Chrome or set BROWSER_PATH to a Chromium-based browser.');
152
159
  process.exit(1);
153
160
  }
154
161
  startChrome(chromePath);
@@ -179,7 +186,7 @@ Full API docs: https://github.com/AllAboutAI-YT/surfagent#readme
179
186
  else {
180
187
  const chromePath = getChromePath();
181
188
  if (!chromePath) {
182
- console.error('[surfagent] Chrome not found. Install Google Chrome and try again.');
189
+ console.error('[surfagent] Chrome not found. Install Google Chrome or set BROWSER_PATH to a Chromium-based browser.');
183
190
  process.exit(1);
184
191
  }
185
192
  startChrome(chromePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "surfagent",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
4
4
  "description": "Browser automation API for AI agents — structured page recon, form filling, clicking, and navigation via Chrome CDP",
5
5
  "keywords": [
6
6
  "ai-agent",
package/src/api/act.ts CHANGED
@@ -163,6 +163,28 @@ export async function fillFields(
163
163
  // Press Enter via CDP — works on SPAs like YouTube
164
164
  await cdp.Input.dispatchKeyEvent({ type: 'keyDown', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
165
165
  await cdp.Input.dispatchKeyEvent({ type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13 });
166
+ } else if (request.submit === 'form') {
167
+ // Dispatch native submit event on nearest form — works on React SPAs where Enter is intercepted
168
+ // (e.g. X.com search combobox, autocomplete widgets that swallow Enter key)
169
+ await client.Runtime.evaluate({
170
+ expression: `
171
+ (function() {
172
+ // Find the last filled field and its nearest form ancestor
173
+ const lastSelector = ${JSON.stringify(request.fields.length > 0 ? request.fields[request.fields.length - 1].selector : null)};
174
+ let form;
175
+ if (lastSelector) {
176
+ const field = document.querySelector(lastSelector);
177
+ form = field ? field.closest('form') : null;
178
+ }
179
+ if (!form) {
180
+ form = document.querySelector('form');
181
+ }
182
+ if (!form) throw new Error('No form found');
183
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
184
+ })()
185
+ `,
186
+ returnByValue: true
187
+ });
166
188
  } else {
167
189
  const submitSelector = request.submit === 'auto'
168
190
  ? 'button[type="submit"], input[type="submit"]'
@@ -815,3 +837,99 @@ export async function typeKeys(
815
837
  throw error;
816
838
  }
817
839
  }
840
+
841
+ // POST /dispatch — dispatch any DOM event on any element
842
+ // Solves React SPAs where .click() and CDP key events don't trigger synthetic event handlers
843
+ export interface DispatchRequest {
844
+ tab: string;
845
+ selector: string; // CSS selector for target element
846
+ event: string; // Event type: "submit", "click", "input", "change", "focus", "blur", etc.
847
+ bubbles?: boolean; // Default: true
848
+ cancelable?: boolean; // Default: true
849
+ detail?: any; // For CustomEvent detail payload
850
+ eventInit?: Record<string, any>; // Extra event init properties (e.g. {key: "Enter"} for KeyboardEvent)
851
+ reactDebug?: boolean; // If true, return React event handlers found on element and ancestors
852
+ }
853
+
854
+ export async function dispatchEvent(
855
+ request: DispatchRequest,
856
+ options: { port?: number; host?: string }
857
+ ): Promise<{ success: boolean; dispatched?: string; reactHandlers?: any[]; error?: string }> {
858
+ const port = options.port || 9222;
859
+ const host = options.host || 'localhost';
860
+
861
+ const tab = await resolveTab(request.tab, port, host);
862
+ const client = await connectToTab(tab.id, port, host);
863
+
864
+ try {
865
+ const result = await client.Runtime.evaluate({
866
+ expression: `
867
+ (function() {
868
+ const selector = ${JSON.stringify(request.selector)};
869
+ const eventType = ${JSON.stringify(request.event)};
870
+ const bubbles = ${request.bubbles !== false};
871
+ const cancelable = ${request.cancelable !== false};
872
+ const detail = ${JSON.stringify(request.detail || null)};
873
+ const extraInit = ${JSON.stringify(request.eventInit || {})};
874
+ const reactDebug = ${JSON.stringify(!!request.reactDebug)};
875
+
876
+ const el = document.querySelector(selector);
877
+ if (!el) return { success: false, error: 'Element not found: ' + selector };
878
+
879
+ // React debug: walk up tree and find all React event handlers
880
+ if (reactDebug) {
881
+ const handlers = [];
882
+ let current = el;
883
+ while (current && current !== document.documentElement) {
884
+ const propsKey = Object.keys(current).find(k => k.startsWith('__reactProps'));
885
+ if (propsKey) {
886
+ const props = current[propsKey] || {};
887
+ const reactHandlers = Object.keys(props).filter(k => typeof props[k] === 'function' && k.startsWith('on'));
888
+ if (reactHandlers.length > 0) {
889
+ handlers.push({
890
+ tag: current.tagName,
891
+ role: current.getAttribute('role'),
892
+ testid: current.getAttribute('data-testid'),
893
+ className: (current.className || '').toString().substring(0, 60),
894
+ handlers: reactHandlers
895
+ });
896
+ }
897
+ }
898
+ current = current.parentElement;
899
+ }
900
+ return { success: true, reactHandlers: handlers };
901
+ }
902
+
903
+ // Build the event object
904
+ let event;
905
+ const init = { bubbles, cancelable, ...extraInit };
906
+
907
+ // Use specific event constructors for better compatibility
908
+ if (eventType === 'click' || eventType === 'mousedown' || eventType === 'mouseup' || eventType === 'dblclick') {
909
+ event = new MouseEvent(eventType, init);
910
+ } else if (eventType === 'keydown' || eventType === 'keyup' || eventType === 'keypress') {
911
+ event = new KeyboardEvent(eventType, init);
912
+ } else if (eventType === 'input' || eventType === 'change') {
913
+ event = new Event(eventType, init);
914
+ } else if (eventType === 'pointerdown' || eventType === 'pointerup' || eventType === 'pointermove') {
915
+ event = new PointerEvent(eventType, init);
916
+ } else if (detail !== null) {
917
+ event = new CustomEvent(eventType, { ...init, detail });
918
+ } else {
919
+ event = new Event(eventType, init);
920
+ }
921
+
922
+ el.dispatchEvent(event);
923
+ return { success: true, dispatched: eventType + ' on ' + el.tagName + (el.getAttribute('role') ? '[role=' + el.getAttribute('role') + ']' : '') };
924
+ })()
925
+ `,
926
+ returnByValue: true
927
+ });
928
+
929
+ await client.close();
930
+ return result.result.value as any;
931
+ } catch (error) {
932
+ await client.close();
933
+ throw error;
934
+ }
935
+ }
package/src/api/server.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import http from 'node:http';
4
4
  import { reconUrl, reconTab } from './recon.js';
5
- import { fillFields, clickElement, scrollPage, navigatePage, evalInTab, focusTab, readPage, captchaInteract, dismissOverlays, typeKeys } from './act.js';
5
+ import { fillFields, clickElement, scrollPage, navigatePage, evalInTab, focusTab, readPage, captchaInteract, dismissOverlays, typeKeys, dispatchEvent } from './act.js';
6
6
  import { getAllTabs } from '../chrome/tabs.js';
7
7
 
8
8
  const PORT = parseInt(process.env.API_PORT || '3456', 10);
@@ -176,6 +176,17 @@ const server = http.createServer(async (req, res) => {
176
176
  return json(res, 200, result);
177
177
  }
178
178
 
179
+ // POST /dispatch — dispatch DOM events on elements (React SPA workaround)
180
+ if (path === '/dispatch' && req.method === 'POST') {
181
+ const body = parseBody(await readBody(req));
182
+ if (!body.tab || !body.selector || (!body.event && !body.reactDebug)) {
183
+ return json(res, 400, { error: 'Provide "tab", "selector", and "event" (e.g. "submit", "click"). Add "reactDebug":true to inspect React handlers instead.' });
184
+ }
185
+ const start = Date.now();
186
+ const result = await dispatchEvent(body, { port: CDP_PORT, host: CDP_HOST });
187
+ return json(res, 200, { ...result, _dispatchMs: Date.now() - start });
188
+ }
189
+
179
190
  // POST /navigate — go to url, back, or forward in same tab
180
191
  if (path === '/navigate' && req.method === 'POST') {
181
192
  const body = parseBody(await readBody(req));
@@ -208,7 +219,7 @@ const server = http.createServer(async (req, res) => {
208
219
  }
209
220
  }
210
221
 
211
- json(res, 404, { error: 'Not found. Endpoints: POST /recon, /read, /fill, /click, /type, /scroll, /navigate, /eval, /dismiss, /captcha, /focus | GET /tabs, /health' });
222
+ json(res, 404, { error: 'Not found. Endpoints: POST /recon, /read, /fill, /click, /type, /scroll, /navigate, /eval, /dispatch, /dismiss, /captcha, /focus | GET /tabs, /health' });
212
223
  } catch (error) {
213
224
  const message = error instanceof Error ? error.message : String(error);
214
225
  console.error(`[${new Date().toISOString()}] Error:`, message);
@@ -242,6 +253,7 @@ server.listen(PORT, () => {
242
253
  console.log(` POST /recon — { url: "..." } or { tab: "0" }`);
243
254
  console.log(` POST /fill — { tab, fields: [{ selector, value }], submit? }`);
244
255
  console.log(` POST /click — { tab, selector? , text? }`);
256
+ console.log(` POST /dispatch— { tab, selector, event, reactDebug? }`);
245
257
  console.log(` GET /tabs — list open Chrome tabs`);
246
258
  console.log(` GET /health — check CDP connection`);
247
259
  });
package/src/cli.ts CHANGED
@@ -32,6 +32,11 @@ function detectOS(): 'mac' | 'linux' | 'windows' {
32
32
  }
33
33
 
34
34
  function getChromePath(): string | null {
35
+ if (process.env.BROWSER_PATH) {
36
+ if (fs.existsSync(process.env.BROWSER_PATH)) return process.env.BROWSER_PATH;
37
+ console.error(`[surfagent] BROWSER_PATH set but not found: ${process.env.BROWSER_PATH}`);
38
+ return null;
39
+ }
35
40
  const os = detectOS();
36
41
  const paths: Record<string, string[]> = {
37
42
  mac: [
@@ -127,6 +132,7 @@ Usage:
127
132
  Environment variables:
128
133
  CDP_PORT Chrome debug port (default: 9222)
129
134
  API_PORT API server port (default: 3456)
135
+ BROWSER_PATH Path to any Chromium-based browser (Arc, Brave, Edge, etc.)
130
136
  CHROME_USER_DATA_DIR Chrome profile directory (default: /tmp/surfagent-chrome)
131
137
 
132
138
  After starting, your AI agent can call http://localhost:3456
@@ -159,7 +165,7 @@ Full API docs: https://github.com/AllAboutAI-YT/surfagent#readme
159
165
 
160
166
  const chromePath = getChromePath();
161
167
  if (!chromePath) {
162
- console.error('[surfagent] Chrome not found. Install Google Chrome and try again.');
168
+ console.error('[surfagent] Chrome not found. Install Google Chrome or set BROWSER_PATH to a Chromium-based browser.');
163
169
  process.exit(1);
164
170
  }
165
171
 
@@ -193,7 +199,7 @@ Full API docs: https://github.com/AllAboutAI-YT/surfagent#readme
193
199
  } else {
194
200
  const chromePath = getChromePath();
195
201
  if (!chromePath) {
196
- console.error('[surfagent] Chrome not found. Install Google Chrome and try again.');
202
+ console.error('[surfagent] Chrome not found. Install Google Chrome or set BROWSER_PATH to a Chromium-based browser.');
197
203
  process.exit(1);
198
204
  }
199
205
  startChrome(chromePath);