surfagent 1.1.2 → 1.2.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.
- package/AGENT.md +13 -1
- package/API.md +137 -0
- package/CLAUDE.md +6 -0
- package/dist/api/act.d.ts +19 -0
- package/dist/api/act.js +100 -0
- package/dist/api/server.js +13 -2
- package/package.json +1 -1
- package/src/api/act.ts +118 -0
- package/src/api/server.ts +14 -2
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
|
+
}
|
package/dist/api/server.js
CHANGED
|
@@ -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/package.json
CHANGED
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
|
});
|