yunti-browser-runtime 0.1.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/LICENSE +21 -0
- package/README.md +256 -0
- package/bin/yunti-browser-runtime.js +86 -0
- package/docs/EXECUTION_PLAN.md +1278 -0
- package/docs/INSTALL.md +205 -0
- package/docs/PROJECT_INTENT.md +44 -0
- package/docs/PROJECT_STATUS.md +263 -0
- package/docs/PUBLISHING_BLOCKERS.md +110 -0
- package/docs/RELEASE.md +148 -0
- package/docs/ROADMAP.md +42 -0
- package/docs/SECURITY.md +56 -0
- package/docs/TOOL_GUIDE.md +69 -0
- package/extension/background.js +55 -0
- package/extension/cdp.js +582 -0
- package/extension/content.css +9 -0
- package/extension/content.js +946 -0
- package/extension/manifest.json +35 -0
- package/extension/network-monitor.js +140 -0
- package/extension/popup.css +66 -0
- package/extension/popup.html +30 -0
- package/extension/popup.js +55 -0
- package/extension/session-manager.js +332 -0
- package/extension/settings.js +94 -0
- package/extension/tool-handlers.js +1158 -0
- package/lib/runtime-paths.js +39 -0
- package/mcp/bridge-hub.js +604 -0
- package/mcp/http-server.js +326 -0
- package/mcp/json-rpc.js +35 -0
- package/mcp/memory.js +126 -0
- package/mcp/redaction.js +94 -0
- package/mcp/server.js +269 -0
- package/mcp/tools.js +1092 -0
- package/package.json +60 -0
- package/scripts/check-package-metadata.js +131 -0
- package/scripts/check-published-package.js +113 -0
- package/scripts/doctor.js +163 -0
- package/scripts/package-extension.js +137 -0
- package/scripts/print-config.js +116 -0
- package/scripts/release-check.js +472 -0
- package/skills/yunti-browser-runtime/SKILL.md +77 -0
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
export function createToolDispatcher({
|
|
2
|
+
sessionsByTab,
|
|
3
|
+
pollers,
|
|
4
|
+
postBridge,
|
|
5
|
+
startPolling,
|
|
6
|
+
cdp,
|
|
7
|
+
}) {
|
|
8
|
+
const {
|
|
9
|
+
chromeDebuggerSendCommand,
|
|
10
|
+
delayCdp,
|
|
11
|
+
detachCdpTab,
|
|
12
|
+
ensureCdpAttached,
|
|
13
|
+
getBrowserTarget,
|
|
14
|
+
listBrowserTargets,
|
|
15
|
+
sendCdpCommand,
|
|
16
|
+
startPerformanceTrace,
|
|
17
|
+
stopPerformanceTrace,
|
|
18
|
+
} = cdp
|
|
19
|
+
|
|
20
|
+
async function executeToolRequest(tabId, session, event) {
|
|
21
|
+
let result
|
|
22
|
+
let ok = true
|
|
23
|
+
let error = null
|
|
24
|
+
try {
|
|
25
|
+
if (event.tool === "yunti_capture_visible_tab") {
|
|
26
|
+
try {
|
|
27
|
+
const dataUrl = await chrome.tabs.captureVisibleTab(session.windowId, {
|
|
28
|
+
format: "png",
|
|
29
|
+
})
|
|
30
|
+
result = {
|
|
31
|
+
dataUrl,
|
|
32
|
+
mimeType: "image/png",
|
|
33
|
+
method: "tabs.captureVisibleTab",
|
|
34
|
+
browserSessionId: session.browserSessionId,
|
|
35
|
+
url: session.url,
|
|
36
|
+
title: session.title || "",
|
|
37
|
+
capturedAt: new Date().toISOString(),
|
|
38
|
+
}
|
|
39
|
+
} catch (permError) {
|
|
40
|
+
// Fall back to CDP Page.captureScreenshot when activeTab
|
|
41
|
+
// permission is not available (e.g. tool call via relay without
|
|
42
|
+
// direct user interaction on the extension)
|
|
43
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
44
|
+
const shot = await chromeDebuggerSendCommand(
|
|
45
|
+
{ tabId },
|
|
46
|
+
"Page.captureScreenshot",
|
|
47
|
+
{ format: "png" }
|
|
48
|
+
)
|
|
49
|
+
const dataUrl = `data:image/png;base64,${shot?.data || ""}`
|
|
50
|
+
result = {
|
|
51
|
+
dataUrl,
|
|
52
|
+
mimeType: "image/png",
|
|
53
|
+
method: "cdp.Page.captureScreenshot",
|
|
54
|
+
browserSessionId: session.browserSessionId,
|
|
55
|
+
url: session.url,
|
|
56
|
+
title: session.title || "",
|
|
57
|
+
capturedAt: new Date().toISOString(),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} else if (event.tool === "yunti_cdp_send_command") {
|
|
61
|
+
result = await sendCdpCommand(tabId, session, event.arguments || {})
|
|
62
|
+
} else if (event.tool === "yunti_list_browser_targets" || event.tool === "yunti_list_pages") {
|
|
63
|
+
result = await listBrowserTargets(session)
|
|
64
|
+
} else if (event.tool === "yunti_get_browser_target") {
|
|
65
|
+
result = await getBrowserTarget(session, event.arguments || {})
|
|
66
|
+
} else if (event.tool === "yunti_cdp_detach") {
|
|
67
|
+
result = await detachCdpTab(tabId, session, "tool_request")
|
|
68
|
+
} else if (event.tool === "yunti_navigate_page") {
|
|
69
|
+
result = await navigatePage(tabId, session, event.arguments || {})
|
|
70
|
+
} else if (event.tool === "yunti_take_screenshot") {
|
|
71
|
+
result = await takeScreenshot(tabId, session, event.arguments || {})
|
|
72
|
+
} else if (event.tool === "yunti_evaluate_script") {
|
|
73
|
+
result = await evaluateScript(tabId, session, event.arguments || {})
|
|
74
|
+
} else if (event.tool === "yunti_take_snapshot") {
|
|
75
|
+
result = await takeSnapshot(tabId, session, event.arguments || {})
|
|
76
|
+
} else if (event.tool === "yunti_click") {
|
|
77
|
+
result = await clickByUid(tabId, session, event.arguments || {})
|
|
78
|
+
} else if (event.tool === "yunti_hover") {
|
|
79
|
+
result = await hoverByUid(tabId, session, event.arguments || {})
|
|
80
|
+
} else if (event.tool === "yunti_fill") {
|
|
81
|
+
result = await fillByUid(tabId, session, event.arguments || {})
|
|
82
|
+
} else if (event.tool === "yunti_fill_form") {
|
|
83
|
+
result = await fillForm(tabId, session, event.arguments || {})
|
|
84
|
+
} else if (event.tool === "yunti_wait_for") {
|
|
85
|
+
result = await waitForCondition(tabId, session, event.arguments || {})
|
|
86
|
+
} else if (event.tool === "yunti_handle_dialog") {
|
|
87
|
+
result = await handleDialog(tabId, session, event.arguments || {})
|
|
88
|
+
} else if (event.tool === "yunti_resize_page") {
|
|
89
|
+
result = await resizePage(tabId, session, event.arguments || {})
|
|
90
|
+
} else if (event.tool === "yunti_emulate") {
|
|
91
|
+
result = await emulateDevice(tabId, session, event.arguments || {})
|
|
92
|
+
} else if (event.tool === "yunti_performance_start_trace") {
|
|
93
|
+
result = await startPerformanceTrace(tabId, session, event.arguments || {})
|
|
94
|
+
} else if (event.tool === "yunti_performance_stop_trace") {
|
|
95
|
+
result = await stopPerformanceTrace(tabId, session)
|
|
96
|
+
} else if (event.tool === "yunti_new_page") {
|
|
97
|
+
result = await newPage(session, event.arguments || {})
|
|
98
|
+
} else if (event.tool === "yunti_close_page") {
|
|
99
|
+
result = await closePage(tabId, session, event.arguments || {})
|
|
100
|
+
} else if (event.tool === "yunti_drag") {
|
|
101
|
+
result = await performDrag(tabId, session, event.arguments || {})
|
|
102
|
+
} else if (event.tool === "yunti_upload_file") {
|
|
103
|
+
result = await uploadFile(tabId, session, event.arguments || {})
|
|
104
|
+
} else if (event.tool === "yunti_type_text") {
|
|
105
|
+
result = await typeTextByUid(tabId, session, event.arguments || {})
|
|
106
|
+
} else if (event.tool === "yunti_press_key") {
|
|
107
|
+
result = await pressKeyByUid(tabId, session, event.arguments || {})
|
|
108
|
+
} else {
|
|
109
|
+
result = await chrome.tabs.sendMessage(tabId, {
|
|
110
|
+
type: "yunti_execute_tool",
|
|
111
|
+
tool: event.tool,
|
|
112
|
+
arguments: event.arguments || {},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
ok = false
|
|
117
|
+
error = err instanceof Error ? err.message : String(err)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await postBridge("/extension/result", {
|
|
121
|
+
browserSessionId: session.browserSessionId,
|
|
122
|
+
requestId: event.id,
|
|
123
|
+
ok,
|
|
124
|
+
result,
|
|
125
|
+
error,
|
|
126
|
+
}).catch(() => {})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function navigatePage(tabId, session, args = {}) {
|
|
130
|
+
const action = String(args.action || "url").trim()
|
|
131
|
+
const url = String(args.url || "").trim()
|
|
132
|
+
|
|
133
|
+
if (action === "reload") {
|
|
134
|
+
await chrome.tabs.reload(tabId)
|
|
135
|
+
return { navigated: true, action: "reload", tabId, browserSessionId: session.browserSessionId }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (action === "back" || action === "forward") {
|
|
139
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
140
|
+
let history
|
|
141
|
+
try {
|
|
142
|
+
history = await chromeDebuggerSendCommand(
|
|
143
|
+
{ tabId },
|
|
144
|
+
"Page.getNavigationHistory",
|
|
145
|
+
{}
|
|
146
|
+
)
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return navigateHistoryViaRuntime(
|
|
149
|
+
tabId,
|
|
150
|
+
session,
|
|
151
|
+
action,
|
|
152
|
+
`Page.getNavigationHistory failed: ${error?.message || String(error)}`
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
const entries = history?.entries || []
|
|
156
|
+
const currentIndex = history?.currentIndex ?? -1
|
|
157
|
+
const targetIndex = action === "back" ? currentIndex - 1 : currentIndex + 1
|
|
158
|
+
if (targetIndex < 0 || targetIndex >= entries.length) {
|
|
159
|
+
return navigateHistoryViaRuntime(
|
|
160
|
+
tabId,
|
|
161
|
+
session,
|
|
162
|
+
action,
|
|
163
|
+
`no CDP history entry at index ${targetIndex}`
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await chromeDebuggerSendCommand(
|
|
168
|
+
{ tabId },
|
|
169
|
+
"Page.navigateToHistoryEntry",
|
|
170
|
+
{ entryId: entries[targetIndex].id }
|
|
171
|
+
)
|
|
172
|
+
return {
|
|
173
|
+
navigated: true,
|
|
174
|
+
action,
|
|
175
|
+
tabId,
|
|
176
|
+
url: entries[targetIndex].url,
|
|
177
|
+
browserSessionId: session.browserSessionId,
|
|
178
|
+
method: "Page.navigateToHistoryEntry",
|
|
179
|
+
}
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return navigateHistoryViaRuntime(
|
|
182
|
+
tabId,
|
|
183
|
+
session,
|
|
184
|
+
action,
|
|
185
|
+
`Page.navigateToHistoryEntry failed: ${error?.message || String(error)}`
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// action === "url" (default)
|
|
191
|
+
if (!url) throw new Error("url is required for navigate action 'url'")
|
|
192
|
+
await chrome.tabs.update(tabId, { url })
|
|
193
|
+
return { navigated: true, action: "url", url, tabId, browserSessionId: session.browserSessionId }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function navigateHistoryViaRuntime(tabId, session, action, reason) {
|
|
197
|
+
const method = action === "back" ? "back" : "forward"
|
|
198
|
+
try {
|
|
199
|
+
await chromeDebuggerSendCommand(
|
|
200
|
+
{ tabId },
|
|
201
|
+
"Runtime.evaluate",
|
|
202
|
+
{
|
|
203
|
+
expression: `window.history.${method}(); true`,
|
|
204
|
+
returnByValue: true,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Cannot navigate ${action}: ${reason}; Runtime.evaluate window.history.${method}() failed: ${error?.message || String(error)}`
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
navigated: true,
|
|
214
|
+
action,
|
|
215
|
+
tabId,
|
|
216
|
+
browserSessionId: session.browserSessionId,
|
|
217
|
+
method: `Runtime.evaluate(window.history.${method})`,
|
|
218
|
+
fallback: true,
|
|
219
|
+
fallbackReason: reason,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function takeScreenshot(tabId, session, args = {}) {
|
|
224
|
+
const format = String(args.format || "png").trim() === "jpeg" ? "jpeg" : "png"
|
|
225
|
+
const quality = format === "jpeg" ? clampQuality(args.quality) : undefined
|
|
226
|
+
const fullPage = Boolean(args.fullPage)
|
|
227
|
+
const clip = normalizeClip(args.clip)
|
|
228
|
+
|
|
229
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
230
|
+
|
|
231
|
+
if (fullPage) {
|
|
232
|
+
// Get full page dimensions
|
|
233
|
+
const metrics = await chromeDebuggerSendCommand(
|
|
234
|
+
{ tabId },
|
|
235
|
+
"Page.getLayoutMetrics",
|
|
236
|
+
{}
|
|
237
|
+
)
|
|
238
|
+
const cssContentSize = metrics?.cssContentSize || metrics?.contentSize || {}
|
|
239
|
+
const width = cssContentSize.width || 1280
|
|
240
|
+
const height = cssContentSize.height || 720
|
|
241
|
+
|
|
242
|
+
await chromeDebuggerSendCommand(
|
|
243
|
+
{ tabId },
|
|
244
|
+
"Emulation.setDeviceMetricsOverride",
|
|
245
|
+
{ width: Math.ceil(width), height: Math.ceil(height), deviceScaleFactor: 1, mobile: false }
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const params = { format }
|
|
250
|
+
if (quality !== undefined) params.quality = quality
|
|
251
|
+
if (clip) {
|
|
252
|
+
params.clip = { x: clip.x, y: clip.y, width: clip.width, height: clip.height, scale: clip.scale ?? 1 }
|
|
253
|
+
}
|
|
254
|
+
if (fullPage) {
|
|
255
|
+
params.captureBeyondViewport = true
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let shot
|
|
259
|
+
try {
|
|
260
|
+
shot = await chromeDebuggerSendCommand({ tabId }, "Page.captureScreenshot", params)
|
|
261
|
+
} finally {
|
|
262
|
+
if (fullPage) {
|
|
263
|
+
await chromeDebuggerSendCommand(
|
|
264
|
+
{ tabId },
|
|
265
|
+
"Emulation.clearDeviceMetricsOverride",
|
|
266
|
+
{}
|
|
267
|
+
).catch(() => {})
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const mimeType = `image/${format}`
|
|
272
|
+
const dataUrl = `data:${mimeType};base64,${shot?.data || ""}`
|
|
273
|
+
return {
|
|
274
|
+
dataUrl,
|
|
275
|
+
mimeType,
|
|
276
|
+
browserSessionId: session.browserSessionId,
|
|
277
|
+
format,
|
|
278
|
+
fullPage,
|
|
279
|
+
capturedAt: new Date().toISOString(),
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function clampQuality(value) {
|
|
284
|
+
const num = Number(value)
|
|
285
|
+
return Number.isFinite(num) ? Math.max(0, Math.min(100, Math.round(num))) : 80
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function normalizeClip(value) {
|
|
289
|
+
if (!value || typeof value !== "object") return null
|
|
290
|
+
const x = Number(value.x)
|
|
291
|
+
const y = Number(value.y)
|
|
292
|
+
const width = Number(value.width)
|
|
293
|
+
const height = Number(value.height)
|
|
294
|
+
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(width) || !Number.isFinite(height)) return null
|
|
295
|
+
const scale = Number.isFinite(Number(value.scale)) && Number(value.scale) > 0 ? Number(value.scale) : 1
|
|
296
|
+
return { x, y, width, height, scale }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function evaluateScript(tabId, session, args = {}) {
|
|
300
|
+
const expression = String(args.expression || "").trim()
|
|
301
|
+
if (!expression) throw new Error("expression is required")
|
|
302
|
+
const awaitPromise = Boolean(args.awaitPromise)
|
|
303
|
+
const maxLength = Number.isFinite(Number(args.maxLength)) && Number(args.maxLength) > 0
|
|
304
|
+
? Math.min(Number(args.maxLength), 100000)
|
|
305
|
+
: 10000
|
|
306
|
+
|
|
307
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
308
|
+
|
|
309
|
+
const result = await chromeDebuggerSendCommand(
|
|
310
|
+
{ tabId },
|
|
311
|
+
"Runtime.evaluate",
|
|
312
|
+
{ expression, returnByValue: true, awaitPromise }
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if (result?.exceptionDetails) {
|
|
316
|
+
const text = result.exceptionDetails.text || result.exceptionDetails.exception?.description || "Script error"
|
|
317
|
+
throw new Error(text)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const value = result?.result?.value
|
|
321
|
+
const type = result?.result?.type || typeof value
|
|
322
|
+
const serialized = serializeResult(value, maxLength)
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
value: serialized,
|
|
326
|
+
type,
|
|
327
|
+
truncated: typeof value === "string" && value.length > maxLength,
|
|
328
|
+
browserSessionId: session.browserSessionId,
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function serializeResult(value, maxLength) {
|
|
333
|
+
if (value === undefined || value === null) return value
|
|
334
|
+
if (typeof value === "string") {
|
|
335
|
+
return value.length > maxLength ? value.slice(0, maxLength) + "..." : value
|
|
336
|
+
}
|
|
337
|
+
if (typeof value === "number" || typeof value === "boolean") return value
|
|
338
|
+
try {
|
|
339
|
+
const json = JSON.stringify(value)
|
|
340
|
+
return json.length > maxLength ? json.slice(0, maxLength) + "..." : json
|
|
341
|
+
} catch {
|
|
342
|
+
return String(value).slice(0, maxLength)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Snapshot state — maps uid to element info for click/fill/hover
|
|
347
|
+
const snapshotStore = new Map()
|
|
348
|
+
|
|
349
|
+
async function takeSnapshot(tabId, session, args = {}) {
|
|
350
|
+
const maxElements = Number.isFinite(Number(args.maxElements))
|
|
351
|
+
? Math.max(1, Math.min(500, Number(args.maxElements)))
|
|
352
|
+
: 200
|
|
353
|
+
const includeHidden = Boolean(args.includeHidden)
|
|
354
|
+
|
|
355
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
356
|
+
|
|
357
|
+
let elements = []
|
|
358
|
+
try {
|
|
359
|
+
const axTree = await chromeDebuggerSendCommand(
|
|
360
|
+
{ tabId },
|
|
361
|
+
"Accessibility.getFullAXTree",
|
|
362
|
+
{}
|
|
363
|
+
)
|
|
364
|
+
elements = flattenAXTree(axTree?.nodes || [], maxElements, includeHidden)
|
|
365
|
+
} catch {
|
|
366
|
+
// Accessibility tree unavailable, fall back to DOM
|
|
367
|
+
elements = await domFallbackSnapshot(tabId, maxElements)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Build uid mapping
|
|
371
|
+
const uidMap = {}
|
|
372
|
+
for (const el of elements) {
|
|
373
|
+
uidMap[el.uid] = { ...el }
|
|
374
|
+
}
|
|
375
|
+
snapshotStore.set(session.browserSessionId, uidMap)
|
|
376
|
+
|
|
377
|
+
return {
|
|
378
|
+
browserSessionId: session.browserSessionId,
|
|
379
|
+
url: session.url || "",
|
|
380
|
+
title: session.title || "",
|
|
381
|
+
elements,
|
|
382
|
+
elementCount: elements.length,
|
|
383
|
+
snapshotId: `snap-${Date.now()}`,
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function flattenAXTree(nodes, maxElements, includeHidden) {
|
|
388
|
+
const elements = []
|
|
389
|
+
let uidCounter = 0
|
|
390
|
+
|
|
391
|
+
function walk(nodeList) {
|
|
392
|
+
for (const node of nodeList) {
|
|
393
|
+
if (elements.length >= maxElements) return
|
|
394
|
+
const el = axNodeToElement(node, ++uidCounter, includeHidden)
|
|
395
|
+
if (el) elements.push(el)
|
|
396
|
+
if (node.children) walk(node.children)
|
|
397
|
+
if (node.childIds) {
|
|
398
|
+
// childIds reference other nodes in the tree by backendDOMNodeId
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
walk(Array.isArray(nodes) ? nodes : [])
|
|
404
|
+
return elements
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function axNodeToElement(node, uid, includeHidden) {
|
|
408
|
+
const role = String(node.role?.value || "").toLowerCase()
|
|
409
|
+
if (!role) return null
|
|
410
|
+
|
|
411
|
+
const name = String(node.name?.value || "").trim()
|
|
412
|
+
const description = String(node.description?.value || "").trim()
|
|
413
|
+
|
|
414
|
+
// Skip non-interactive and hidden elements
|
|
415
|
+
const isInteractive = /button|link|textbox|combobox|listbox|checkbox|radio|menuitem|tab|switch|slider|spinbutton|option|treeitem|gridcell|rowheader|columnheader/i.test(role)
|
|
416
|
+
const isStructural = /heading|list|listitem|table|row|cell|group|region|banner|main|navigation|article|section|status|alert/i.test(role)
|
|
417
|
+
const hasName = name.length > 0
|
|
418
|
+
|
|
419
|
+
if (!isInteractive && !isStructural && !hasName) return null
|
|
420
|
+
if (!includeHidden) {
|
|
421
|
+
const hidden = node.properties?.find(p => p.name === "hidden")?.value?.value
|
|
422
|
+
if (hidden === true) return null
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
uid: `yunti-${uid}`,
|
|
427
|
+
role,
|
|
428
|
+
name,
|
|
429
|
+
description: description || undefined,
|
|
430
|
+
backendNodeId: node.backendDOMNodeId,
|
|
431
|
+
nodeId: node.nodeId,
|
|
432
|
+
childCount: (node.children?.length || 0),
|
|
433
|
+
properties: summarizeAXProperties(node.properties),
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function summarizeAXProperties(properties) {
|
|
438
|
+
if (!Array.isArray(properties)) return undefined
|
|
439
|
+
const out = {}
|
|
440
|
+
for (const prop of properties) {
|
|
441
|
+
if (prop.name === "hidden" || prop.name === "focusable" || prop.name === "focused" ||
|
|
442
|
+
prop.name === "disabled" || prop.name === "checked" || prop.name === "selected" ||
|
|
443
|
+
prop.name === "expanded" || prop.name === "haspopup" || prop.name === "required" ||
|
|
444
|
+
prop.name === "invalid" || prop.name === "level" || prop.name === "valuemin" ||
|
|
445
|
+
prop.name === "valuemax" || prop.name === "valuenow" || prop.name === "valuetext" ||
|
|
446
|
+
prop.name === "multiselectable" || prop.name === "readonly") {
|
|
447
|
+
out[prop.name] = prop.value?.value ?? prop.value ?? true
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return Object.keys(out).length ? out : undefined
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function domFallbackSnapshot(tabId, maxElements) {
|
|
454
|
+
const result = await chromeDebuggerSendCommand(
|
|
455
|
+
{ tabId },
|
|
456
|
+
"Runtime.evaluate",
|
|
457
|
+
{
|
|
458
|
+
expression: `(() => {
|
|
459
|
+
const elements = [];
|
|
460
|
+
const interactive = 'a,button,input,select,textarea,[role="button"],[role="link"],[role="textbox"],[role="combobox"],[role="checkbox"],[role="radio"],[onclick]';
|
|
461
|
+
const all = document.querySelectorAll(interactive);
|
|
462
|
+
for (let i = 0; i < Math.min(all.length, ${maxElements}); i++) {
|
|
463
|
+
const el = all[i];
|
|
464
|
+
const rect = el.getBoundingClientRect();
|
|
465
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
466
|
+
if (rect.bottom < 0 || rect.top > window.innerHeight) continue;
|
|
467
|
+
elements.push({
|
|
468
|
+
tag: el.tagName.toLowerCase(),
|
|
469
|
+
id: el.id || undefined,
|
|
470
|
+
name: (el.getAttribute('name') || '').slice(0, 100) || undefined,
|
|
471
|
+
text: (el.textContent || '').trim().slice(0, 200) || undefined,
|
|
472
|
+
type: el.type || undefined,
|
|
473
|
+
placeholder: (el.placeholder || '').slice(0, 100) || undefined,
|
|
474
|
+
href: el.href || undefined,
|
|
475
|
+
role: el.getAttribute('role') || undefined,
|
|
476
|
+
ariaLabel: (el.getAttribute('aria-label') || '').slice(0, 200) || undefined,
|
|
477
|
+
rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
|
478
|
+
disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return elements;
|
|
482
|
+
})()`,
|
|
483
|
+
returnByValue: true,
|
|
484
|
+
}
|
|
485
|
+
)
|
|
486
|
+
const rawElements = result?.result?.value || []
|
|
487
|
+
return rawElements.map((el, i) => ({
|
|
488
|
+
uid: `yunti-${i + 1}`,
|
|
489
|
+
role: el.role || el.tag || "element",
|
|
490
|
+
name: el.ariaLabel || el.text || el.name || el.placeholder || el.id || "",
|
|
491
|
+
tag: el.tag,
|
|
492
|
+
id: el.id,
|
|
493
|
+
type: el.type,
|
|
494
|
+
placeholder: el.placeholder,
|
|
495
|
+
href: el.href,
|
|
496
|
+
rect: el.rect,
|
|
497
|
+
disabled: el.disabled,
|
|
498
|
+
}))
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function clickByUid(tabId, session, args = {}) {
|
|
502
|
+
const uid = String(args.uid || "").trim()
|
|
503
|
+
if (uid) {
|
|
504
|
+
return clickViaSnapshotUid(tabId, session, uid)
|
|
505
|
+
}
|
|
506
|
+
const x = Number(args.x)
|
|
507
|
+
const y = Number(args.y)
|
|
508
|
+
if (Number.isFinite(x) || Number.isFinite(y)) {
|
|
509
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
510
|
+
throw new Error("yunti_click coordinate mode requires both x and y. Use yunti_click_at with x/y, or call yunti_take_snapshot and pass uid.")
|
|
511
|
+
}
|
|
512
|
+
return clickAtCoordinate(tabId, session, x, y)
|
|
513
|
+
}
|
|
514
|
+
if (!String(args.selector || "").trim()) {
|
|
515
|
+
throw new Error("yunti_click requires uid, selector, or both x and y. Call yunti_take_snapshot to get uid, pass a CSS selector, or use yunti_click_at for coordinate-only clicks.")
|
|
516
|
+
}
|
|
517
|
+
// Fall back to content script for selector-based click
|
|
518
|
+
return chrome.tabs.sendMessage(tabId, {
|
|
519
|
+
type: "yunti_execute_tool",
|
|
520
|
+
tool: "yunti_click",
|
|
521
|
+
arguments: args,
|
|
522
|
+
})
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function hoverByUid(tabId, session, args = {}) {
|
|
526
|
+
const uid = String(args.uid || "").trim()
|
|
527
|
+
if (uid) {
|
|
528
|
+
return hoverViaSnapshotUid(tabId, session, uid)
|
|
529
|
+
}
|
|
530
|
+
const x = Number(args.x)
|
|
531
|
+
const y = Number(args.y)
|
|
532
|
+
if (Number.isFinite(x) || Number.isFinite(y)) {
|
|
533
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
534
|
+
throw new Error("yunti_hover coordinate mode requires both x and y. Call yunti_take_snapshot to get uid, pass selector, or provide both coordinates.")
|
|
535
|
+
}
|
|
536
|
+
await mouseMove(tabId, x, y)
|
|
537
|
+
return { hovered: true, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId, method: "coordinate" }
|
|
538
|
+
}
|
|
539
|
+
const selector = String(args.selector || "").trim()
|
|
540
|
+
if (!selector) {
|
|
541
|
+
throw new Error("yunti_hover requires uid, selector, or both x and y. Call yunti_take_snapshot to get uid or pass a CSS selector.")
|
|
542
|
+
}
|
|
543
|
+
const point = await resolveSelectorCenter(tabId, selector)
|
|
544
|
+
await mouseMove(tabId, point.x, point.y)
|
|
545
|
+
return {
|
|
546
|
+
hovered: true,
|
|
547
|
+
selector,
|
|
548
|
+
x: Math.round(point.x),
|
|
549
|
+
y: Math.round(point.y),
|
|
550
|
+
browserSessionId: session.browserSessionId,
|
|
551
|
+
method: "selector",
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function fillByUid(tabId, session, args = {}) {
|
|
556
|
+
validateFillArgs(args)
|
|
557
|
+
const uid = String(args.uid || "").trim()
|
|
558
|
+
const value = String(args.value)
|
|
559
|
+
if (uid) {
|
|
560
|
+
const { x, y } = await resolveUidCenter(tabId, session, uid)
|
|
561
|
+
await mouseClick(tabId, x, y, 1)
|
|
562
|
+
await delayCdp(100)
|
|
563
|
+
// Clear existing value
|
|
564
|
+
const elements = await chromeDebuggerSendCommand(
|
|
565
|
+
{ tabId },
|
|
566
|
+
"Runtime.evaluate",
|
|
567
|
+
{ expression: `(() => {
|
|
568
|
+
const el = document.elementFromPoint(${x}, ${y});
|
|
569
|
+
if (!el) return 'not found';
|
|
570
|
+
const tag = el.tagName.toLowerCase();
|
|
571
|
+
if (tag === 'select') {
|
|
572
|
+
const opts = Array.from(el.options);
|
|
573
|
+
return { tag: 'select', options: opts.map(o => ({ value: o.value, text: o.text.slice(0, 80) })), selectedIndex: el.selectedIndex };
|
|
574
|
+
}
|
|
575
|
+
if (el.isContentEditable) {
|
|
576
|
+
el.textContent = '';
|
|
577
|
+
} else if (tag === 'input' || tag === 'textarea') {
|
|
578
|
+
el.focus();
|
|
579
|
+
el.select();
|
|
580
|
+
}
|
|
581
|
+
return { tag, type: el.type, contentEditable: el.isContentEditable };
|
|
582
|
+
})()`,
|
|
583
|
+
returnByValue: true,
|
|
584
|
+
}
|
|
585
|
+
)
|
|
586
|
+
const elInfo = elements?.result?.value
|
|
587
|
+
|
|
588
|
+
if (elInfo === "not found") throw new Error(`No element found at uid ${uid} coordinates`)
|
|
589
|
+
|
|
590
|
+
// Handle select element
|
|
591
|
+
if (elInfo?.tag === "select") {
|
|
592
|
+
const targetOption = elInfo.options?.find(
|
|
593
|
+
o => o.value === value || o.text === value
|
|
594
|
+
)
|
|
595
|
+
if (targetOption) {
|
|
596
|
+
await chromeDebuggerSendCommand(
|
|
597
|
+
{ tabId },
|
|
598
|
+
"Runtime.evaluate",
|
|
599
|
+
{
|
|
600
|
+
expression: `(() => {
|
|
601
|
+
const el = document.elementFromPoint(${x}, ${y});
|
|
602
|
+
if (el) el.value = ${JSON.stringify(targetOption.value)};
|
|
603
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
604
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
605
|
+
})()`,
|
|
606
|
+
}
|
|
607
|
+
)
|
|
608
|
+
return { filled: true, uid, method: "select", value: targetOption.value, browserSessionId: session.browserSessionId }
|
|
609
|
+
}
|
|
610
|
+
throw new Error(`Option '${value}' not found in select at uid ${uid}. Use yunti_take_snapshot to inspect the select element or pass an available option value/text.`)
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// Type text via CDP Input.dispatchKeyEvent
|
|
614
|
+
const text = value
|
|
615
|
+
for (const char of text) {
|
|
616
|
+
await chromeDebuggerSendCommand(
|
|
617
|
+
{ tabId },
|
|
618
|
+
"Input.dispatchKeyEvent",
|
|
619
|
+
{ type: "char", text: char, unmodifiedText: char }
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
return { filled: true, uid, method: "keyboard", value: text, browserSessionId: session.browserSessionId }
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return chrome.tabs.sendMessage(tabId, {
|
|
626
|
+
type: "yunti_execute_tool",
|
|
627
|
+
tool: "yunti_fill",
|
|
628
|
+
arguments: args,
|
|
629
|
+
})
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function validateFillArgs(args = {}) {
|
|
633
|
+
if (!Object.prototype.hasOwnProperty.call(args, "value") || args.value === undefined || args.value === null) {
|
|
634
|
+
throw new Error("yunti_fill requires value. Pass { value: \"...\", uid: \"yunti-...\" } after yunti_take_snapshot, or pass value with a CSS selector.")
|
|
635
|
+
}
|
|
636
|
+
const uid = String(args.uid || "").trim()
|
|
637
|
+
const selector = String(args.selector || "").trim()
|
|
638
|
+
const hasX = Number.isFinite(Number(args.x))
|
|
639
|
+
const hasY = Number.isFinite(Number(args.y))
|
|
640
|
+
if (hasX || hasY) {
|
|
641
|
+
throw new Error("yunti_fill does not support coordinate-only targeting. Call yunti_take_snapshot and pass uid, or pass selector with value.")
|
|
642
|
+
}
|
|
643
|
+
if (!uid && !selector) {
|
|
644
|
+
throw new Error("yunti_fill requires uid or selector with value. Call yunti_take_snapshot to get uid, or pass a CSS selector.")
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function clickViaSnapshotUid(tabId, session, uid) {
|
|
649
|
+
const { x, y } = await resolveUidCenter(tabId, session, uid)
|
|
650
|
+
await mouseClick(tabId, x, y, 1)
|
|
651
|
+
return { clicked: true, uid, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId }
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async function hoverViaSnapshotUid(tabId, session, uid) {
|
|
655
|
+
const { x, y } = await resolveUidCenter(tabId, session, uid)
|
|
656
|
+
await mouseMove(tabId, x, y)
|
|
657
|
+
return { hovered: true, uid, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function clickAtCoordinate(tabId, session, x, y) {
|
|
661
|
+
await mouseClick(tabId, x, y, 1)
|
|
662
|
+
return { clicked: true, x: Math.round(x), y: Math.round(y), browserSessionId: session.browserSessionId, method: "coordinate" }
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function resolveSelectorCenter(tabId, selector) {
|
|
666
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
667
|
+
const escapedSelector = JSON.stringify(selector)
|
|
668
|
+
const result = await chromeDebuggerSendCommand(
|
|
669
|
+
{ tabId },
|
|
670
|
+
"Runtime.evaluate",
|
|
671
|
+
{
|
|
672
|
+
expression: `(() => {
|
|
673
|
+
const element = document.querySelector(${escapedSelector});
|
|
674
|
+
if (!element) return { ok: false, error: "Element not found: " + ${escapedSelector} };
|
|
675
|
+
const rect = element.getBoundingClientRect();
|
|
676
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) {
|
|
677
|
+
return { ok: false, error: "Element is hidden or has no size: " + ${escapedSelector} };
|
|
678
|
+
}
|
|
679
|
+
element.scrollIntoView({ block: "center", inline: "center" });
|
|
680
|
+
const next = element.getBoundingClientRect();
|
|
681
|
+
return {
|
|
682
|
+
ok: true,
|
|
683
|
+
x: next.left + next.width / 2,
|
|
684
|
+
y: next.top + next.height / 2,
|
|
685
|
+
};
|
|
686
|
+
})()`,
|
|
687
|
+
returnByValue: true,
|
|
688
|
+
}
|
|
689
|
+
)
|
|
690
|
+
const value = result?.result?.value
|
|
691
|
+
if (!value?.ok) {
|
|
692
|
+
throw new Error(value?.error || `Element not found: ${selector}`)
|
|
693
|
+
}
|
|
694
|
+
return { x: Number(value.x), y: Number(value.y) }
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function resolveUidCenter(tabId, session, uid) {
|
|
698
|
+
const uidMap = snapshotStore.get(session.browserSessionId)
|
|
699
|
+
if (!uidMap || !uidMap[uid]) {
|
|
700
|
+
throw new Error(`uid ${uid} not found. Run yunti_take_snapshot first.`)
|
|
701
|
+
}
|
|
702
|
+
const el = uidMap[uid]
|
|
703
|
+
|
|
704
|
+
if (el.rect && el.rect.width > 0 && el.rect.height > 0) {
|
|
705
|
+
return {
|
|
706
|
+
x: el.rect.x + el.rect.width / 2,
|
|
707
|
+
y: el.rect.y + el.rect.height / 2,
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Resolve coordinates via CDP DOM.getBoxModel using backendNodeId
|
|
712
|
+
if (el.backendNodeId) {
|
|
713
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
714
|
+
const boxModel = await chromeDebuggerSendCommand(
|
|
715
|
+
{ tabId },
|
|
716
|
+
"DOM.getBoxModel",
|
|
717
|
+
{ backendNodeId: el.backendNodeId }
|
|
718
|
+
)
|
|
719
|
+
const content = boxModel?.model?.content
|
|
720
|
+
if (content && content.length >= 4) {
|
|
721
|
+
const x = (content[0] + content[4]) / 2
|
|
722
|
+
const y = (content[1] + content[5]) / 2
|
|
723
|
+
return { x, y }
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
throw new Error(`Cannot resolve coordinates for uid ${uid}. Element may be off-screen or hidden.`)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
async function mouseClick(tabId, x, y, clickCount = 1) {
|
|
731
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
732
|
+
const button = "left"
|
|
733
|
+
await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
|
|
734
|
+
type: "mousePressed", x, y, button, clickCount,
|
|
735
|
+
})
|
|
736
|
+
await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
|
|
737
|
+
type: "mouseReleased", x, y, button, clickCount,
|
|
738
|
+
})
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function mouseMove(tabId, x, y) {
|
|
742
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
743
|
+
await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
|
|
744
|
+
type: "mouseMoved", x, y,
|
|
745
|
+
})
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
async function fillForm(tabId, session, args = {}) {
|
|
749
|
+
const fields = Array.isArray(args.fields) ? args.fields : []
|
|
750
|
+
if (!fields.length) throw new Error("fields array is required")
|
|
751
|
+
|
|
752
|
+
let filled = 0
|
|
753
|
+
let failed = 0
|
|
754
|
+
const results = []
|
|
755
|
+
|
|
756
|
+
for (const field of fields) {
|
|
757
|
+
try {
|
|
758
|
+
const uid = String(field.uid || "").trim()
|
|
759
|
+
if (uid) {
|
|
760
|
+
await fillByUid(tabId, session, { uid, value: String(field.value || "") })
|
|
761
|
+
} else {
|
|
762
|
+
await chrome.tabs.sendMessage(tabId, {
|
|
763
|
+
type: "yunti_execute_tool",
|
|
764
|
+
tool: "yunti_fill",
|
|
765
|
+
arguments: { selector: field.selector, value: field.value },
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
filled++
|
|
769
|
+
results.push({ uid: field.uid, selector: field.selector, ok: true })
|
|
770
|
+
} catch (err) {
|
|
771
|
+
failed++
|
|
772
|
+
results.push({ uid: field.uid, selector: field.selector, ok: false, error: err?.message || String(err) })
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return { filled, failed, results, browserSessionId: session.browserSessionId }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function waitForCondition(tabId, session, args = {}) {
|
|
780
|
+
const timeoutMs = Number.isFinite(Number(args.timeoutMs))
|
|
781
|
+
? Math.max(100, Math.min(30000, Number(args.timeoutMs)))
|
|
782
|
+
: 5000
|
|
783
|
+
const text = String(args.text || "").trim()
|
|
784
|
+
const selector = String(args.selector || "").trim()
|
|
785
|
+
const urlContains = String(args.urlContains || "").trim()
|
|
786
|
+
|
|
787
|
+
if (!text && !selector && !urlContains) {
|
|
788
|
+
throw new Error("At least one of text, selector, or urlContains is required")
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
792
|
+
const startTime = Date.now()
|
|
793
|
+
|
|
794
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
795
|
+
if (urlContains) {
|
|
796
|
+
const tab = await chrome.tabs.get(tabId)
|
|
797
|
+
if (tab.url && tab.url.includes(urlContains)) {
|
|
798
|
+
return { found: true, condition: "urlContains", value: urlContains, waitedMs: Date.now() - startTime, browserSessionId: session.browserSessionId }
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (text || selector) {
|
|
803
|
+
const expression = `(() => {
|
|
804
|
+
${selector ? `const el = document.querySelector(${JSON.stringify(selector)}); if (el) { const rect = el.getBoundingClientRect(); if (rect.width > 0 && rect.height > 0) return { found: 'selector', selector: ${JSON.stringify(selector)} }; }` : ""}
|
|
805
|
+
${text ? `if (document.body && document.body.innerText && document.body.innerText.includes(${JSON.stringify(text)})) return { found: 'text', text: ${JSON.stringify(text)} };` : ""}
|
|
806
|
+
return null;
|
|
807
|
+
})()`
|
|
808
|
+
|
|
809
|
+
const result = await chromeDebuggerSendCommand(
|
|
810
|
+
{ tabId },
|
|
811
|
+
"Runtime.evaluate",
|
|
812
|
+
{ expression, returnByValue: true }
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
if (result?.result?.value) {
|
|
816
|
+
return { found: true, ...result.result.value, waitedMs: Date.now() - startTime, browserSessionId: session.browserSessionId }
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
await delayCdp(200)
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
return { found: false, waitedMs: timeoutMs, browserSessionId: session.browserSessionId }
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function handleDialog(tabId, session, args = {}) {
|
|
827
|
+
const action = String(args.action || "accept").trim()
|
|
828
|
+
if (action !== "accept" && action !== "dismiss") throw new Error("action must be 'accept' or 'dismiss'")
|
|
829
|
+
const promptText = action === "accept" ? String(args.promptText || "") : undefined
|
|
830
|
+
|
|
831
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
832
|
+
await chromeDebuggerSendCommand(
|
|
833
|
+
{ tabId },
|
|
834
|
+
"Page.handleJavaScriptDialog",
|
|
835
|
+
{ accept: action === "accept", promptText }
|
|
836
|
+
)
|
|
837
|
+
return { handled: true, action, browserSessionId: session.browserSessionId }
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function resizePage(tabId, session, args = {}) {
|
|
841
|
+
const width = Number.isFinite(Number(args.width)) ? Math.round(Number(args.width)) : null
|
|
842
|
+
const height = Number.isFinite(Number(args.height)) ? Math.round(Number(args.height)) : null
|
|
843
|
+
const deviceScaleFactor = Number.isFinite(Number(args.deviceScaleFactor)) && Number(args.deviceScaleFactor) > 0
|
|
844
|
+
? Number(args.deviceScaleFactor) : 1
|
|
845
|
+
|
|
846
|
+
if (!width && !height) throw new Error("width and/or height is required")
|
|
847
|
+
|
|
848
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
849
|
+
const params = { deviceScaleFactor, mobile: false }
|
|
850
|
+
if (width) params.width = width
|
|
851
|
+
if (height) params.height = height
|
|
852
|
+
await chromeDebuggerSendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", params)
|
|
853
|
+
return { resized: true, width, height, deviceScaleFactor, browserSessionId: session.browserSessionId }
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
async function emulateDevice(tabId, session, args = {}) {
|
|
857
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
858
|
+
|
|
859
|
+
if (args.clear) {
|
|
860
|
+
await chromeDebuggerSendCommand({ tabId }, "Emulation.clearDeviceMetricsOverride", {})
|
|
861
|
+
// Use userAgent: "" to clear UA override — Chrome requires explicit param
|
|
862
|
+
await chromeDebuggerSendCommand({ tabId }, "Emulation.setUserAgentOverride", { userAgent: "" })
|
|
863
|
+
// Use -1 for unlimited throughput (0 = zero bandwidth)
|
|
864
|
+
await chromeDebuggerSendCommand({ tabId }, "Network.emulateNetworkConditions", {
|
|
865
|
+
offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1, connectionType: "none",
|
|
866
|
+
})
|
|
867
|
+
// Also clear CPU throttling
|
|
868
|
+
await chromeDebuggerSendCommand({ tabId }, "Emulation.setCPUThrottlingRate", { rate: 1 })
|
|
869
|
+
return { emulated: false, cleared: true, browserSessionId: session.browserSessionId }
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const deviceOverrides = resolveDeviceOverrides(args.deviceName)
|
|
873
|
+
|
|
874
|
+
// Viewport
|
|
875
|
+
if (args.viewport || deviceOverrides) {
|
|
876
|
+
const vp = args.viewport || deviceOverrides || {}
|
|
877
|
+
const w = Number.isFinite(Number(vp.width)) ? Math.round(Number(vp.width)) : 375
|
|
878
|
+
const h = Number.isFinite(Number(vp.height)) ? Math.round(Number(vp.height)) : 812
|
|
879
|
+
const dsf = Number.isFinite(Number(vp.deviceScaleFactor)) && Number(vp.deviceScaleFactor) > 0
|
|
880
|
+
? Number(vp.deviceScaleFactor) : 2
|
|
881
|
+
await chromeDebuggerSendCommand({ tabId }, "Emulation.setDeviceMetricsOverride", {
|
|
882
|
+
width: w, height: h, deviceScaleFactor: dsf, mobile: true,
|
|
883
|
+
})
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// User agent
|
|
887
|
+
if (args.userAgent || deviceOverrides?.userAgent) {
|
|
888
|
+
await chromeDebuggerSendCommand({ tabId }, "Emulation.setUserAgentOverride", {
|
|
889
|
+
userAgent: args.userAgent || deviceOverrides.userAgent,
|
|
890
|
+
})
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// CPU throttle
|
|
894
|
+
if (Number.isFinite(Number(args.cpuThrottleRate)) && Number(args.cpuThrottleRate) > 1) {
|
|
895
|
+
await chromeDebuggerSendCommand({ tabId }, "Emulation.setCPUThrottlingRate", {
|
|
896
|
+
rate: Number(args.cpuThrottleRate),
|
|
897
|
+
})
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// Network conditions
|
|
901
|
+
if (args.networkConditions && args.networkConditions !== "none") {
|
|
902
|
+
const conditions = NETWORK_CONDITIONS[args.networkConditions] || NETWORK_CONDITIONS.slow3g
|
|
903
|
+
await chromeDebuggerSendCommand({ tabId }, "Network.emulateNetworkConditions", {
|
|
904
|
+
offline: false, ...conditions,
|
|
905
|
+
})
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
return { emulated: true, deviceName: args.deviceName || null, browserSessionId: session.browserSessionId }
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const DEVICE_PRESETS = {
|
|
912
|
+
"iPhone 12": { width: 390, height: 844, deviceScaleFactor: 3, mobile: true, userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15" },
|
|
913
|
+
"iPhone SE": { width: 375, height: 667, deviceScaleFactor: 2, mobile: true },
|
|
914
|
+
"Pixel 5": { width: 393, height: 851, deviceScaleFactor: 2.75, mobile: true, userAgent: "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36" },
|
|
915
|
+
"iPad": { width: 810, height: 1080, deviceScaleFactor: 2, mobile: true },
|
|
916
|
+
"iPad Pro": { width: 1024, height: 1366, deviceScaleFactor: 2, mobile: true },
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const NETWORK_CONDITIONS = {
|
|
920
|
+
offline: { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1, connectionType: "none" },
|
|
921
|
+
slow3g: { offline: false, latency: 2000, downloadThroughput: 50000, uploadThroughput: 20000, connectionType: "cellular3g" },
|
|
922
|
+
fast3g: { offline: false, latency: 563, downloadThroughput: 180000, uploadThroughput: 45000, connectionType: "cellular3g" },
|
|
923
|
+
slow4g: { offline: false, latency: 100, downloadThroughput: 1750000, uploadThroughput: 500000, connectionType: "cellular4g" },
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function resolveDeviceOverrides(deviceName) {
|
|
927
|
+
if (!deviceName) return null
|
|
928
|
+
const key = String(deviceName).trim()
|
|
929
|
+
return DEVICE_PRESETS[key] || DEVICE_PRESETS[Object.keys(DEVICE_PRESETS).find(k => k.toLowerCase() === key.toLowerCase())] || null
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function newPage(parentSession = null, args = {}) {
|
|
933
|
+
const url = String(args.url || "").trim()
|
|
934
|
+
const active = args.active !== false
|
|
935
|
+
const tab = await chrome.tabs.create({ url: url || undefined, active })
|
|
936
|
+
const browserSessionId = `yunti-${tab.id}-${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`
|
|
937
|
+
const session = {
|
|
938
|
+
browserSessionId,
|
|
939
|
+
userId: parentSession?.userId || "",
|
|
940
|
+
displayName: parentSession?.displayName || "",
|
|
941
|
+
tabId: tab.id,
|
|
942
|
+
windowId: tab.windowId,
|
|
943
|
+
url: tab.url || url,
|
|
944
|
+
title: tab.title || "",
|
|
945
|
+
registeredAt: new Date().toISOString(),
|
|
946
|
+
}
|
|
947
|
+
sessionsByTab.set(tab.id, session)
|
|
948
|
+
await postBridge("/sessions/register", session).catch(() => null)
|
|
949
|
+
startPolling(tab.id)
|
|
950
|
+
return { created: true, browserSessionId, tabId: tab.id, windowId: tab.windowId, url: tab.url || url, title: tab.title || "" }
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
async function closePage(tabId, session, args = {}) {
|
|
954
|
+
if (args.tabId !== undefined || args.targetId !== undefined) {
|
|
955
|
+
throw new Error("yunti_close_page only closes the registered page identified by browserSessionId. To close by tabId or targetId, call yunti_cdp_send_command with method Target.closeTarget and top-level tabId or targetId.")
|
|
956
|
+
}
|
|
957
|
+
const connectedTabs = [...sessionsByTab.keys()]
|
|
958
|
+
if (connectedTabs.length <= 1) {
|
|
959
|
+
throw new Error("Cannot close the last connected tab")
|
|
960
|
+
}
|
|
961
|
+
await detachCdpTab(tabId, session, "page_closed").catch(() => {})
|
|
962
|
+
await chrome.tabs.remove(tabId)
|
|
963
|
+
sessionsByTab.delete(tabId)
|
|
964
|
+
pollers.get(tabId)?.abort()
|
|
965
|
+
pollers.delete(tabId)
|
|
966
|
+
return { closed: true, browserSessionId: session.browserSessionId, tabId }
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async function performDrag(tabId, session, args = {}) {
|
|
970
|
+
const fromX = Number(args.fromX)
|
|
971
|
+
const fromY = Number(args.fromY)
|
|
972
|
+
const toX = Number(args.toX)
|
|
973
|
+
const toY = Number(args.toY)
|
|
974
|
+
if (!Number.isFinite(fromX) || !Number.isFinite(fromY) || !Number.isFinite(toX) || !Number.isFinite(toY)) {
|
|
975
|
+
throw new Error("fromX, fromY, toX, toY are required and must be numbers")
|
|
976
|
+
}
|
|
977
|
+
const steps = Number.isFinite(Number(args.steps)) ? Math.max(1, Math.min(50, Math.round(Number(args.steps)))) : 10
|
|
978
|
+
|
|
979
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
980
|
+
const button = "left"
|
|
981
|
+
|
|
982
|
+
// Mouse press at start
|
|
983
|
+
await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
|
|
984
|
+
type: "mousePressed", x: fromX, y: fromY, button, clickCount: 1,
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
// Move through intermediate points
|
|
988
|
+
for (let i = 1; i <= steps; i++) {
|
|
989
|
+
const t = i / (steps + 1)
|
|
990
|
+
const x = fromX + (toX - fromX) * t
|
|
991
|
+
const y = fromY + (toY - fromY) * t
|
|
992
|
+
await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
|
|
993
|
+
type: "mouseMoved", x, y, button, buttons: 1,
|
|
994
|
+
})
|
|
995
|
+
await delayCdp(16) // ~60fps
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// Mouse release at end
|
|
999
|
+
await chromeDebuggerSendCommand({ tabId }, "Input.dispatchMouseEvent", {
|
|
1000
|
+
type: "mouseReleased", x: toX, y: toY, button, clickCount: 1,
|
|
1001
|
+
})
|
|
1002
|
+
|
|
1003
|
+
return { dragged: true, from: { x: Math.round(fromX), y: Math.round(fromY) }, to: { x: Math.round(toX), y: Math.round(toY) }, steps, browserSessionId: session.browserSessionId }
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function uploadFile(tabId, session, args = {}) {
|
|
1007
|
+
const filePaths = Array.isArray(args.filePaths) ? args.filePaths.filter(p => typeof p === "string") : []
|
|
1008
|
+
if (!filePaths.length) throw new Error("filePaths array is required")
|
|
1009
|
+
|
|
1010
|
+
const uid = String(args.uid || "").trim()
|
|
1011
|
+
const selector = String(args.selector || "").trim()
|
|
1012
|
+
|
|
1013
|
+
await ensureCdpAttached(tabId, "1.3")
|
|
1014
|
+
|
|
1015
|
+
let backendNodeId = null
|
|
1016
|
+
if (uid) {
|
|
1017
|
+
const { x, y } = await resolveUidCenter(tabId, session, uid)
|
|
1018
|
+
const nodeResult = await chromeDebuggerSendCommand({ tabId }, "DOM.getNodeForLocation", { x: Math.round(x), y: Math.round(y) })
|
|
1019
|
+
backendNodeId = nodeResult?.backendNodeId
|
|
1020
|
+
} else if (selector) {
|
|
1021
|
+
const docResult = await chromeDebuggerSendCommand({ tabId }, "DOM.getDocument", {})
|
|
1022
|
+
const rootNodeId = docResult?.root?.nodeId
|
|
1023
|
+
if (rootNodeId) {
|
|
1024
|
+
const queryResult = await chromeDebuggerSendCommand({ tabId }, "DOM.querySelector", { nodeId: rootNodeId, selector })
|
|
1025
|
+
if (queryResult?.nodeId) {
|
|
1026
|
+
const nodeResult = await chromeDebuggerSendCommand({ tabId }, "DOM.describeNode", { nodeId: queryResult.nodeId })
|
|
1027
|
+
backendNodeId = nodeResult?.node?.backendNodeId
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
if (!backendNodeId) throw new Error("Cannot resolve file input element. Provide a valid uid from yunti_take_snapshot or a CSS selector.")
|
|
1033
|
+
|
|
1034
|
+
await chromeDebuggerSendCommand({ tabId }, "DOM.setFileInputFiles", {
|
|
1035
|
+
files: filePaths,
|
|
1036
|
+
backendNodeId,
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
return { uploaded: true, fileCount: filePaths.length, filePaths, browserSessionId: session.browserSessionId }
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function typeTextByUid(tabId, session, args = {}) {
|
|
1043
|
+
const uid = String(args.uid || "").trim()
|
|
1044
|
+
if (!uid) {
|
|
1045
|
+
// Fall back to content script for selector/coordinate-based typing
|
|
1046
|
+
return chrome.tabs.sendMessage(tabId, {
|
|
1047
|
+
type: "yunti_execute_tool",
|
|
1048
|
+
tool: "yunti_type_text",
|
|
1049
|
+
arguments: args,
|
|
1050
|
+
})
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const text = String(args.text || "")
|
|
1054
|
+
if (!text) throw new Error("text is required")
|
|
1055
|
+
|
|
1056
|
+
// Click the uid element to focus it
|
|
1057
|
+
const { x, y } = await resolveUidCenter(tabId, session, uid)
|
|
1058
|
+
await mouseClick(tabId, x, y, 1)
|
|
1059
|
+
await delayCdp(100)
|
|
1060
|
+
|
|
1061
|
+
// Type each character via CDP
|
|
1062
|
+
for (const char of text) {
|
|
1063
|
+
await chromeDebuggerSendCommand(
|
|
1064
|
+
{ tabId },
|
|
1065
|
+
"Input.dispatchKeyEvent",
|
|
1066
|
+
{ type: "char", text: char, unmodifiedText: char }
|
|
1067
|
+
)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
return { typed: true, uid, text, method: "cdp.keyboard", browserSessionId: session.browserSessionId }
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async function pressKeyByUid(tabId, session, args = {}) {
|
|
1074
|
+
const uid = String(args.uid || "").trim()
|
|
1075
|
+
if (!uid) {
|
|
1076
|
+
return chrome.tabs.sendMessage(tabId, {
|
|
1077
|
+
type: "yunti_execute_tool",
|
|
1078
|
+
tool: "yunti_press_key",
|
|
1079
|
+
arguments: args,
|
|
1080
|
+
})
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const key = String(args.key || "").trim()
|
|
1084
|
+
if (!key) throw new Error("key is required")
|
|
1085
|
+
|
|
1086
|
+
// Focus the uid element
|
|
1087
|
+
const { x, y } = await resolveUidCenter(tabId, session, uid)
|
|
1088
|
+
await mouseClick(tabId, x, y, 1)
|
|
1089
|
+
await delayCdp(50)
|
|
1090
|
+
|
|
1091
|
+
// Map common key names to CDP key events
|
|
1092
|
+
const keyDef = normalizeKey(key)
|
|
1093
|
+
|
|
1094
|
+
await chromeDebuggerSendCommand({ tabId }, "Input.dispatchKeyEvent", {
|
|
1095
|
+
type: "keyDown",
|
|
1096
|
+
key: keyDef.key,
|
|
1097
|
+
code: keyDef.code,
|
|
1098
|
+
windowsVirtualKeyCode: keyDef.keyCode,
|
|
1099
|
+
nativeVirtualKeyCode: keyDef.keyCode,
|
|
1100
|
+
})
|
|
1101
|
+
await chromeDebuggerSendCommand({ tabId }, "Input.dispatchKeyEvent", {
|
|
1102
|
+
type: "keyUp",
|
|
1103
|
+
key: keyDef.key,
|
|
1104
|
+
code: keyDef.code,
|
|
1105
|
+
windowsVirtualKeyCode: keyDef.keyCode,
|
|
1106
|
+
nativeVirtualKeyCode: keyDef.keyCode,
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
return { pressed: true, uid, key, browserSessionId: session.browserSessionId }
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
const KEY_MAP = {
|
|
1113
|
+
Enter: { key: "Enter", code: "Enter", keyCode: 13 },
|
|
1114
|
+
Escape: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
1115
|
+
Tab: { key: "Tab", code: "Tab", keyCode: 9 },
|
|
1116
|
+
Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
|
|
1117
|
+
Delete: { key: "Delete", code: "Delete", keyCode: 46 },
|
|
1118
|
+
ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
|
|
1119
|
+
ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
|
|
1120
|
+
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
|
|
1121
|
+
ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
|
|
1122
|
+
Home: { key: "Home", code: "Home", keyCode: 36 },
|
|
1123
|
+
End: { key: "End", code: "End", keyCode: 35 },
|
|
1124
|
+
PageUp: { key: "PageUp", code: "PageUp", keyCode: 33 },
|
|
1125
|
+
PageDown: { key: "PageDown", code: "PageDown", keyCode: 34 },
|
|
1126
|
+
Space: { key: " ", code: "Space", keyCode: 32 },
|
|
1127
|
+
F1: { key: "F1", code: "F1", keyCode: 112 },
|
|
1128
|
+
F2: { key: "F2", code: "F2", keyCode: 113 },
|
|
1129
|
+
F3: { key: "F3", code: "F3", keyCode: 114 },
|
|
1130
|
+
F4: { key: "F4", code: "F4", keyCode: 115 },
|
|
1131
|
+
F5: { key: "F5", code: "F5", keyCode: 116 },
|
|
1132
|
+
F6: { key: "F6", code: "F6", keyCode: 117 },
|
|
1133
|
+
F7: { key: "F7", code: "F7", keyCode: 118 },
|
|
1134
|
+
F8: { key: "F8", code: "F8", keyCode: 119 },
|
|
1135
|
+
F9: { key: "F9", code: "F9", keyCode: 120 },
|
|
1136
|
+
F10: { key: "F10", code: "F10", keyCode: 121 },
|
|
1137
|
+
F11: { key: "F11", code: "F11", keyCode: 122 },
|
|
1138
|
+
F12: { key: "F12", code: "F12", keyCode: 123 },
|
|
1139
|
+
Control: { key: "Control", code: "ControlLeft", keyCode: 17 },
|
|
1140
|
+
Shift: { key: "Shift", code: "ShiftLeft", keyCode: 16 },
|
|
1141
|
+
Alt: { key: "Alt", code: "AltLeft", keyCode: 18 },
|
|
1142
|
+
Meta: { key: "Meta", code: "MetaLeft", keyCode: 91 },
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function normalizeKey(key) {
|
|
1146
|
+
const upper = key.slice(0, 1).toUpperCase() + key.slice(1).toLowerCase()
|
|
1147
|
+
if (KEY_MAP[upper]) return KEY_MAP[upper]
|
|
1148
|
+
if (KEY_MAP[key]) return KEY_MAP[key]
|
|
1149
|
+
// Fallback for single characters
|
|
1150
|
+
if (key.length === 1) {
|
|
1151
|
+
const code = key.toUpperCase().charCodeAt(0)
|
|
1152
|
+
return { key, code: `Key${key.toUpperCase()}`, keyCode: code }
|
|
1153
|
+
}
|
|
1154
|
+
return { key, code: key, keyCode: 0 }
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
return { executeToolRequest }
|
|
1158
|
+
}
|