safari-mcp 2.9.3 → 2.10.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/README.md +163 -10
- package/index.js +13 -0
- package/mcp-helpers.js +51 -21
- package/package.json +1 -1
- package/safari.js +254 -32
package/README.md
CHANGED
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
[](https://opensource.org/licenses/MIT)
|
|
16
16
|
[](https://www.apple.com/macos/)
|
|
17
17
|
|
|
18
|
+
[](https://glama.ai/mcp/servers/@achiya-automation/safari-mcp)
|
|
19
|
+
[](https://registry.modelcontextprotocol.io/v0/servers?search=achiya-automation)
|
|
20
|
+
[](https://smithery.ai/server/@achiya-automation/safari-mcp)
|
|
21
|
+
|
|
18
22
|
<a href="vscode:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code-Install_Server-0078d4?logo=visual-studio-code&logoColor=white" alt="Install in VS Code"></a>
|
|
19
23
|
<a href="https://insiders.vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D"><img src="https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?logo=visual-studio-code&logoColor=white" alt="Install in VS Code Insiders"></a>
|
|
20
24
|
<a href="cursor://anysphere.cursor-deeplink/mcp/install?name=safari-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D"><img src="https://img.shields.io/badge/Cursor-Install_Server-f97316?logo=cursor&logoColor=white" alt="Install in Cursor"></a>
|
|
@@ -23,7 +27,7 @@
|
|
|
23
27
|
|
|
24
28
|
[Quick Start](#quick-start) · [All 80 Tools](#tools-80) · [Examples](examples/) · [Why Safari MCP?](#safari-mcp-vs-alternatives) · [Architecture](#architecture) · [Changelog](CHANGELOG.md)
|
|
25
29
|
|
|
26
|
-

|
|
27
31
|
|
|
28
32
|
</div>
|
|
29
33
|
|
|
@@ -80,7 +84,54 @@ npm install -g safari-mcp
|
|
|
80
84
|
|
|
81
85
|
### Configure your MCP client
|
|
82
86
|
|
|
83
|
-
|
|
87
|
+
All clients run Safari MCP the same way — `npx safari-mcp`. Pick your editor:
|
|
88
|
+
|
|
89
|
+
<details>
|
|
90
|
+
<summary><b>Claude Code</b></summary>
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
claude mcp add safari -- npx safari-mcp
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Or edit `~/.mcp.json`:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"mcpServers": {
|
|
101
|
+
"safari": {
|
|
102
|
+
"command": "npx",
|
|
103
|
+
"args": ["safari-mcp"]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
</details>
|
|
109
|
+
|
|
110
|
+
<details>
|
|
111
|
+
<summary><b>Claude Desktop</b></summary>
|
|
112
|
+
|
|
113
|
+
Edit `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
114
|
+
|
|
115
|
+
```json
|
|
116
|
+
{
|
|
117
|
+
"mcpServers": {
|
|
118
|
+
"safari": {
|
|
119
|
+
"command": "npx",
|
|
120
|
+
"args": ["safari-mcp"]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Restart Claude Desktop after saving.
|
|
127
|
+
</details>
|
|
128
|
+
|
|
129
|
+
<details>
|
|
130
|
+
<summary><b>Cursor</b></summary>
|
|
131
|
+
|
|
132
|
+
One-click: [**Install in Cursor**](cursor://anysphere.cursor-deeplink/mcp/install?name=safari-mcp&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D)
|
|
133
|
+
|
|
134
|
+
Or edit `.cursor/mcp.json` in your project:
|
|
84
135
|
|
|
85
136
|
```json
|
|
86
137
|
{
|
|
@@ -92,18 +143,120 @@ Add to your client's config file — all clients use the same JSON:
|
|
|
92
143
|
}
|
|
93
144
|
}
|
|
94
145
|
```
|
|
146
|
+
</details>
|
|
95
147
|
|
|
96
148
|
<details>
|
|
97
|
-
<summary><b>
|
|
149
|
+
<summary><b>VS Code / VS Code Insiders</b></summary>
|
|
150
|
+
|
|
151
|
+
One-click: [**Install in VS Code**](vscode:mcp/install?%7B%22safari-mcp%22%3A%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22safari-mcp%22%5D%7D%7D)
|
|
98
152
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
153
|
+
Or edit `.vscode/mcp.json`:
|
|
154
|
+
|
|
155
|
+
```json
|
|
156
|
+
{
|
|
157
|
+
"servers": {
|
|
158
|
+
"safari": {
|
|
159
|
+
"type": "stdio",
|
|
160
|
+
"command": "npx",
|
|
161
|
+
"args": ["safari-mcp"]
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
</details>
|
|
167
|
+
|
|
168
|
+
<details>
|
|
169
|
+
<summary><b>Windsurf</b></summary>
|
|
106
170
|
|
|
171
|
+
Edit `.windsurf/mcp.json` in your project (or `~/.codeium/windsurf/mcp_config.json` globally):
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"mcpServers": {
|
|
176
|
+
"safari": {
|
|
177
|
+
"command": "npx",
|
|
178
|
+
"args": ["safari-mcp"]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
</details>
|
|
184
|
+
|
|
185
|
+
<details>
|
|
186
|
+
<summary><b>Cline</b></summary>
|
|
187
|
+
|
|
188
|
+
Open Cline in VS Code → click the MCP icon → **Edit MCP Settings** → add:
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"mcpServers": {
|
|
193
|
+
"safari": {
|
|
194
|
+
"command": "npx",
|
|
195
|
+
"args": ["safari-mcp"]
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
</details>
|
|
201
|
+
|
|
202
|
+
<details>
|
|
203
|
+
<summary><b>Continue</b></summary>
|
|
204
|
+
|
|
205
|
+
Edit `~/.continue/config.yaml` (or `.continue/config.yaml` in workspace):
|
|
206
|
+
|
|
207
|
+
```yaml
|
|
208
|
+
mcpServers:
|
|
209
|
+
- name: safari
|
|
210
|
+
command: npx
|
|
211
|
+
args:
|
|
212
|
+
- safari-mcp
|
|
213
|
+
```
|
|
214
|
+
</details>
|
|
215
|
+
|
|
216
|
+
<details>
|
|
217
|
+
<summary><b>Goose</b></summary>
|
|
218
|
+
|
|
219
|
+
Edit `~/.config/goose/config.yaml`:
|
|
220
|
+
|
|
221
|
+
```yaml
|
|
222
|
+
extensions:
|
|
223
|
+
safari:
|
|
224
|
+
name: safari
|
|
225
|
+
type: stdio
|
|
226
|
+
cmd: npx
|
|
227
|
+
args:
|
|
228
|
+
- safari-mcp
|
|
229
|
+
enabled: true
|
|
230
|
+
```
|
|
231
|
+
</details>
|
|
232
|
+
|
|
233
|
+
<details>
|
|
234
|
+
<summary><b>LM Studio</b></summary>
|
|
235
|
+
|
|
236
|
+
Open LM Studio → **Settings** → **MCP Servers** → **Add Server**:
|
|
237
|
+
|
|
238
|
+
- **Name:** `safari`
|
|
239
|
+
- **Command:** `npx`
|
|
240
|
+
- **Args:** `safari-mcp`
|
|
241
|
+
</details>
|
|
242
|
+
|
|
243
|
+
<details>
|
|
244
|
+
<summary><b>Zed</b></summary>
|
|
245
|
+
|
|
246
|
+
Open Zed → **Settings** → search for "Context Servers" and add:
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{
|
|
250
|
+
"context_servers": {
|
|
251
|
+
"safari": {
|
|
252
|
+
"command": {
|
|
253
|
+
"path": "npx",
|
|
254
|
+
"args": ["safari-mcp"]
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
```
|
|
107
260
|
</details>
|
|
108
261
|
|
|
109
262
|
<details>
|
package/index.js
CHANGED
|
@@ -1071,6 +1071,19 @@ server.tool(
|
|
|
1071
1071
|
}
|
|
1072
1072
|
);
|
|
1073
1073
|
|
|
1074
|
+
server.tool(
|
|
1075
|
+
"safari_verify_state",
|
|
1076
|
+
"Verify the framework-level state of an editor/input matches the expected value. Returns JSON {match, mode, actual, expected, hint?}. Modern editors (ProseMirror, Lexical, Closure, React-controlled inputs) maintain state separately from the DOM — `.value` or `.textContent` may show new text while the internal store still holds old data, so a Submit click sends stale data. Call this AFTER safari_fill and BEFORE clicking Submit on critical forms (Featured.com, LinkedIn share, Medium, Reddit).",
|
|
1077
|
+
{
|
|
1078
|
+
selector: z.string().describe("CSS selector of the editor/input to verify"),
|
|
1079
|
+
expected: z.string().describe("Expected value or text fragment that should appear in framework state"),
|
|
1080
|
+
},
|
|
1081
|
+
async (args) => {
|
|
1082
|
+
const result = await safari.verifyState(args);
|
|
1083
|
+
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
1084
|
+
}
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1074
1087
|
server.tool(
|
|
1075
1088
|
"safari_select_option",
|
|
1076
1089
|
"Select an option in a native <select> dropdown. Sets .value and dispatches change event. For custom dropdowns (React/LinkedIn), use safari_click on the dropdown trigger, then safari_click on the option instead.",
|
package/mcp-helpers.js
CHANGED
|
@@ -178,42 +178,72 @@ if (window.__mcpVersion !== 5) {
|
|
|
178
178
|
window.mcpReactClick = function(el) {
|
|
179
179
|
var startNode = window.mcpResolveTarget(el) || el;
|
|
180
180
|
if (!startNode) return false;
|
|
181
|
+
// React 18-compatible SyntheticEvent — includes isDefaultPrevented/isPropagationStopped
|
|
182
|
+
// methods that React internally checks. Without them, some portal-rendered components
|
|
183
|
+
// (HackerNoon modal Submit, Radix dropdowns) silently no-op.
|
|
181
184
|
function makeSynth(targetNode, type) {
|
|
182
185
|
var r = targetNode.getBoundingClientRect();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
var native = type === 'click' || type === 'mousedown' || type === 'mouseup' || type === 'mousemove'
|
|
187
|
+
? new MouseEvent(type, { bubbles: true, cancelable: true, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2 })
|
|
188
|
+
: type === 'pointerdown' || type === 'pointerup'
|
|
189
|
+
? new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, pointerType: 'mouse' })
|
|
190
|
+
: new Event(type || 'click', { bubbles: true, cancelable: true });
|
|
191
|
+
var defaultPrevented = false, propagationStopped = false;
|
|
192
|
+
return {
|
|
193
|
+
type: type || 'click', target: targetNode, currentTarget: targetNode,
|
|
194
|
+
clientX: r.left + r.width / 2, clientY: r.top + r.height / 2,
|
|
195
|
+
pageX: r.left + r.width / 2 + window.scrollX, pageY: r.top + r.height / 2 + window.scrollY,
|
|
196
|
+
screenX: r.left + r.width / 2, screenY: r.top + r.height / 2,
|
|
197
|
+
button: 0, buttons: 0, detail: 1,
|
|
198
|
+
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
|
199
|
+
preventDefault: function() { defaultPrevented = true; if (native.preventDefault) native.preventDefault(); },
|
|
200
|
+
stopPropagation: function() { propagationStopped = true; if (native.stopPropagation) native.stopPropagation(); },
|
|
201
|
+
stopImmediatePropagation: function() { propagationStopped = true; },
|
|
202
|
+
isDefaultPrevented: function() { return defaultPrevented; },
|
|
203
|
+
isPropagationStopped: function() { return propagationStopped; },
|
|
204
|
+
defaultPrevented: false, cancelBubble: false,
|
|
205
|
+
nativeEvent: native, persist: function() {},
|
|
206
|
+
bubbles: true, cancelable: true, eventPhase: 2, isTrusted: false,
|
|
207
|
+
timeStamp: Date.now(), composed: true, view: window
|
|
208
|
+
};
|
|
186
209
|
}
|
|
187
210
|
// For React radio/checkbox inputs, onChange is the primary handler (not onClick)
|
|
188
211
|
var isToggle = startNode.tagName === 'INPUT' && (startNode.type === 'radio' || startNode.type === 'checkbox');
|
|
189
212
|
function tryOnChange(propsObj, targetNode) {
|
|
190
213
|
if (!isToggle || !propsObj.onChange) return false;
|
|
191
214
|
var newChecked = startNode.type === 'radio' ? true : !startNode.checked;
|
|
192
|
-
|
|
193
|
-
|
|
215
|
+
var ev = makeSynth(targetNode, 'change');
|
|
216
|
+
ev.target = { value: startNode.value, checked: newChecked, type: startNode.type, name: startNode.name, id: startNode.id, tagName: 'INPUT' };
|
|
217
|
+
propsObj.onClick && propsObj.onClick(ev);
|
|
218
|
+
propsObj.onChange(ev);
|
|
194
219
|
return true;
|
|
195
220
|
}
|
|
221
|
+
// Try a single React props bag: returns true if ANY handler fired.
|
|
222
|
+
// Order matters — onPointerDown/onMouseDown sometimes opens modals, then onClick commits.
|
|
223
|
+
function tryProps(propsObj, targetNode) {
|
|
224
|
+
if (!propsObj) return false;
|
|
225
|
+
if (tryOnChange(propsObj, targetNode)) return true;
|
|
226
|
+
var fired = false;
|
|
227
|
+
if (typeof propsObj.onPointerDown === 'function') { try { propsObj.onPointerDown(makeSynth(targetNode, 'pointerdown')); fired = true; } catch (_e) {} }
|
|
228
|
+
if (typeof propsObj.onMouseDown === 'function') { try { propsObj.onMouseDown(makeSynth(targetNode, 'mousedown')); fired = true; } catch (_e) {} }
|
|
229
|
+
if (typeof propsObj.onMouseUp === 'function') { try { propsObj.onMouseUp(makeSynth(targetNode, 'mouseup')); fired = true; } catch (_e) {} }
|
|
230
|
+
if (typeof propsObj.onPointerUp === 'function') { try { propsObj.onPointerUp(makeSynth(targetNode, 'pointerup')); fired = true; } catch (_e) {} }
|
|
231
|
+
if (typeof propsObj.onClick === 'function') { try { propsObj.onClick(makeSynth(targetNode, 'click')); fired = true; } catch (_e) {} }
|
|
232
|
+
return fired;
|
|
233
|
+
}
|
|
196
234
|
var node = startNode;
|
|
197
235
|
for (var depth = 0; depth < 15 && node; depth++) {
|
|
198
236
|
var pk = Object.keys(node).find(function(k) { return k.startsWith('__reactProps$'); });
|
|
199
|
-
if (pk && node[pk])
|
|
200
|
-
var props = node[pk];
|
|
201
|
-
if (tryOnChange(props, node)) return true;
|
|
202
|
-
if (props.onClick) { props.onClick(makeSynth(node, 'click')); return true; }
|
|
203
|
-
if (props.onMouseDown) { props.onMouseDown(makeSynth(node, 'mousedown')); return true; }
|
|
204
|
-
if (props.onPointerDown) { props.onPointerDown(makeSynth(node, 'pointerdown')); return true; }
|
|
205
|
-
}
|
|
237
|
+
if (pk && node[pk] && tryProps(node[pk], node)) return true;
|
|
206
238
|
var fk = Object.keys(node).find(function(k) { return k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'); });
|
|
207
239
|
if (fk) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
f = f.return;
|
|
240
|
+
// Walk fiber chain UP via .return (covers portals — Fiber tree is logical, not DOM-shaped).
|
|
241
|
+
var f = node[fk], hops = 0;
|
|
242
|
+
while (f && hops < 30) {
|
|
243
|
+
if (tryProps(f.memoizedProps, node)) return true;
|
|
244
|
+
// Components written as forwardRef put the click handler on stateNode.props
|
|
245
|
+
if (f.stateNode && f.stateNode.props && tryProps(f.stateNode.props, node)) return true;
|
|
246
|
+
f = f.return; hops++;
|
|
217
247
|
}
|
|
218
248
|
}
|
|
219
249
|
node = node.parentElement;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.10.0",
|
|
4
4
|
"mcpName": "io.github.achiya-automation/safari-mcp",
|
|
5
5
|
"description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
|
|
6
6
|
"type": "module",
|
package/safari.js
CHANGED
|
@@ -1042,32 +1042,39 @@ async function clickWithRetry(js) {
|
|
|
1042
1042
|
|
|
1043
1043
|
export async function click({ selector, text, x, y, ref }) {
|
|
1044
1044
|
await ensureHelpers();
|
|
1045
|
+
// Native <select> elements look like custom dropdowns on LinkedIn etc., but they
|
|
1046
|
+
// hand off to the OS-level option list. .click() on them blocks AppleScript until
|
|
1047
|
+
// the native popup is dismissed → the JSC eval times out and the tool returns an
|
|
1048
|
+
// error after seconds of hang. Detect early and return a clear directive instead.
|
|
1049
|
+
const selectGuard = `if(el&&el.tagName==='SELECT'){var opts=[];for(var oi=0;oi<el.options.length&&opts.length<8;oi++){opts.push(el.options[oi].text||el.options[oi].value);}return '__SELECT_GUARD__:'+(el.id||el.name||'select')+': '+opts.join('|');}`;
|
|
1050
|
+
let result;
|
|
1045
1051
|
if (ref) {
|
|
1046
|
-
|
|
1047
|
-
`(function(){var el=mcpFindRef('${ref}');if(!el)return 'Element not found: ref=${ref}'
|
|
1052
|
+
result = await clickWithRetry(
|
|
1053
|
+
`(function(){var el=mcpFindRef('${ref}');if(!el)return 'Element not found: ref=${ref}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
|
|
1048
1054
|
);
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
if (selector) {
|
|
1055
|
+
} else if (selector) {
|
|
1052
1056
|
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1053
|
-
|
|
1054
|
-
`(function(){var el=mcpQuerySelectorDeep('${sel}');if(!el)return 'Element not found: ${sel}'
|
|
1057
|
+
result = await clickWithRetry(
|
|
1058
|
+
`(function(){var el=mcpQuerySelectorDeep('${sel}');if(!el)return 'Element not found: ${sel}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+((target.innerText||target.textContent)?(' "'+(target.innerText||target.textContent).trim().substring(0,50)+'"'):'');})()`
|
|
1055
1059
|
);
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
if (text) {
|
|
1060
|
+
} else if (text) {
|
|
1059
1061
|
const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1060
|
-
|
|
1061
|
-
`(function(){var el=mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false);if(!el)return 'Element not found with text: ${safeText}'
|
|
1062
|
+
result = await clickWithRetry(
|
|
1063
|
+
`(function(){var el=mcpFindText('${safeText}',true)||mcpFindText('${safeText}',false);if(!el)return 'Element not found with text: ${safeText}';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' "'+((target.innerText||target.textContent)||'').trim().substring(0,50)+'"';})()`
|
|
1064
|
+
);
|
|
1065
|
+
} else if (x !== undefined && y !== undefined) {
|
|
1066
|
+
result = await clickWithRetry(
|
|
1067
|
+
`(function(){var el=mcpElementFromPoint(${Number(x)},${Number(y)});if(!el)return 'No element at (${Number(x)},${Number(y)})';${selectGuard}var target=mcpClickWithReact(el);return 'Clicked: '+target.tagName+' at (${Number(x)},${Number(y)})';})()`
|
|
1062
1068
|
);
|
|
1069
|
+
} else {
|
|
1070
|
+
throw new Error("click requires selector, text, or x/y coordinates");
|
|
1063
1071
|
}
|
|
1064
1072
|
|
|
1065
|
-
if (
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
);
|
|
1073
|
+
if (typeof result === 'string' && result.startsWith('__SELECT_GUARD__:')) {
|
|
1074
|
+
const detail = result.substring('__SELECT_GUARD__:'.length);
|
|
1075
|
+
throw new Error(`Target is a native <select> (${detail}). Use safari_select_option with a value matching one of the options instead — clicking it would open the OS picker and block.`);
|
|
1069
1076
|
}
|
|
1070
|
-
|
|
1077
|
+
return result;
|
|
1071
1078
|
}
|
|
1072
1079
|
|
|
1073
1080
|
export async function doubleClick({ selector, x, y, ref }) {
|
|
@@ -1289,18 +1296,60 @@ export async function fill({ selector, value, ref }) {
|
|
|
1289
1296
|
const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
1290
1297
|
const result = await runJS(
|
|
1291
1298
|
`(function(){try{var el=document.querySelector('${sel}');if(!el){var q=function(r){var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++){if(a[i].shadowRoot){el=a[i].shadowRoot.querySelector('${sel}');if(el)return el;el=q(a[i].shadowRoot);if(el)return el;}}return null;};el=q(document);}if(!el)return 'Element not found: ${sel}';el.focus();if(el.isContentEditable||el.getAttribute('contenteditable')==='true'){` +
|
|
1299
|
+
// Lexical editor detection (LinkedIn share composer, modern Meta/Shopify apps).
|
|
1300
|
+
// Three lookup strategies, cheapest first:
|
|
1301
|
+
// A) [data-lexical-editor="true"] on ancestor with __lexicalEditor property.
|
|
1302
|
+
// B) Walk DOM ancestors for __lexicalEditor (nested-config wrappers).
|
|
1303
|
+
// C) React Fiber walk — LinkedIn sometimes obfuscates __lexicalEditor, so we
|
|
1304
|
+
// locate the editor by duck-typing: any object on props/state/stateNode
|
|
1305
|
+
// that exposes both parseEditorState AND setEditorState is the editor.
|
|
1306
|
+
// Once found, two fill strategies, both pure DOM (no CGEvent, no focus shift):
|
|
1307
|
+
// 1) InputEvent('beforeinput', insertFromPaste + DataTransfer) — Lexical's
|
|
1308
|
+
// handleBeforeInput reads dataTransfer and commits via editor.update().
|
|
1309
|
+
// 2) parseEditorState() + setEditorState() with a minimal paragraph doc —
|
|
1310
|
+
// used when Lexical ignores synthetic paste events.
|
|
1311
|
+
`var lexEl=el.closest('[data-lexical-editor="true"]');var lex=(lexEl&&lexEl.__lexicalEditor)||null;` +
|
|
1312
|
+
`if(!lex){var lexCur=el;for(var lexI=0;lexI<15&&lexCur;lexI++){if(lexCur.__lexicalEditor){lexEl=lexCur;lex=lexCur.__lexicalEditor;break;}lexCur=lexCur.parentElement;}}` +
|
|
1313
|
+
`if(!lex){var lexHost=lexEl||el;var lexFk=null;for(var lfk in lexHost){if(lfk.indexOf('__reactFiber')===0||lfk.indexOf('__reactInternalInstance')===0){lexFk=lfk;break;}}` +
|
|
1314
|
+
`if(lexFk){var lexF=lexHost[lexFk];var lexIsEd=function(o){return o&&typeof o.parseEditorState==='function'&&typeof o.setEditorState==='function';};` +
|
|
1315
|
+
`for(var lexD=0;lexD<30&&lexF;lexD++){var lp=lexF.memoizedProps;if(lp){for(var lpk in lp){if(lexIsEd(lp[lpk])){lex=lp[lpk];break;}}}` +
|
|
1316
|
+
`if(!lex){var ls=lexF.memoizedState;while(ls){if(lexIsEd(ls.memoizedState)){lex=ls.memoizedState;break;}ls=ls.next;}}` +
|
|
1317
|
+
`if(!lex){var lsn=lexF.stateNode;if(lsn){if(lexIsEd(lsn))lex=lsn;else if(lsn.editor&&lexIsEd(lsn.editor))lex=lsn.editor;}}` +
|
|
1318
|
+
`if(lex)break;lexF=lexF.return;}}}` +
|
|
1319
|
+
`if(lex){if(!lexEl){lexEl=lex._rootElement||el;}` +
|
|
1320
|
+
// Strategy 1: beforeinput with insertFromPaste + DataTransfer
|
|
1321
|
+
`try{lexEl.focus();var lexSel=window.getSelection();if(lexSel){var lexRng=document.createRange();lexRng.selectNodeContents(lexEl);lexSel.removeAllRanges();lexSel.addRange(lexRng);}var lexDt=new DataTransfer();lexDt.setData('text/plain','${val}');var lexBi=new InputEvent('beforeinput',{inputType:'insertFromPaste',dataTransfer:lexDt,bubbles:true,cancelable:true});var lexCancelled=!lexEl.dispatchEvent(lexBi);if(lexCancelled||lexEl.textContent.indexOf('${val.substring(0, 20)}')>=0){return 'Filled CE (Lexical beforeinput paste)';}}catch(lexE1){}` +
|
|
1322
|
+
// Strategy 2: parseEditorState + setEditorState (direct state replacement)
|
|
1323
|
+
`try{var lexJson='{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"${val}","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"textFormat":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}';var lexNs=lex.parseEditorState(lexJson);lex.setEditorState(lexNs);if(typeof lex.focus==='function')lex.focus();return 'Filled CE (Lexical setEditorState)';}catch(lexE2){}` +
|
|
1324
|
+
`}` +
|
|
1292
1325
|
// ProseMirror detection
|
|
1293
1326
|
`var pm=el.closest('.ProseMirror')||el.querySelector('.ProseMirror');if(pm){try{var v=null;if(pm.pmViewDesc&&pm.pmViewDesc.view)v=pm.pmViewDesc.view;else if(pm.cmView&&pm.cmView.view)v=pm.cmView.view;else{var keys=Object.keys(pm);for(var ki=0;ki<keys.length;ki++){var o=pm[keys[ki]];if(o&&o.state&&o.dispatch){v=o;break;}}}` +
|
|
1294
1327
|
// React Fiber walk for ProseMirror view (LinkedIn, Tiptap-React)
|
|
1295
1328
|
`if(!v){var fk=Object.keys(pm).find(function(k){return k.startsWith('__reactFiber$')||k.startsWith('__reactInternalInstance$');});if(fk){var fiber=pm[fk];for(var d=0;d<20&&fiber;d++){var props=fiber.memoizedProps||(fiber.stateNode&&fiber.stateNode.props);if(props){var pv=props.editorView||props.view;if(pv&&pv.state&&pv.dispatch){v=pv;break;}}fiber=fiber.return;}}}` +
|
|
1296
|
-
`if(v&&v.state&&v.dispatch){var doc=v.state.doc;var hasContent=doc.textContent&&doc.textContent.trim().length>0;if(hasContent){var endPos=doc.content.size>1?doc.content.size-1:doc.content.size;v.dispatch(v.state.tr.insertText(' ${val}',endPos));
|
|
1329
|
+
`if(v&&v.state&&v.dispatch){try{var doc=v.state.doc;var hasContent=doc.textContent&&doc.textContent.trim().length>0;if(hasContent){var endPos=doc.content.size>1?doc.content.size-1:doc.content.size;v.dispatch(v.state.tr.insertText(' ${val}',endPos));}else{var tr=v.state.tr;tr.replaceWith(0,doc.content.size,v.state.schema.text('${val}'));v.dispatch(tr);}v.focus();` +
|
|
1330
|
+
// Verify the dispatch actually committed — LinkedIn's PM sometimes accepts the
|
|
1331
|
+
// tr but rolls it back on the next tick (its readOnly plugin reasserts state).
|
|
1332
|
+
// If the doc text doesn't contain our value within 50ms, route to native paste.
|
|
1333
|
+
`var pmCheck=(v.state.doc.textContent||'')+' '+(pm.textContent||'');` +
|
|
1334
|
+
`if(pmCheck.indexOf('${val.substring(0, Math.min(20, val.length))}')>=0){return 'Filled CE (ProseMirror)';}` +
|
|
1335
|
+
`}catch(_pmE){}}}catch(e){}}` +
|
|
1297
1336
|
// ProseMirror detected but no view — in a dialog (LinkedIn share composer), go to
|
|
1298
1337
|
// native paste (real CGEvent Cmd+V) so ProseMirror's isTrusted-gated paste handler
|
|
1299
1338
|
// accepts the insertion and the dialog doesn't dismiss. Outside a dialog fall back
|
|
1300
1339
|
// to char-by-char beforeinput (works on Discord-style PM without dismissal risk).
|
|
1301
1340
|
`if(pm&&!v){var inDlg=!!el.closest('[role="dialog"]');if(inDlg){return '__NATIVE_PASTE_DIALOG__';}return '__PM_CHARBYCHAR__';}` +
|
|
1302
|
-
// Closure/Medium detection — signal for native paste
|
|
1303
|
-
|
|
1341
|
+
// Closure/Medium detection — signal for native paste.
|
|
1342
|
+
// Match any of: closure_uid_ key on element, medium.com hostname, ancestor with
|
|
1343
|
+
// closure markers, or window.goog.events/goog.editor.Plugin globals (broader catch
|
|
1344
|
+
// for Closure-built editors outside Medium).
|
|
1345
|
+
`var isClosure=false;` +
|
|
1346
|
+
`try{` +
|
|
1347
|
+
`if(Object.keys(el).some(function(k){return k.indexOf('closure_uid_')===0||k.indexOf('closure_lm_')===0;}))isClosure=true;` +
|
|
1348
|
+
`else if(location.hostname.indexOf('medium.com')>=0)isClosure=true;` +
|
|
1349
|
+
`else if(typeof goog!=='undefined'&&goog&&(goog.events||goog.editor))isClosure=true;` +
|
|
1350
|
+
`else{var clCur=el.parentElement,clHop=0;while(clCur&&clHop<10){if(Object.keys(clCur).some(function(k){return k.indexOf('closure_uid_')===0;})){isClosure=true;break;}clCur=clCur.parentElement;clHop++;}}` +
|
|
1351
|
+
`}catch(_clE){}` +
|
|
1352
|
+
`if(isClosure){return '__CLOSURE_NATIVE_PASTE__';}` +
|
|
1304
1353
|
// Synthetic ClipboardEvent paste — works on ProseMirror, TipTap, Slate, and most modern editors
|
|
1305
1354
|
// that don't respond to execCommand but DO handle paste events
|
|
1306
1355
|
`try{el.focus();var sel2=window.getSelection();if(sel2.rangeCount){var rng=document.createRange();rng.selectNodeContents(el);sel2.removeAllRanges();sel2.addRange(rng);}var dt=new DataTransfer();dt.setData('text/plain','${val}');var pe=new ClipboardEvent('paste',{bubbles:true,cancelable:true,clipboardData:dt});var handled=!el.dispatchEvent(pe);if(handled||el.textContent.indexOf('${val.substring(0, 20)}')>=0){return 'Filled CE (synthetic paste)';}}catch(ep){}` +
|
|
@@ -1310,8 +1359,26 @@ export async function fill({ selector, value, ref }) {
|
|
|
1310
1359
|
// Default contenteditable: selectAll+delete+insert. No blur — blur dismisses dialogs
|
|
1311
1360
|
// and nearby popovers; React only needs 'input' to see the change.
|
|
1312
1361
|
`document.execCommand('selectAll');document.execCommand('delete');document.execCommand('insertText',false,'${val}');el.dispatchEvent(new Event('input',{bubbles:true}));return 'Filled contenteditable';}` +
|
|
1313
|
-
// Standard input/textarea with _valueTracker
|
|
1314
|
-
|
|
1362
|
+
// Standard input/textarea with _valueTracker.
|
|
1363
|
+
// • focus() FIRST — Formik/HubSpot read focused state during onChange validation.
|
|
1364
|
+
// • InputEvent with inputType:'insertReplacementText' + data — React 18 RSC, Next.js,
|
|
1365
|
+
// and Featured.com require a real InputEvent (not plain Event) to update store state.
|
|
1366
|
+
// • composed:true on every event — pierces Shadow DOM (Reddit, web components).
|
|
1367
|
+
// • blur/focusout SUPPRESSED inside dialogs — blur dismisses LinkedIn share composer
|
|
1368
|
+
// and many MUI/Radix dialogs. React only needs 'input' to see the change anyway.
|
|
1369
|
+
// • Post-fill verification — if el.value !== expected after dispatch, signal the wrapper
|
|
1370
|
+
// to try native CGEvent paste fallback (real isTrusted events).
|
|
1371
|
+
`var inDlg=!!(el.closest&&el.closest('[role="dialog"]'));` +
|
|
1372
|
+
`var t=el._valueTracker;if(t)t.setValue('');` +
|
|
1373
|
+
`var proto=el.tagName==='TEXTAREA'?window.HTMLTextAreaElement.prototype:window.HTMLInputElement.prototype;` +
|
|
1374
|
+
`var s=Object.getOwnPropertyDescriptor(proto,'value');` +
|
|
1375
|
+
`if(s&&s.set){s.set.call(el,'${val}');}else{el.value='${val}';}` +
|
|
1376
|
+
`try{el.dispatchEvent(new InputEvent('input',{inputType:'insertReplacementText',data:'${val}',bubbles:true,composed:true,cancelable:true}));}catch(_iE){el.dispatchEvent(new Event('input',{bubbles:true,composed:true}));}` +
|
|
1377
|
+
`el.dispatchEvent(new Event('change',{bubbles:true,composed:true}));` +
|
|
1378
|
+
`if(!inDlg){el.dispatchEvent(new Event('blur',{bubbles:true,composed:true}));el.dispatchEvent(new Event('focusout',{bubbles:true,composed:true}));el.focus();}` +
|
|
1379
|
+
`var actual=(el.value!==undefined?el.value:'');` +
|
|
1380
|
+
`if(actual!=='${val}'){return '__FILL_VALUE_MISMATCH__:'+actual.substring(0,80);}` +
|
|
1381
|
+
`return 'Filled: '+actual.substring(0,50);}catch(e){return 'ERR: '+e.message;}})()`
|
|
1315
1382
|
);
|
|
1316
1383
|
|
|
1317
1384
|
// ProseMirror editor with no view access: use char-by-char with beforeinput events
|
|
@@ -1382,6 +1449,26 @@ export async function fill({ selector, value, ref }) {
|
|
|
1382
1449
|
return fillResult;
|
|
1383
1450
|
}
|
|
1384
1451
|
|
|
1452
|
+
// Standard input/textarea path returned with el.value mismatched against expected —
|
|
1453
|
+
// typically Next.js RSC (Featured.com) or HubSpot Formik silently ignored the property
|
|
1454
|
+
// descriptor setter. Fall back to native CGEvent Cmd+V paste — real keyboard events
|
|
1455
|
+
// route through the framework's normal input pipeline.
|
|
1456
|
+
if (typeof result === 'string' && result.startsWith('__FILL_VALUE_MISMATCH__')) {
|
|
1457
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1458
|
+
await runJS(
|
|
1459
|
+
`(function(){var el=document.querySelector('${sel}');if(!el)return 'not-found';` +
|
|
1460
|
+
`el.focus();if('select' in el){el.select();}else if(el.setSelectionRange){el.setSelectionRange(0,(el.value||'').length);}` +
|
|
1461
|
+
`return 'focused';})()`
|
|
1462
|
+
);
|
|
1463
|
+
await new Promise(r => setTimeout(r, 30));
|
|
1464
|
+
try {
|
|
1465
|
+
await _nativeTypeViaClipboard(value);
|
|
1466
|
+
return `Filled (native paste fallback after value-mismatch, ${value.length} chars)`;
|
|
1467
|
+
} catch (e) {
|
|
1468
|
+
return `${result} (native paste fallback failed: ${e.message})`;
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1385
1472
|
// ProseMirror/contenteditable inside a dialog (LinkedIn share composer is the canonical
|
|
1386
1473
|
// case). Synthetic events get rejected by isTrusted-gated paste handlers, and stray blur
|
|
1387
1474
|
// events dismiss the dialog. Use CGEvent Cmd+V — real paste, isTrusted:true, windowed so
|
|
@@ -1407,6 +1494,90 @@ export async function fill({ selector, value, ref }) {
|
|
|
1407
1494
|
return result;
|
|
1408
1495
|
}
|
|
1409
1496
|
|
|
1497
|
+
// Verify the framework-level state of an editor or input matches `expected`.
|
|
1498
|
+
// Modern editors (ProseMirror, Lexical, Closure, React-controlled inputs) maintain
|
|
1499
|
+
// state separately from the DOM — `.value` or `.textContent` can show the new text
|
|
1500
|
+
// while the framework's internal store still holds the old value, so a Submit click
|
|
1501
|
+
// sends the stale data. Call this after `fill` and BEFORE `click`-Submit.
|
|
1502
|
+
//
|
|
1503
|
+
// Returns JSON: { match: boolean, mode: 'input'|'prosemirror'|'lexical'|'closure'|'contenteditable',
|
|
1504
|
+
// actual: string, expected: string, hint?: string }
|
|
1505
|
+
export async function verifyState({ selector, expected, ref }) {
|
|
1506
|
+
if (ref) selector = refSelector(ref);
|
|
1507
|
+
if (!selector) throw new Error("verifyState requires selector or ref");
|
|
1508
|
+
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1509
|
+
const exp = String(expected || '').replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
1510
|
+
const expSnippet = String(expected || '').substring(0, 30).replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
|
|
1511
|
+
return runJS(
|
|
1512
|
+
`(function(){
|
|
1513
|
+
var el = document.querySelector('${sel}');
|
|
1514
|
+
if (!el && window.mcpQuerySelectorDeep) el = window.mcpQuerySelectorDeep('${sel}');
|
|
1515
|
+
if (!el) return JSON.stringify({ match: false, mode: 'not-found', actual: '', expected: '${exp}' });
|
|
1516
|
+
|
|
1517
|
+
var expected = '${exp}';
|
|
1518
|
+
var snippet = '${expSnippet}';
|
|
1519
|
+
|
|
1520
|
+
// ProseMirror — check view state, not DOM
|
|
1521
|
+
var pm = el.closest && el.closest('.ProseMirror') || el.querySelector && el.querySelector('.ProseMirror');
|
|
1522
|
+
if (pm) {
|
|
1523
|
+
var v = (pm.pmViewDesc && pm.pmViewDesc.view) || (pm.cmView && pm.cmView.view) || null;
|
|
1524
|
+
if (!v) {
|
|
1525
|
+
var keys = Object.keys(pm);
|
|
1526
|
+
for (var ki = 0; ki < keys.length; ki++) { var o = pm[keys[ki]]; if (o && o.state && o.dispatch) { v = o; break; } }
|
|
1527
|
+
}
|
|
1528
|
+
if (!v) {
|
|
1529
|
+
var fk = Object.keys(pm).find(function(k){return k.indexOf('__reactFiber')===0||k.indexOf('__reactInternalInstance')===0;});
|
|
1530
|
+
if (fk) { var fb = pm[fk]; for (var d=0; d<20 && fb; d++){ var pp=fb.memoizedProps||(fb.stateNode&&fb.stateNode.props); if (pp){ var pv=pp.editorView||pp.view; if (pv&&pv.state){ v=pv; break; }} fb=fb.return; }}
|
|
1531
|
+
}
|
|
1532
|
+
var pmText = v && v.state && v.state.doc ? (v.state.doc.textContent || '') : (pm.textContent || '');
|
|
1533
|
+
return JSON.stringify({ match: pmText.indexOf(snippet) >= 0, mode: 'prosemirror', actual: pmText.substring(0, 200), expected: expected.substring(0, 200) });
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// Lexical — read editor state
|
|
1537
|
+
var lexEl = el.closest && el.closest('[data-lexical-editor="true"]');
|
|
1538
|
+
var lex = lexEl && lexEl.__lexicalEditor;
|
|
1539
|
+
if (!lex) {
|
|
1540
|
+
var lc = el; for (var li = 0; li < 15 && lc; li++) { if (lc.__lexicalEditor) { lex = lc.__lexicalEditor; break; } lc = lc.parentElement; }
|
|
1541
|
+
}
|
|
1542
|
+
if (lex && typeof lex.getEditorState === 'function') {
|
|
1543
|
+
try {
|
|
1544
|
+
var lexText = '';
|
|
1545
|
+
lex.getEditorState().read(function(){
|
|
1546
|
+
var root = lex._editorState && lex._editorState._nodeMap ? lex._editorState._nodeMap.get('root') : null;
|
|
1547
|
+
// Fallback: read DOM text since direct API requires Lexical's $getRoot
|
|
1548
|
+
lexText = (lexEl || el).textContent || '';
|
|
1549
|
+
});
|
|
1550
|
+
return JSON.stringify({ match: lexText.indexOf(snippet) >= 0, mode: 'lexical', actual: lexText.substring(0, 200), expected: expected.substring(0, 200) });
|
|
1551
|
+
} catch (_lE) {}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// Closure (Medium and similar) — check element textContent (Closure mutates DOM directly)
|
|
1555
|
+
var isClosure = Object.keys(el).some(function(k){return k.indexOf('closure_uid_')===0;}) || location.hostname.indexOf('medium.com') >= 0;
|
|
1556
|
+
if (isClosure) {
|
|
1557
|
+
var clText = el.textContent || '';
|
|
1558
|
+
return JSON.stringify({ match: clText.indexOf(snippet) >= 0, mode: 'closure', actual: clText.substring(0, 200), expected: expected.substring(0, 200) });
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// Generic contenteditable
|
|
1562
|
+
if (el.isContentEditable) {
|
|
1563
|
+
var ceText = el.textContent || '';
|
|
1564
|
+
return JSON.stringify({ match: ceText.indexOf(snippet) >= 0, mode: 'contenteditable', actual: ceText.substring(0, 200), expected: expected.substring(0, 200) });
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// Standard input/textarea/select
|
|
1568
|
+
var actualVal = el.value !== undefined ? String(el.value) : '';
|
|
1569
|
+
var match = actualVal === expected;
|
|
1570
|
+
var hint = '';
|
|
1571
|
+
// Detect React store/_valueTracker mismatch — common Featured.com / Next.js RSC bug
|
|
1572
|
+
if (!match && el._valueTracker && typeof el._valueTracker.getValue === 'function') {
|
|
1573
|
+
var tracked = el._valueTracker.getValue();
|
|
1574
|
+
if (tracked !== actualVal) hint = 'React _valueTracker out of sync (tracked=' + JSON.stringify(tracked.substring(0, 60)) + ')';
|
|
1575
|
+
}
|
|
1576
|
+
return JSON.stringify({ match: match, mode: 'input', actual: actualVal.substring(0, 200), expected: expected.substring(0, 200), hint: hint });
|
|
1577
|
+
})()`
|
|
1578
|
+
);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1410
1581
|
export async function clearField({ selector }) {
|
|
1411
1582
|
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
1412
1583
|
return runJS(
|
|
@@ -1423,7 +1594,9 @@ export async function selectOption({ selector, value }) {
|
|
|
1423
1594
|
}
|
|
1424
1595
|
|
|
1425
1596
|
export async function fillForm({ fields }) {
|
|
1426
|
-
// Single JS call for ALL fields (instead of N separate osascript calls)
|
|
1597
|
+
// Single JS call for ALL fields (instead of N separate osascript calls).
|
|
1598
|
+
// Same React-state-sync logic as `fill`: _valueTracker reset, prototype-correct setter,
|
|
1599
|
+
// InputEvent with inputType, composed events for shadow DOM, dialog-aware blur.
|
|
1427
1600
|
const fieldsJSON = JSON.stringify(fields.map(f => ({
|
|
1428
1601
|
s: f.selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'"),
|
|
1429
1602
|
v: f.value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n"),
|
|
@@ -1434,20 +1607,42 @@ export async function fillForm({ fields }) {
|
|
|
1434
1607
|
var results = [];
|
|
1435
1608
|
fields.forEach(function(f) {
|
|
1436
1609
|
var el = document.querySelector(f.s);
|
|
1610
|
+
if (!el) {
|
|
1611
|
+
var roots = window.mcpCollectRoots ? window.mcpCollectRoots() : [document];
|
|
1612
|
+
for (var ri = 0; ri < roots.length && !el; ri++) {
|
|
1613
|
+
try { el = roots[ri].querySelector(f.s); } catch (_e) {}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1437
1616
|
if (!el) { results.push('Not found: ' + f.s); return; }
|
|
1438
1617
|
el.focus();
|
|
1618
|
+
var inDlg = !!(el.closest && el.closest('[role="dialog"]'));
|
|
1439
1619
|
if (el.isContentEditable) {
|
|
1440
|
-
|
|
1620
|
+
try {
|
|
1621
|
+
var sel = window.getSelection();
|
|
1622
|
+
if (sel) { var rng = document.createRange(); rng.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(rng); }
|
|
1623
|
+
document.execCommand('delete');
|
|
1624
|
+
} catch (_seE) {}
|
|
1441
1625
|
document.execCommand('insertText', false, f.v);
|
|
1626
|
+
el.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
1442
1627
|
} else {
|
|
1443
|
-
var
|
|
1444
|
-
|
|
1628
|
+
var t = el._valueTracker; if (t) t.setValue('');
|
|
1629
|
+
var proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype :
|
|
1630
|
+
el.tagName === 'SELECT' ? window.HTMLSelectElement.prototype :
|
|
1631
|
+
window.HTMLInputElement.prototype;
|
|
1632
|
+
var setter = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
1445
1633
|
if (setter && setter.set) setter.set.call(el, f.v);
|
|
1446
1634
|
else el.value = f.v;
|
|
1447
|
-
el.dispatchEvent(new
|
|
1448
|
-
el.dispatchEvent(new Event('
|
|
1635
|
+
try { el.dispatchEvent(new InputEvent('input', { inputType: 'insertReplacementText', data: f.v, bubbles: true, composed: true, cancelable: true })); }
|
|
1636
|
+
catch (_iE) { el.dispatchEvent(new Event('input', { bubbles: true, composed: true })); }
|
|
1637
|
+
el.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
|
|
1638
|
+
if (!inDlg) {
|
|
1639
|
+
el.dispatchEvent(new Event('blur', { bubbles: true, composed: true }));
|
|
1640
|
+
el.dispatchEvent(new Event('focusout', { bubbles: true, composed: true }));
|
|
1641
|
+
}
|
|
1449
1642
|
}
|
|
1450
|
-
|
|
1643
|
+
var actual = el.value !== undefined ? String(el.value) : (el.textContent || '');
|
|
1644
|
+
var match = actual === f.v || actual.indexOf(f.v.substring(0, 20)) >= 0;
|
|
1645
|
+
results.push((match ? 'Filled' : 'PARTIAL') + ': ' + el.tagName + ' with "' + f.v.substring(0, 30) + '"');
|
|
1451
1646
|
});
|
|
1452
1647
|
return results.join('\\n');
|
|
1453
1648
|
})()`
|
|
@@ -2672,11 +2867,38 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
2672
2867
|
|
|
2673
2868
|
const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2674
2869
|
const { basename, extname } = await import("node:path");
|
|
2675
|
-
|
|
2676
|
-
|
|
2870
|
+
let fileName = basename(filePath);
|
|
2871
|
+
let ext = extname(filePath).toLowerCase().replace(".", "");
|
|
2872
|
+
let resolvedPath = filePath;
|
|
2873
|
+
|
|
2874
|
+
// Auto-convert images that the target input rejects.
|
|
2875
|
+
// Quora is the canonical case — declares accept="image/png,image/jpeg" and silently
|
|
2876
|
+
// ignores webp drops. Convert webp/heic to PNG via macOS sips (no extra deps).
|
|
2877
|
+
const imageFormats = new Set(['webp', 'heic', 'heif', 'tiff', 'tif']);
|
|
2878
|
+
if (imageFormats.has(ext)) {
|
|
2879
|
+
const accept = await runJS(
|
|
2880
|
+
`(function(){var el=document.querySelector('${sel}');if(!el){var roots=window.mcpCollectRoots?window.mcpCollectRoots():[document];for(var i=0;i<roots.length;i++){el=roots[i].querySelector('${sel}');if(el)break;}}return el?(el.getAttribute('accept')||''):'';})()`
|
|
2881
|
+
).catch(() => '');
|
|
2882
|
+
const acceptStr = String(accept || '').toLowerCase();
|
|
2883
|
+
const accepted = !acceptStr ||
|
|
2884
|
+
acceptStr.includes('image/*') ||
|
|
2885
|
+
acceptStr.includes(`image/${ext}`) ||
|
|
2886
|
+
acceptStr.includes(`.${ext}`);
|
|
2887
|
+
if (!accepted) {
|
|
2888
|
+
const tmpPng = join(tmpdir(), `safari-mcp-upload-${Date.now()}.png`);
|
|
2889
|
+
try {
|
|
2890
|
+
await execFileAsync('sips', ['-s', 'format', 'png', resolvedPath, '--out', tmpPng], { timeout: 15000 });
|
|
2891
|
+
resolvedPath = tmpPng;
|
|
2892
|
+
fileName = basename(tmpPng);
|
|
2893
|
+
ext = 'png';
|
|
2894
|
+
} catch (sipsErr) {
|
|
2895
|
+
console.error(`[Safari MCP] sips conversion failed (${ext}→png): ${sipsErr.message}. Continuing with original.`);
|
|
2896
|
+
}
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2677
2899
|
|
|
2678
2900
|
// Read file as base64
|
|
2679
|
-
const fileData = await readFile(
|
|
2901
|
+
const fileData = await readFile(resolvedPath);
|
|
2680
2902
|
const base64 = fileData.toString("base64");
|
|
2681
2903
|
|
|
2682
2904
|
// Determine MIME type
|