dsh-live-browser 0.4.0 → 0.5.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.en.md +38 -14
- package/README.md +35 -16
- package/client.js +413 -124
- package/lib/agent-script.js +723 -723
- package/lib/skills.js +11 -1
- package/lib/tools.js +66 -3
- package/package.json +1 -1
package/lib/agent-script.js
CHANGED
|
@@ -1,723 +1,723 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The script injected into each browser guest page.
|
|
3
|
-
*
|
|
4
|
-
* It is serialized into the guest through `<webview>.executeJavaScript()`, so
|
|
5
|
-
* this file must evaluate standalone inside a page: no imports, no closures over
|
|
6
|
-
* plugin state. It installs one global (`__dshBrowserAgent`) exposing the
|
|
7
|
-
* primitive the panel calls per command.
|
|
8
|
-
*
|
|
9
|
-
* Everything here runs *in the page*, so it uses DOM APIs only. Coordinates are
|
|
10
|
-
* CSS pixels in the page's own viewport, matching what a human sees.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
export const AGENT_GLOBAL = '__dshBrowserAgent'
|
|
14
|
-
|
|
15
|
-
/** Accessibility-ish view of one element, used to give the model stable refs. */
|
|
16
|
-
const SNAPSHOT_HELPERS = /* js */ `
|
|
17
|
-
const INTERACTIVE = new Set([
|
|
18
|
-
'A','BUTTON','INPUT','SELECT','TEXTAREA','SUMMARY','OPTION','LABEL'
|
|
19
|
-
])
|
|
20
|
-
const ROLE_BY_TAG = {
|
|
21
|
-
A: 'link', BUTTON: 'button', INPUT: 'textbox', SELECT: 'combobox',
|
|
22
|
-
TEXTAREA: 'textbox', IMG: 'img', H1: 'heading', H2: 'heading', H3: 'heading',
|
|
23
|
-
H4: 'heading', H5: 'heading', H6: 'heading', UL: 'list', OL: 'list',
|
|
24
|
-
LI: 'listitem', TABLE: 'table', TR: 'row', TD: 'cell', TH: 'columnheader',
|
|
25
|
-
NAV: 'navigation', MAIN: 'main', ASIDE: 'complementary', HEADER: 'banner',
|
|
26
|
-
FOOTER: 'contentinfo', FORM: 'form', DIALOG: 'dialog', P: 'paragraph'
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const roleOf = (element) => {
|
|
30
|
-
const explicit = element.getAttribute && element.getAttribute('role')
|
|
31
|
-
if (explicit) return explicit
|
|
32
|
-
if (element.tagName === 'INPUT') {
|
|
33
|
-
const type = (element.getAttribute('type') || 'text').toLowerCase()
|
|
34
|
-
if (type === 'checkbox') return 'checkbox'
|
|
35
|
-
if (type === 'radio') return 'radio'
|
|
36
|
-
if (type === 'button' || type === 'submit' || type === 'reset') return 'button'
|
|
37
|
-
if (type === 'search') return 'searchbox'
|
|
38
|
-
if (type === 'number') return 'spinbutton'
|
|
39
|
-
if (type === 'range') return 'slider'
|
|
40
|
-
return 'textbox'
|
|
41
|
-
}
|
|
42
|
-
return ROLE_BY_TAG[element.tagName] || element.tagName.toLowerCase()
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const accessibleName = (element) => {
|
|
46
|
-
const aria = element.getAttribute && (element.getAttribute('aria-label') || element.getAttribute('aria-labelledby'))
|
|
47
|
-
if (aria) return aria
|
|
48
|
-
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
|
|
49
|
-
if (element.labels && element.labels.length > 0) return (element.labels[0].innerText || '').trim()
|
|
50
|
-
return (element.getAttribute('placeholder') || element.getAttribute('title') || '').trim()
|
|
51
|
-
}
|
|
52
|
-
if (element.tagName === 'IMG') return (element.getAttribute('alt') || '').trim()
|
|
53
|
-
return (element.innerText || element.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 120)
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Whether an element has any painted box.
|
|
58
|
-
*
|
|
59
|
-
* A zero-height block still reports a non-zero width, so both dimensions must
|
|
60
|
-
* be checked: otherwise an empty wrapper looks visible to a caller that asked
|
|
61
|
-
* to wait until something is actually on screen.
|
|
62
|
-
*/
|
|
63
|
-
const isVisible = (element) => {
|
|
64
|
-
if (!element.isConnected) return false
|
|
65
|
-
const style = getComputedStyle(element)
|
|
66
|
-
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false
|
|
67
|
-
const rect = element.getBoundingClientRect()
|
|
68
|
-
if (rect.width <= 0 || rect.height <= 0) return false
|
|
69
|
-
return true
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const clickable = (element) => element.closest(
|
|
73
|
-
'a,button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="tab"],[onclick]'
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
/** Stable ref per element for the lifetime of one snapshot. */
|
|
77
|
-
const refFor = (element) => {
|
|
78
|
-
if (!element.__dshRef) {
|
|
79
|
-
const next = (window.__dshRefSeq = (window.__dshRefSeq || 0) + 1)
|
|
80
|
-
element.__dshRef = 'e' + next
|
|
81
|
-
;(window.__dshRefs = window.__dshRefs || {})[element.__dshRef] = element
|
|
82
|
-
}
|
|
83
|
-
return element.__dshRef
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Drop the previous snapshot's refs.
|
|
88
|
-
*
|
|
89
|
-
* A ref is documented as valid for one snapshot, and a page holds its own refs
|
|
90
|
-
* alive: without this, every element ever snapshotted stayed reachable from
|
|
91
|
-
* window.__dshRefs, so a long-lived single-page app leaked every node it had
|
|
92
|
-
* ever shown. Clearing on the next snapshot keeps the documented lifetime and
|
|
93
|
-
* bounds the map to one snapshot's worth of nodes.
|
|
94
|
-
*/
|
|
95
|
-
const resetRefs = () => {
|
|
96
|
-
const previous = window.__dshRefs
|
|
97
|
-
if (previous) {
|
|
98
|
-
for (const element of Object.values(previous)) {
|
|
99
|
-
if (element && typeof element === 'object') element.__dshRef = undefined
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
window.__dshRefs = {}
|
|
103
|
-
window.__dshRefSeq = 0
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const elementFor = (ref) => {
|
|
107
|
-
const found = (window.__dshRefs || {})[ref]
|
|
108
|
-
// A ref outlives the node it pointed at: single-page apps re-render constantly,
|
|
109
|
-
// so an old ref may resolve to a detached element. Acting on that would
|
|
110
|
-
// silently succeed against invisible DOM, which is worse than an error.
|
|
111
|
-
if (found === undefined) return undefined
|
|
112
|
-
if (found.isConnected !== true) return undefined
|
|
113
|
-
// Also reject a node that belongs to a previous document instance (a
|
|
114
|
-
// same-origin navigation can leave an element object alive but orphaned).
|
|
115
|
-
if (found.ownerDocument !== document) return undefined
|
|
116
|
-
return found
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const buildSnapshot = (maxNodes) => {
|
|
120
|
-
resetRefs()
|
|
121
|
-
const nodes = []
|
|
122
|
-
const root = document.body || document.documentElement
|
|
123
|
-
if (!root) return nodes
|
|
124
|
-
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
|
|
125
|
-
acceptNode: (element) => {
|
|
126
|
-
const tag = element.tagName
|
|
127
|
-
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') return NodeFilter.FILTER_REJECT
|
|
128
|
-
return NodeFilter.FILTER_ACCEPT
|
|
129
|
-
}
|
|
130
|
-
})
|
|
131
|
-
let element = root
|
|
132
|
-
let scanned = 0
|
|
133
|
-
while (element && nodes.length < maxNodes && scanned < maxNodes * 12) {
|
|
134
|
-
scanned += 1
|
|
135
|
-
if (isVisible(element) && element !== document.body) {
|
|
136
|
-
const role = roleOf(element)
|
|
137
|
-
const text = accessibleName(element)
|
|
138
|
-
const interactive = INTERACTIVE.has(element.tagName) || clickable(element) !== null
|
|
139
|
-
if (interactive || text !== '' || role === 'heading') {
|
|
140
|
-
nodes.push({
|
|
141
|
-
ref: refFor(element),
|
|
142
|
-
role,
|
|
143
|
-
name: text,
|
|
144
|
-
tag: element.tagName.toLowerCase(),
|
|
145
|
-
disabled: element.disabled === true || element.getAttribute('aria-disabled') === 'true',
|
|
146
|
-
...(element.tagName === 'INPUT' || element.tagName === 'TEXTAREA'
|
|
147
|
-
? { value: String(element.value ?? '').slice(0, 200), inputType: (element.getAttribute('type') || 'text').toLowerCase() }
|
|
148
|
-
: {})
|
|
149
|
-
})
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
element = walker.nextNode()
|
|
153
|
-
}
|
|
154
|
-
return nodes
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const centreOf = (element) => {
|
|
158
|
-
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
|
|
159
|
-
const rect = element.getBoundingClientRect()
|
|
160
|
-
return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }
|
|
161
|
-
}
|
|
162
|
-
`
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Build the page-facing source. Kept as a template string so the plugin can
|
|
166
|
-
* inject it with a single executeJavaScript call.
|
|
167
|
-
*/
|
|
168
|
-
export const AGENT_SOURCE = `
|
|
169
|
-
(() => {
|
|
170
|
-
if (window.${AGENT_GLOBAL}) return { installed: true, reused: true }
|
|
171
|
-
${SNAPSHOT_HELPERS}
|
|
172
|
-
|
|
173
|
-
const describe = (element) => ({
|
|
174
|
-
ref: element ? refFor(element) : undefined,
|
|
175
|
-
role: element ? roleOf(element) : undefined,
|
|
176
|
-
name: element ? accessibleName(element) : '',
|
|
177
|
-
tag: element ? element.tagName.toLowerCase() : undefined
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
const resolveTarget = (params) => {
|
|
181
|
-
if (params && params.ref) {
|
|
182
|
-
const byRef = elementFor(params.ref)
|
|
183
|
-
if (!byRef) throw new Error('快照引用已失效:' + params.ref + '(请重新调用 browser_snapshot)')
|
|
184
|
-
return byRef
|
|
185
|
-
}
|
|
186
|
-
if (params && typeof params.selector === 'string' && params.selector !== '') {
|
|
187
|
-
const found = document.querySelector(params.selector)
|
|
188
|
-
if (!found) throw new Error('找不到元素:' + params.selector)
|
|
189
|
-
return found
|
|
190
|
-
}
|
|
191
|
-
if (params && Number.isFinite(params.x) && Number.isFinite(params.y)) {
|
|
192
|
-
const hit = document.elementFromPoint(params.x, params.y)
|
|
193
|
-
if (!hit) throw new Error('该坐标没有元素:' + params.x + ',' + params.y)
|
|
194
|
-
return hit
|
|
195
|
-
}
|
|
196
|
-
return undefined
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const dispatchMouse = (element, type, point) => {
|
|
200
|
-
const target = element || document.elementFromPoint(point.x, point.y) || document.body
|
|
201
|
-
target.dispatchEvent(new MouseEvent(type, {
|
|
202
|
-
bubbles: true, cancelable: true, composed: true, view: window,
|
|
203
|
-
clientX: point.x, clientY: point.y, button: 0, buttons: type === 'mousedown' ? 1 : 0
|
|
204
|
-
}))
|
|
205
|
-
return target
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Dispatch one pointer event.
|
|
210
|
-
*
|
|
211
|
-
* Modern frameworks bind pointerdown / pointerup rather than the mouse
|
|
212
|
-
* equivalents (React 18 pointer events, Vue's @pointerdown, most drag
|
|
213
|
-
* libraries). A click built from mouse events alone silently does nothing on
|
|
214
|
-
* those pages, so both families are sent in the order a real device produces.
|
|
215
|
-
*/
|
|
216
|
-
const dispatchPointer = (element, type, point) => {
|
|
217
|
-
const target = element || document.elementFromPoint(point.x, point.y) || document.body
|
|
218
|
-
const init = {
|
|
219
|
-
bubbles: true, cancelable: true, composed: true, view: window,
|
|
220
|
-
clientX: point.x, clientY: point.y,
|
|
221
|
-
button: type === 'pointermove' ? -1 : 0,
|
|
222
|
-
buttons: type === 'pointerdown' ? 1 : 0,
|
|
223
|
-
pointerId: 1, pointerType: 'mouse', isPrimary: true,
|
|
224
|
-
}
|
|
225
|
-
let event
|
|
226
|
-
try {
|
|
227
|
-
event = new PointerEvent(type, init)
|
|
228
|
-
} catch {
|
|
229
|
-
// Older engines without PointerEvent: a MouseEvent still fires listeners
|
|
230
|
-
// registered for the pointer type by name.
|
|
231
|
-
event = new MouseEvent(type, init)
|
|
232
|
-
}
|
|
233
|
-
target.dispatchEvent(event)
|
|
234
|
-
return target
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Whether Shift/Ctrl/Meta/Alt is held for a synthetic key press.
|
|
239
|
-
*
|
|
240
|
-
* Only the modifiers that change what a key actually does are honoured;
|
|
241
|
-
* anything else would need real OS state, which a page cannot fabricate.
|
|
242
|
-
*/
|
|
243
|
-
const modifierOf = (key) => ({
|
|
244
|
-
shiftKey: key.startsWith('Shift+'),
|
|
245
|
-
ctrlKey: key.startsWith('Control+') || key.startsWith('Ctrl+'),
|
|
246
|
-
altKey: key.startsWith('Alt+'),
|
|
247
|
-
metaKey: key.startsWith('Meta+'),
|
|
248
|
-
})
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Strip a Shift+-style prefix off the key that follows it.
|
|
252
|
-
*
|
|
253
|
-
* The escaped plus is doubled on purpose: this file is a template string, so a
|
|
254
|
-
* single backslash would collapse into a regex quantifier and match the wrong
|
|
255
|
-
* characters, turning "Control+a" into "+a".
|
|
256
|
-
*/
|
|
257
|
-
const baseKeyOf = (raw) => raw.replace(/^(?:Shift|Control|Ctrl|Alt|Meta)\\+/u, '')
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Whether an element is a contenteditable host.
|
|
261
|
-
*
|
|
262
|
-
* Contenteditable is detected through the attribute as well as the property:
|
|
263
|
-
* every browser implements the isContentEditable property for real elements,
|
|
264
|
-
* but some DOM implementations (jsdom among them) do not, and the attribute is
|
|
265
|
-
* the ground truth either way.
|
|
266
|
-
*/
|
|
267
|
-
const isContentEditableHost = (element) => {
|
|
268
|
-
if (!element || element.nodeType !== 1) return false
|
|
269
|
-
if (element.isContentEditable === true) return true
|
|
270
|
-
const attribute = element.getAttribute && element.getAttribute('contenteditable')
|
|
271
|
-
if (attribute === null || attribute === undefined) return false
|
|
272
|
-
const value = String(attribute).toLowerCase()
|
|
273
|
-
return value === '' || value === 'true' || value === 'plaintext-only'
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** Whether an element accepts typed characters. */
|
|
277
|
-
const isEditable = (element) => {
|
|
278
|
-
if (!element || element.nodeType !== 1) return false
|
|
279
|
-
if (isContentEditableHost(element)) return true
|
|
280
|
-
if (element.tagName === 'TEXTAREA') return element.readOnly !== true && element.disabled !== true
|
|
281
|
-
if (element.tagName === 'INPUT') {
|
|
282
|
-
const type = (element.getAttribute('type') || 'text').toLowerCase()
|
|
283
|
-
const textual = ['text', 'search', 'url', 'tel', 'email', 'password', 'number']
|
|
284
|
-
return textual.includes(type) && element.readOnly !== true && element.disabled !== true
|
|
285
|
-
}
|
|
286
|
-
return false
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** Write a value the way typing does, through the prototype setter. */
|
|
290
|
-
const writeValue = (element, next) => {
|
|
291
|
-
if (isContentEditableHost(element)) {
|
|
292
|
-
element.textContent = next
|
|
293
|
-
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
|
294
|
-
return next
|
|
295
|
-
}
|
|
296
|
-
const proto = element.tagName === 'TEXTAREA'
|
|
297
|
-
? HTMLTextAreaElement.prototype
|
|
298
|
-
: HTMLInputElement.prototype
|
|
299
|
-
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
|
300
|
-
if (setter) setter.call(element, next)
|
|
301
|
-
else element.value = next
|
|
302
|
-
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
|
303
|
-
return next
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* Apply the effect of an editing key.
|
|
308
|
-
*
|
|
309
|
-
* Returns the new value when the key changed something, or null when it is not
|
|
310
|
-
* an editing key (arrow keys, Escape, Tab and similar are left to the page).
|
|
311
|
-
*/
|
|
312
|
-
const applyEditKey = (element, key, modifiers) => {
|
|
313
|
-
if (!isEditable(element)) return null
|
|
314
|
-
const isContent = isContentEditableHost(element)
|
|
315
|
-
const current = isContent ? String(element.textContent ?? '') : String(element.value ?? '')
|
|
316
|
-
// A page cannot report a caret position reliably, so edits apply at the end,
|
|
317
|
-
// which is where typing leaves it for the usual "type then press Backspace".
|
|
318
|
-
if (key === 'Backspace') return writeValue(element, current.slice(0, -1))
|
|
319
|
-
if (key === 'Delete') return writeValue(element, current)
|
|
320
|
-
if (key === 'Enter') {
|
|
321
|
-
// Enter in a textarea inserts a newline; the form-submit path above covers
|
|
322
|
-
// the single-line case.
|
|
323
|
-
if (element.tagName === 'TEXTAREA' || isContent) return writeValue(element, current + '\\n')
|
|
324
|
-
return null
|
|
325
|
-
}
|
|
326
|
-
if (key.length === 1 && (modifiers.ctrlKey || modifiers.metaKey) && (key === 'a' || key === 'A')) {
|
|
327
|
-
return writeValue(element, '')
|
|
328
|
-
}
|
|
329
|
-
if (key.length === 1 && !modifiers.ctrlKey && !modifiers.metaKey) {
|
|
330
|
-
return writeValue(element, current + key)
|
|
331
|
-
}
|
|
332
|
-
return null
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
window.${AGENT_GLOBAL} = {
|
|
336
|
-
version: 1,
|
|
337
|
-
|
|
338
|
-
/** Page identity plus viewport geometry. */
|
|
339
|
-
state() {
|
|
340
|
-
return {
|
|
341
|
-
url: location.href,
|
|
342
|
-
title: document.title,
|
|
343
|
-
readyState: document.readyState,
|
|
344
|
-
viewport: {
|
|
345
|
-
width: window.innerWidth,
|
|
346
|
-
height: window.innerHeight,
|
|
347
|
-
scrollX: Math.round(window.scrollX),
|
|
348
|
-
scrollY: Math.round(window.scrollY),
|
|
349
|
-
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0),
|
|
350
|
-
scrollHeight: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
},
|
|
354
|
-
|
|
355
|
-
/** Accessibility-style node list with stable refs. */
|
|
356
|
-
snapshot(params) {
|
|
357
|
-
const maxNodes = Math.min(Math.max(Number(params && params.maxNodes) || 400, 1), 2000)
|
|
358
|
-
return { ...this.state(), nodes: buildSnapshot(maxNodes) }
|
|
359
|
-
},
|
|
360
|
-
|
|
361
|
-
/** Visible text, optionally scoped to a selector. */
|
|
362
|
-
readText(params) {
|
|
363
|
-
const scope = params && params.selector ? document.querySelector(params.selector) : document.body
|
|
364
|
-
if (!scope) throw new Error('找不到元素:' + params.selector)
|
|
365
|
-
const text = (scope.innerText || scope.textContent || '').replace(/\\n{3,}/g, '\\n\\n').trim()
|
|
366
|
-
const limit = Math.min(Math.max(Number(params && params.maxChars) || 12000, 1), 200000)
|
|
367
|
-
return { text: text.slice(0, limit), truncated: text.length > limit, length: text.length }
|
|
368
|
-
},
|
|
369
|
-
|
|
370
|
-
/** Evaluate an expression in the page and return a JSON-safe value. */
|
|
371
|
-
evaluate(params) {
|
|
372
|
-
const expression = String(params && params.expression ? params.expression : '')
|
|
373
|
-
if (expression === '') throw new Error('expression 不能为空')
|
|
374
|
-
// eslint-disable-next-line no-new-func
|
|
375
|
-
const value = new Function('return (' + expression + ')')()
|
|
376
|
-
if (value === undefined) return null
|
|
377
|
-
if (value === null || typeof value !== 'object') return value
|
|
378
|
-
try { return JSON.parse(JSON.stringify(value)) } catch { return String(value) }
|
|
379
|
-
},
|
|
380
|
-
|
|
381
|
-
/** Scroll by a delta, or to an element. */
|
|
382
|
-
scroll(params) {
|
|
383
|
-
const target = params && (params.ref || params.selector) ? resolveTarget(params) : undefined
|
|
384
|
-
if (target !== undefined) {
|
|
385
|
-
target.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
|
|
386
|
-
} else {
|
|
387
|
-
window.scrollBy(Number(params && params.dx) || 0, Number(params && params.dy) || 0)
|
|
388
|
-
}
|
|
389
|
-
return this.state()
|
|
390
|
-
},
|
|
391
|
-
|
|
392
|
-
/** Click by ref, selector or viewport coordinate. */
|
|
393
|
-
click(params) {
|
|
394
|
-
const element = resolveTarget(params)
|
|
395
|
-
if (!element) throw new Error('缺少点击目标:请提供 ref、selector 或 x/y')
|
|
396
|
-
if (element.disabled === true) throw new Error('元素已禁用:' + (element.tagName || ''))
|
|
397
|
-
const point = params && Number.isFinite(params.x) && Number.isFinite(params.y)
|
|
398
|
-
? { x: params.x, y: params.y }
|
|
399
|
-
: centreOf(element)
|
|
400
|
-
element.focus({ preventScroll: true })
|
|
401
|
-
// A real press produces pointer events around the mouse events; pages built
|
|
402
|
-
// on pointer listeners (most modern frameworks) ignore mouse-only dispatch.
|
|
403
|
-
dispatchPointer(element, 'pointerover', point)
|
|
404
|
-
dispatchPointer(element, 'pointerenter', point)
|
|
405
|
-
dispatchPointer(element, 'pointerdown', point)
|
|
406
|
-
dispatchMouse(element, 'mousedown', point)
|
|
407
|
-
dispatchPointer(element, 'pointerup', point)
|
|
408
|
-
dispatchMouse(element, 'mouseup', point)
|
|
409
|
-
// HTMLElement.click() runs the activation behaviour a human click would:
|
|
410
|
-
// following a link, submitting a form, toggling a checkbox. Dispatching a
|
|
411
|
-
// bare MouseEvent does not, so a link "click" previously reported an href
|
|
412
|
-
// without ever navigating.
|
|
413
|
-
let activated = false
|
|
414
|
-
if (typeof element.click === 'function') {
|
|
415
|
-
element.click()
|
|
416
|
-
activated = true
|
|
417
|
-
}
|
|
418
|
-
if (!activated) dispatchMouse(element, 'click', point)
|
|
419
|
-
const link = element.closest && element.closest('a')
|
|
420
|
-
return {
|
|
421
|
-
clicked: describe(element),
|
|
422
|
-
...(link && link.href ? { href: link.href } : {}),
|
|
423
|
-
...this.state()
|
|
424
|
-
}
|
|
425
|
-
},
|
|
426
|
-
|
|
427
|
-
/** Hover an element by ref, selector or coordinate. */
|
|
428
|
-
hover(params) {
|
|
429
|
-
const element = resolveTarget(params)
|
|
430
|
-
if (!element) throw new Error('缺少悬停目标:请提供 ref、selector 或 x/y')
|
|
431
|
-
const point = params && Number.isFinite(params.x) && Number.isFinite(params.y)
|
|
432
|
-
? { x: params.x, y: params.y }
|
|
433
|
-
: centreOf(element)
|
|
434
|
-
dispatchPointer(element, 'pointerover', point)
|
|
435
|
-
dispatchPointer(element, 'pointerenter', point)
|
|
436
|
-
dispatchPointer(element, 'pointermove', point)
|
|
437
|
-
dispatchMouse(element, 'mouseover', point)
|
|
438
|
-
dispatchMouse(element, 'mouseenter', point)
|
|
439
|
-
dispatchMouse(element, 'mousemove', point)
|
|
440
|
-
return { hovered: point }
|
|
441
|
-
},
|
|
442
|
-
|
|
443
|
-
/** Type into a field, replacing its current value. */
|
|
444
|
-
type(params) {
|
|
445
|
-
const element = resolveTarget(params)
|
|
446
|
-
if (!element) throw new Error('缺少输入目标:请提供 ref、selector 或 x/y')
|
|
447
|
-
const text = String(params && params.text !== undefined ? params.text : '')
|
|
448
|
-
const clear = !params || params.clear !== false
|
|
449
|
-
const editable = isContentEditableHost(element)
|
|
450
|
-
if (!editable && !('value' in element)) {
|
|
451
|
-
throw new Error(
|
|
452
|
-
'该元素不能输入文本:' + element.tagName + '(只支持 input / textarea / contenteditable)',
|
|
453
|
-
)
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
element.focus({ preventScroll: true })
|
|
457
|
-
if (editable) {
|
|
458
|
-
// Rich editors keep their state in the DOM, so write text nodes and let
|
|
459
|
-
// the framework observe a normal input event.
|
|
460
|
-
element.textContent = clear ? text : String(element.textContent ?? '') + text
|
|
461
|
-
} else {
|
|
462
|
-
const proto = element instanceof HTMLTextAreaElement
|
|
463
|
-
? HTMLTextAreaElement.prototype
|
|
464
|
-
: HTMLInputElement.prototype
|
|
465
|
-
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set
|
|
466
|
-
setter.call(element, clear ? text : String(element.value ?? '') + text)
|
|
467
|
-
}
|
|
468
|
-
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
|
469
|
-
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
|
|
470
|
-
return {
|
|
471
|
-
typed: text.length,
|
|
472
|
-
value: String(element.value !== undefined ? element.value : element.textContent ?? '').slice(0, 200),
|
|
473
|
-
target: describe(element)
|
|
474
|
-
}
|
|
475
|
-
},
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Send a key to the focused element (or a specific target).
|
|
479
|
-
*
|
|
480
|
-
* A page cannot synthesise OS-level key events, so the visible effect has to
|
|
481
|
-
* be produced here as well as reported: dispatching keydown alone never
|
|
482
|
-
* deletes a character, which made Backspace/Delete appear to do nothing in
|
|
483
|
-
* any input the page did not handle itself. Editing keys therefore apply
|
|
484
|
-
* their effect and fire input, the same shape a real key produces.
|
|
485
|
-
*
|
|
486
|
-
* Modifiers may be written the way a user would: Shift+Tab, Control+a.
|
|
487
|
-
*/
|
|
488
|
-
press(params) {
|
|
489
|
-
const raw = String(params && params.key ? params.key : '')
|
|
490
|
-
if (raw === '') throw new Error('key 不能为空')
|
|
491
|
-
const key = baseKeyOf(raw)
|
|
492
|
-
const modifiers = modifierOf(raw)
|
|
493
|
-
const element = (params && (params.ref || params.selector)) ? resolveTarget(params) : document.activeElement
|
|
494
|
-
const target = element || document.body
|
|
495
|
-
if (target && target.focus) target.focus({ preventScroll: true })
|
|
496
|
-
const init = { key, bubbles: true, cancelable: true, composed: true, ...modifiers }
|
|
497
|
-
target.dispatchEvent(new KeyboardEvent('keydown', init))
|
|
498
|
-
target.dispatchEvent(new KeyboardEvent('keypress', init))
|
|
499
|
-
|
|
500
|
-
// Apply the effect the key would have had on an editable element.
|
|
501
|
-
let edited = null
|
|
502
|
-
if (isEditable(target)) edited = applyEditKey(target, key, modifiers)
|
|
503
|
-
|
|
504
|
-
target.dispatchEvent(new KeyboardEvent('keyup', init))
|
|
505
|
-
// Enter on a focused form control should submit, as a real key press would.
|
|
506
|
-
if (key === 'Enter' && !modifiers.shiftKey) {
|
|
507
|
-
const form = target.closest && target.closest('form')
|
|
508
|
-
if (form) {
|
|
509
|
-
if (typeof form.requestSubmit === 'function') form.requestSubmit()
|
|
510
|
-
else form.submit()
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
return {
|
|
514
|
-
pressed: raw,
|
|
515
|
-
target: describe(target),
|
|
516
|
-
...(edited === null ? {} : { edited }),
|
|
517
|
-
}
|
|
518
|
-
},
|
|
519
|
-
|
|
520
|
-
/** Choose an option in a <select>. */
|
|
521
|
-
select(params) {
|
|
522
|
-
const element = resolveTarget(params)
|
|
523
|
-
if (!element) throw new Error('缺少下拉目标:请提供 ref 或 selector')
|
|
524
|
-
if (element.tagName !== 'SELECT') throw new Error('目标不是 SELECT:' + element.tagName)
|
|
525
|
-
const wanted = String(params && params.value !== undefined ? params.value : params && params.label ? params.label : '')
|
|
526
|
-
let matched = false
|
|
527
|
-
for (const option of element.options) {
|
|
528
|
-
const hit = option.value === wanted || (option.text || '').trim() === wanted
|
|
529
|
-
if (hit) { element.value = option.value; matched = true; break }
|
|
530
|
-
}
|
|
531
|
-
if (!matched) throw new Error('没有匹配的选项:' + wanted)
|
|
532
|
-
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
|
533
|
-
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
|
|
534
|
-
return { value: element.value, label: element.options[element.selectedIndex]?.text ?? '' }
|
|
535
|
-
},
|
|
536
|
-
|
|
537
|
-
/** Wait until a selector appears or the text shows up. */
|
|
538
|
-
async wait(params) {
|
|
539
|
-
const selector = params && typeof params.selector === 'string' ? params.selector : ''
|
|
540
|
-
const text = params && typeof params.text === 'string' ? params.text : ''
|
|
541
|
-
const timeoutMs = Math.min(Math.max(Number(params && params.timeoutMs) || 10000, 50), 120000)
|
|
542
|
-
const started = Date.now()
|
|
543
|
-
const check = () => {
|
|
544
|
-
if (selector !== '') {
|
|
545
|
-
const found = document.querySelector(selector)
|
|
546
|
-
if (!found) return false
|
|
547
|
-
if (params && params.state === 'visible' && !isVisible(found)) return false
|
|
548
|
-
return true
|
|
549
|
-
}
|
|
550
|
-
// innerText is not available in every DOM implementation, so fall back to
|
|
551
|
-
// textContent: a wait for text must never crash the whole tool.
|
|
552
|
-
if (text !== '') {
|
|
553
|
-
const body = document.body
|
|
554
|
-
const content = body ? (body.innerText || body.textContent || '') : ''
|
|
555
|
-
return String(content).includes(text)
|
|
556
|
-
}
|
|
557
|
-
return true
|
|
558
|
-
}
|
|
559
|
-
while (Date.now() - started < timeoutMs) {
|
|
560
|
-
if (check()) return { matched: true, waitedMs: Date.now() - started, ...this.state() }
|
|
561
|
-
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
562
|
-
}
|
|
563
|
-
return { matched: false, waitedMs: Date.now() - started, ...this.state() }
|
|
564
|
-
},
|
|
565
|
-
|
|
566
|
-
/**
|
|
567
|
-
* Attach files to a file input.
|
|
568
|
-
*
|
|
569
|
-
* The panel cannot open a native picker on the agent's behalf, so the host
|
|
570
|
-
* reads the bytes and passes them here as base64. Each entry becomes a real
|
|
571
|
-
* File object in the page, exactly as if the user had chosen it, so framework
|
|
572
|
-
* upload widgets (which read input.files) work unchanged.
|
|
573
|
-
*
|
|
574
|
-
* @param {{ref?: string, selector?: string, files?: Array<{name: string, type?: string, base64: string}>}} params
|
|
575
|
-
*/
|
|
576
|
-
files(params) {
|
|
577
|
-
const element = resolveTarget(params)
|
|
578
|
-
if (!element) throw new Error('缺少文件输入框')
|
|
579
|
-
if (element.tagName !== 'INPUT' || element.type !== 'file') {
|
|
580
|
-
throw new Error('目标不是文件输入框:' + element.tagName + (element.type ? '/' + element.type : ''))
|
|
581
|
-
}
|
|
582
|
-
const entries = Array.isArray(params && params.files) ? params.files : []
|
|
583
|
-
if (entries.length === 0) throw new Error('没有要上传的文件')
|
|
584
|
-
|
|
585
|
-
const transfer = new DataTransfer()
|
|
586
|
-
const attached = []
|
|
587
|
-
for (const entry of entries) {
|
|
588
|
-
const binary = atob(String(entry.base64 ?? ''))
|
|
589
|
-
const bytes = new Uint8Array(binary.length)
|
|
590
|
-
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
|
591
|
-
const file = new File([bytes], String(entry.name ?? 'file'), {
|
|
592
|
-
type: String(entry.type ?? 'application/octet-stream'),
|
|
593
|
-
})
|
|
594
|
-
transfer.items.add(file)
|
|
595
|
-
attached.push({ name: file.name, size: file.size, type: file.type })
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
element.files = transfer.files
|
|
599
|
-
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
|
600
|
-
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
|
|
601
|
-
return { attached: transfer.files.length, files: attached }
|
|
602
|
-
},
|
|
603
|
-
|
|
604
|
-
/**
|
|
605
|
-
* Render an element (or the viewport) to a PNG data URL.
|
|
606
|
-
*
|
|
607
|
-
* The plugin cannot screenshot the native guest, but whatever draws inside
|
|
608
|
-
* the page can hand its own pixels back. Charts are the reason this exists:
|
|
609
|
-
* they are <canvas> or <svg>, and both can be rasterised here.
|
|
610
|
-
*
|
|
611
|
-
* @param {{ref?: string, selector?: string, fullPage?: boolean, scale?: number}} params
|
|
612
|
-
*/
|
|
613
|
-
async capture(params) {
|
|
614
|
-
const scale = Math.min(Math.max(Number(params && params.scale) || 2, 1), 3)
|
|
615
|
-
|
|
616
|
-
/** Serialise an <svg> element, inlining the page's CSS so it renders. */
|
|
617
|
-
const svgToPng = async (svg) => {
|
|
618
|
-
const rect = svg.getBoundingClientRect()
|
|
619
|
-
if (rect.width === 0 || rect.height === 0) throw new Error('该图表尺寸为 0,可能还没渲染完成')
|
|
620
|
-
const clone = svg.cloneNode(true)
|
|
621
|
-
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
|
622
|
-
clone.setAttribute('width', Math.round(rect.width))
|
|
623
|
-
clone.setAttribute('height', Math.round(rect.height))
|
|
624
|
-
// Inline every readable stylesheet: a detached SVG has no cascade.
|
|
625
|
-
let css = ''
|
|
626
|
-
for (const sheet of Array.from(document.styleSheets)) {
|
|
627
|
-
try {
|
|
628
|
-
for (const rule of Array.from(sheet.cssRules)) css += rule.cssText + '\\n'
|
|
629
|
-
} catch {
|
|
630
|
-
/* cross-origin sheet: skip */
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
|
|
634
|
-
style.textContent = css
|
|
635
|
-
clone.insertBefore(style, clone.firstChild)
|
|
636
|
-
const background = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
|
637
|
-
background.setAttribute('width', '100%')
|
|
638
|
-
background.setAttribute('height', '100%')
|
|
639
|
-
background.setAttribute('fill', '#ffffff')
|
|
640
|
-
clone.insertBefore(background, style.nextSibling)
|
|
641
|
-
|
|
642
|
-
const markup = new XMLSerializer().serializeToString(clone)
|
|
643
|
-
const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup)
|
|
644
|
-
const image = new Image()
|
|
645
|
-
await new Promise((resolve, reject) => {
|
|
646
|
-
image.onload = () => resolve()
|
|
647
|
-
image.onerror = () => reject(new Error('SVG 转图片失败'))
|
|
648
|
-
image.src = url
|
|
649
|
-
})
|
|
650
|
-
const canvas = document.createElement('canvas')
|
|
651
|
-
canvas.width = Math.round(rect.width * scale)
|
|
652
|
-
canvas.height = Math.round(rect.height * scale)
|
|
653
|
-
const context = canvas.getContext('2d')
|
|
654
|
-
context.fillStyle = '#ffffff'
|
|
655
|
-
context.fillRect(0, 0, canvas.width, canvas.height)
|
|
656
|
-
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
|
657
|
-
return { dataUrl: canvas.toDataURL('image/png'), width: canvas.width, height: canvas.height }
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/** Copy a <canvas> as it stands. */
|
|
661
|
-
const canvasToPng = (canvas) => {
|
|
662
|
-
const out = document.createElement('canvas')
|
|
663
|
-
out.width = Math.round(canvas.width * scale)
|
|
664
|
-
out.height = Math.round(canvas.height * scale)
|
|
665
|
-
const context = out.getContext('2d')
|
|
666
|
-
context.fillStyle = '#ffffff'
|
|
667
|
-
context.fillRect(0, 0, out.width, out.height)
|
|
668
|
-
context.drawImage(canvas, 0, 0, out.width, out.height)
|
|
669
|
-
return { dataUrl: out.toDataURL('image/png'), width: out.width, height: out.height }
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// 1. An explicit target: honour whatever kind of element it is.
|
|
673
|
-
const target = params && (params.ref || params.selector) ? resolveTarget(params) : undefined
|
|
674
|
-
if (target !== undefined) {
|
|
675
|
-
const tag = target.tagName.toLowerCase()
|
|
676
|
-
if (tag === 'canvas') {
|
|
677
|
-
return { ...canvasToPng(target), tag: 'canvas', ...this.state() }
|
|
678
|
-
}
|
|
679
|
-
if (tag === 'svg') {
|
|
680
|
-
return { ...(await svgToPng(target)), tag: 'svg', ...this.state() }
|
|
681
|
-
}
|
|
682
|
-
// A container: prefer a chart inside it, else the largest canvas/svg.
|
|
683
|
-
const inner = target.querySelector('canvas, svg')
|
|
684
|
-
if (inner !== null) {
|
|
685
|
-
const innerTag = inner.tagName.toLowerCase()
|
|
686
|
-
if (innerTag === 'canvas') return { ...canvasToPng(inner), tag: 'canvas', ...this.state() }
|
|
687
|
-
return { ...(await svgToPng(inner)), tag: 'svg', ...this.state() }
|
|
688
|
-
}
|
|
689
|
-
throw new Error('目标元素里没有 canvas 或 svg,无法导出图像')
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
// 2. No target: take the biggest chart on the page (charts dominate area).
|
|
693
|
-
const charts = [
|
|
694
|
-
...Array.from(document.querySelectorAll('canvas')),
|
|
695
|
-
...Array.from(document.querySelectorAll('svg')),
|
|
696
|
-
]
|
|
697
|
-
.map((element) => {
|
|
698
|
-
const rect = element.getBoundingClientRect()
|
|
699
|
-
return { element, area: rect.width * rect.height, rect }
|
|
700
|
-
})
|
|
701
|
-
.filter((entry) => entry.area > 12000)
|
|
702
|
-
.sort((left, right) => right.area - left.area)
|
|
703
|
-
|
|
704
|
-
if (charts.length === 0) {
|
|
705
|
-
throw new Error('页面上没有可导出的图表(canvas/svg)。请用 browser_snapshot 或 browser_read_text 获取内容。')
|
|
706
|
-
}
|
|
707
|
-
const biggest = charts[0]
|
|
708
|
-
const tag = biggest.element.tagName.toLowerCase()
|
|
709
|
-
const rendered = tag === 'canvas'
|
|
710
|
-
? canvasToPng(biggest.element)
|
|
711
|
-
: await svgToPng(biggest.element)
|
|
712
|
-
return {
|
|
713
|
-
...rendered,
|
|
714
|
-
tag,
|
|
715
|
-
count: charts.length,
|
|
716
|
-
...this.state(),
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
return { installed: true, reused: false }
|
|
722
|
-
})()
|
|
723
|
-
`
|
|
1
|
+
/**
|
|
2
|
+
* The script injected into each browser guest page.
|
|
3
|
+
*
|
|
4
|
+
* It is serialized into the guest through `<webview>.executeJavaScript()`, so
|
|
5
|
+
* this file must evaluate standalone inside a page: no imports, no closures over
|
|
6
|
+
* plugin state. It installs one global (`__dshBrowserAgent`) exposing the
|
|
7
|
+
* primitive the panel calls per command.
|
|
8
|
+
*
|
|
9
|
+
* Everything here runs *in the page*, so it uses DOM APIs only. Coordinates are
|
|
10
|
+
* CSS pixels in the page's own viewport, matching what a human sees.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const AGENT_GLOBAL = '__dshBrowserAgent'
|
|
14
|
+
|
|
15
|
+
/** Accessibility-ish view of one element, used to give the model stable refs. */
|
|
16
|
+
const SNAPSHOT_HELPERS = /* js */ `
|
|
17
|
+
const INTERACTIVE = new Set([
|
|
18
|
+
'A','BUTTON','INPUT','SELECT','TEXTAREA','SUMMARY','OPTION','LABEL'
|
|
19
|
+
])
|
|
20
|
+
const ROLE_BY_TAG = {
|
|
21
|
+
A: 'link', BUTTON: 'button', INPUT: 'textbox', SELECT: 'combobox',
|
|
22
|
+
TEXTAREA: 'textbox', IMG: 'img', H1: 'heading', H2: 'heading', H3: 'heading',
|
|
23
|
+
H4: 'heading', H5: 'heading', H6: 'heading', UL: 'list', OL: 'list',
|
|
24
|
+
LI: 'listitem', TABLE: 'table', TR: 'row', TD: 'cell', TH: 'columnheader',
|
|
25
|
+
NAV: 'navigation', MAIN: 'main', ASIDE: 'complementary', HEADER: 'banner',
|
|
26
|
+
FOOTER: 'contentinfo', FORM: 'form', DIALOG: 'dialog', P: 'paragraph'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const roleOf = (element) => {
|
|
30
|
+
const explicit = element.getAttribute && element.getAttribute('role')
|
|
31
|
+
if (explicit) return explicit
|
|
32
|
+
if (element.tagName === 'INPUT') {
|
|
33
|
+
const type = (element.getAttribute('type') || 'text').toLowerCase()
|
|
34
|
+
if (type === 'checkbox') return 'checkbox'
|
|
35
|
+
if (type === 'radio') return 'radio'
|
|
36
|
+
if (type === 'button' || type === 'submit' || type === 'reset') return 'button'
|
|
37
|
+
if (type === 'search') return 'searchbox'
|
|
38
|
+
if (type === 'number') return 'spinbutton'
|
|
39
|
+
if (type === 'range') return 'slider'
|
|
40
|
+
return 'textbox'
|
|
41
|
+
}
|
|
42
|
+
return ROLE_BY_TAG[element.tagName] || element.tagName.toLowerCase()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const accessibleName = (element) => {
|
|
46
|
+
const aria = element.getAttribute && (element.getAttribute('aria-label') || element.getAttribute('aria-labelledby'))
|
|
47
|
+
if (aria) return aria
|
|
48
|
+
if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') {
|
|
49
|
+
if (element.labels && element.labels.length > 0) return (element.labels[0].innerText || '').trim()
|
|
50
|
+
return (element.getAttribute('placeholder') || element.getAttribute('title') || '').trim()
|
|
51
|
+
}
|
|
52
|
+
if (element.tagName === 'IMG') return (element.getAttribute('alt') || '').trim()
|
|
53
|
+
return (element.innerText || element.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 120)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether an element has any painted box.
|
|
58
|
+
*
|
|
59
|
+
* A zero-height block still reports a non-zero width, so both dimensions must
|
|
60
|
+
* be checked: otherwise an empty wrapper looks visible to a caller that asked
|
|
61
|
+
* to wait until something is actually on screen.
|
|
62
|
+
*/
|
|
63
|
+
const isVisible = (element) => {
|
|
64
|
+
if (!element.isConnected) return false
|
|
65
|
+
const style = getComputedStyle(element)
|
|
66
|
+
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false
|
|
67
|
+
const rect = element.getBoundingClientRect()
|
|
68
|
+
if (rect.width <= 0 || rect.height <= 0) return false
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const clickable = (element) => element.closest(
|
|
73
|
+
'a,button,input,select,textarea,summary,label,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="tab"],[onclick]'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
/** Stable ref per element for the lifetime of one snapshot. */
|
|
77
|
+
const refFor = (element) => {
|
|
78
|
+
if (!element.__dshRef) {
|
|
79
|
+
const next = (window.__dshRefSeq = (window.__dshRefSeq || 0) + 1)
|
|
80
|
+
element.__dshRef = 'e' + next
|
|
81
|
+
;(window.__dshRefs = window.__dshRefs || {})[element.__dshRef] = element
|
|
82
|
+
}
|
|
83
|
+
return element.__dshRef
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Drop the previous snapshot's refs.
|
|
88
|
+
*
|
|
89
|
+
* A ref is documented as valid for one snapshot, and a page holds its own refs
|
|
90
|
+
* alive: without this, every element ever snapshotted stayed reachable from
|
|
91
|
+
* window.__dshRefs, so a long-lived single-page app leaked every node it had
|
|
92
|
+
* ever shown. Clearing on the next snapshot keeps the documented lifetime and
|
|
93
|
+
* bounds the map to one snapshot's worth of nodes.
|
|
94
|
+
*/
|
|
95
|
+
const resetRefs = () => {
|
|
96
|
+
const previous = window.__dshRefs
|
|
97
|
+
if (previous) {
|
|
98
|
+
for (const element of Object.values(previous)) {
|
|
99
|
+
if (element && typeof element === 'object') element.__dshRef = undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
window.__dshRefs = {}
|
|
103
|
+
window.__dshRefSeq = 0
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const elementFor = (ref) => {
|
|
107
|
+
const found = (window.__dshRefs || {})[ref]
|
|
108
|
+
// A ref outlives the node it pointed at: single-page apps re-render constantly,
|
|
109
|
+
// so an old ref may resolve to a detached element. Acting on that would
|
|
110
|
+
// silently succeed against invisible DOM, which is worse than an error.
|
|
111
|
+
if (found === undefined) return undefined
|
|
112
|
+
if (found.isConnected !== true) return undefined
|
|
113
|
+
// Also reject a node that belongs to a previous document instance (a
|
|
114
|
+
// same-origin navigation can leave an element object alive but orphaned).
|
|
115
|
+
if (found.ownerDocument !== document) return undefined
|
|
116
|
+
return found
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const buildSnapshot = (maxNodes) => {
|
|
120
|
+
resetRefs()
|
|
121
|
+
const nodes = []
|
|
122
|
+
const root = document.body || document.documentElement
|
|
123
|
+
if (!root) return nodes
|
|
124
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, {
|
|
125
|
+
acceptNode: (element) => {
|
|
126
|
+
const tag = element.tagName
|
|
127
|
+
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE') return NodeFilter.FILTER_REJECT
|
|
128
|
+
return NodeFilter.FILTER_ACCEPT
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
let element = root
|
|
132
|
+
let scanned = 0
|
|
133
|
+
while (element && nodes.length < maxNodes && scanned < maxNodes * 12) {
|
|
134
|
+
scanned += 1
|
|
135
|
+
if (isVisible(element) && element !== document.body) {
|
|
136
|
+
const role = roleOf(element)
|
|
137
|
+
const text = accessibleName(element)
|
|
138
|
+
const interactive = INTERACTIVE.has(element.tagName) || clickable(element) !== null
|
|
139
|
+
if (interactive || text !== '' || role === 'heading') {
|
|
140
|
+
nodes.push({
|
|
141
|
+
ref: refFor(element),
|
|
142
|
+
role,
|
|
143
|
+
name: text,
|
|
144
|
+
tag: element.tagName.toLowerCase(),
|
|
145
|
+
disabled: element.disabled === true || element.getAttribute('aria-disabled') === 'true',
|
|
146
|
+
...(element.tagName === 'INPUT' || element.tagName === 'TEXTAREA'
|
|
147
|
+
? { value: String(element.value ?? '').slice(0, 200), inputType: (element.getAttribute('type') || 'text').toLowerCase() }
|
|
148
|
+
: {})
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
element = walker.nextNode()
|
|
153
|
+
}
|
|
154
|
+
return nodes
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const centreOf = (element) => {
|
|
158
|
+
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
|
|
159
|
+
const rect = element.getBoundingClientRect()
|
|
160
|
+
return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }
|
|
161
|
+
}
|
|
162
|
+
`
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Build the page-facing source. Kept as a template string so the plugin can
|
|
166
|
+
* inject it with a single executeJavaScript call.
|
|
167
|
+
*/
|
|
168
|
+
export const AGENT_SOURCE = `
|
|
169
|
+
(() => {
|
|
170
|
+
if (window.${AGENT_GLOBAL}) return { installed: true, reused: true }
|
|
171
|
+
${SNAPSHOT_HELPERS}
|
|
172
|
+
|
|
173
|
+
const describe = (element) => ({
|
|
174
|
+
ref: element ? refFor(element) : undefined,
|
|
175
|
+
role: element ? roleOf(element) : undefined,
|
|
176
|
+
name: element ? accessibleName(element) : '',
|
|
177
|
+
tag: element ? element.tagName.toLowerCase() : undefined
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const resolveTarget = (params) => {
|
|
181
|
+
if (params && params.ref) {
|
|
182
|
+
const byRef = elementFor(params.ref)
|
|
183
|
+
if (!byRef) throw new Error('快照引用已失效:' + params.ref + '(请重新调用 browser_snapshot)')
|
|
184
|
+
return byRef
|
|
185
|
+
}
|
|
186
|
+
if (params && typeof params.selector === 'string' && params.selector !== '') {
|
|
187
|
+
const found = document.querySelector(params.selector)
|
|
188
|
+
if (!found) throw new Error('找不到元素:' + params.selector)
|
|
189
|
+
return found
|
|
190
|
+
}
|
|
191
|
+
if (params && Number.isFinite(params.x) && Number.isFinite(params.y)) {
|
|
192
|
+
const hit = document.elementFromPoint(params.x, params.y)
|
|
193
|
+
if (!hit) throw new Error('该坐标没有元素:' + params.x + ',' + params.y)
|
|
194
|
+
return hit
|
|
195
|
+
}
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const dispatchMouse = (element, type, point) => {
|
|
200
|
+
const target = element || document.elementFromPoint(point.x, point.y) || document.body
|
|
201
|
+
target.dispatchEvent(new MouseEvent(type, {
|
|
202
|
+
bubbles: true, cancelable: true, composed: true, view: window,
|
|
203
|
+
clientX: point.x, clientY: point.y, button: 0, buttons: type === 'mousedown' ? 1 : 0
|
|
204
|
+
}))
|
|
205
|
+
return target
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Dispatch one pointer event.
|
|
210
|
+
*
|
|
211
|
+
* Modern frameworks bind pointerdown / pointerup rather than the mouse
|
|
212
|
+
* equivalents (React 18 pointer events, Vue's @pointerdown, most drag
|
|
213
|
+
* libraries). A click built from mouse events alone silently does nothing on
|
|
214
|
+
* those pages, so both families are sent in the order a real device produces.
|
|
215
|
+
*/
|
|
216
|
+
const dispatchPointer = (element, type, point) => {
|
|
217
|
+
const target = element || document.elementFromPoint(point.x, point.y) || document.body
|
|
218
|
+
const init = {
|
|
219
|
+
bubbles: true, cancelable: true, composed: true, view: window,
|
|
220
|
+
clientX: point.x, clientY: point.y,
|
|
221
|
+
button: type === 'pointermove' ? -1 : 0,
|
|
222
|
+
buttons: type === 'pointerdown' ? 1 : 0,
|
|
223
|
+
pointerId: 1, pointerType: 'mouse', isPrimary: true,
|
|
224
|
+
}
|
|
225
|
+
let event
|
|
226
|
+
try {
|
|
227
|
+
event = new PointerEvent(type, init)
|
|
228
|
+
} catch {
|
|
229
|
+
// Older engines without PointerEvent: a MouseEvent still fires listeners
|
|
230
|
+
// registered for the pointer type by name.
|
|
231
|
+
event = new MouseEvent(type, init)
|
|
232
|
+
}
|
|
233
|
+
target.dispatchEvent(event)
|
|
234
|
+
return target
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Whether Shift/Ctrl/Meta/Alt is held for a synthetic key press.
|
|
239
|
+
*
|
|
240
|
+
* Only the modifiers that change what a key actually does are honoured;
|
|
241
|
+
* anything else would need real OS state, which a page cannot fabricate.
|
|
242
|
+
*/
|
|
243
|
+
const modifierOf = (key) => ({
|
|
244
|
+
shiftKey: key.startsWith('Shift+'),
|
|
245
|
+
ctrlKey: key.startsWith('Control+') || key.startsWith('Ctrl+'),
|
|
246
|
+
altKey: key.startsWith('Alt+'),
|
|
247
|
+
metaKey: key.startsWith('Meta+'),
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Strip a Shift+-style prefix off the key that follows it.
|
|
252
|
+
*
|
|
253
|
+
* The escaped plus is doubled on purpose: this file is a template string, so a
|
|
254
|
+
* single backslash would collapse into a regex quantifier and match the wrong
|
|
255
|
+
* characters, turning "Control+a" into "+a".
|
|
256
|
+
*/
|
|
257
|
+
const baseKeyOf = (raw) => raw.replace(/^(?:Shift|Control|Ctrl|Alt|Meta)\\+/u, '')
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Whether an element is a contenteditable host.
|
|
261
|
+
*
|
|
262
|
+
* Contenteditable is detected through the attribute as well as the property:
|
|
263
|
+
* every browser implements the isContentEditable property for real elements,
|
|
264
|
+
* but some DOM implementations (jsdom among them) do not, and the attribute is
|
|
265
|
+
* the ground truth either way.
|
|
266
|
+
*/
|
|
267
|
+
const isContentEditableHost = (element) => {
|
|
268
|
+
if (!element || element.nodeType !== 1) return false
|
|
269
|
+
if (element.isContentEditable === true) return true
|
|
270
|
+
const attribute = element.getAttribute && element.getAttribute('contenteditable')
|
|
271
|
+
if (attribute === null || attribute === undefined) return false
|
|
272
|
+
const value = String(attribute).toLowerCase()
|
|
273
|
+
return value === '' || value === 'true' || value === 'plaintext-only'
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Whether an element accepts typed characters. */
|
|
277
|
+
const isEditable = (element) => {
|
|
278
|
+
if (!element || element.nodeType !== 1) return false
|
|
279
|
+
if (isContentEditableHost(element)) return true
|
|
280
|
+
if (element.tagName === 'TEXTAREA') return element.readOnly !== true && element.disabled !== true
|
|
281
|
+
if (element.tagName === 'INPUT') {
|
|
282
|
+
const type = (element.getAttribute('type') || 'text').toLowerCase()
|
|
283
|
+
const textual = ['text', 'search', 'url', 'tel', 'email', 'password', 'number']
|
|
284
|
+
return textual.includes(type) && element.readOnly !== true && element.disabled !== true
|
|
285
|
+
}
|
|
286
|
+
return false
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Write a value the way typing does, through the prototype setter. */
|
|
290
|
+
const writeValue = (element, next) => {
|
|
291
|
+
if (isContentEditableHost(element)) {
|
|
292
|
+
element.textContent = next
|
|
293
|
+
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
|
294
|
+
return next
|
|
295
|
+
}
|
|
296
|
+
const proto = element.tagName === 'TEXTAREA'
|
|
297
|
+
? HTMLTextAreaElement.prototype
|
|
298
|
+
: HTMLInputElement.prototype
|
|
299
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set
|
|
300
|
+
if (setter) setter.call(element, next)
|
|
301
|
+
else element.value = next
|
|
302
|
+
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
|
303
|
+
return next
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Apply the effect of an editing key.
|
|
308
|
+
*
|
|
309
|
+
* Returns the new value when the key changed something, or null when it is not
|
|
310
|
+
* an editing key (arrow keys, Escape, Tab and similar are left to the page).
|
|
311
|
+
*/
|
|
312
|
+
const applyEditKey = (element, key, modifiers) => {
|
|
313
|
+
if (!isEditable(element)) return null
|
|
314
|
+
const isContent = isContentEditableHost(element)
|
|
315
|
+
const current = isContent ? String(element.textContent ?? '') : String(element.value ?? '')
|
|
316
|
+
// A page cannot report a caret position reliably, so edits apply at the end,
|
|
317
|
+
// which is where typing leaves it for the usual "type then press Backspace".
|
|
318
|
+
if (key === 'Backspace') return writeValue(element, current.slice(0, -1))
|
|
319
|
+
if (key === 'Delete') return writeValue(element, current)
|
|
320
|
+
if (key === 'Enter') {
|
|
321
|
+
// Enter in a textarea inserts a newline; the form-submit path above covers
|
|
322
|
+
// the single-line case.
|
|
323
|
+
if (element.tagName === 'TEXTAREA' || isContent) return writeValue(element, current + '\\n')
|
|
324
|
+
return null
|
|
325
|
+
}
|
|
326
|
+
if (key.length === 1 && (modifiers.ctrlKey || modifiers.metaKey) && (key === 'a' || key === 'A')) {
|
|
327
|
+
return writeValue(element, '')
|
|
328
|
+
}
|
|
329
|
+
if (key.length === 1 && !modifiers.ctrlKey && !modifiers.metaKey) {
|
|
330
|
+
return writeValue(element, current + key)
|
|
331
|
+
}
|
|
332
|
+
return null
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
window.${AGENT_GLOBAL} = {
|
|
336
|
+
version: 1,
|
|
337
|
+
|
|
338
|
+
/** Page identity plus viewport geometry. */
|
|
339
|
+
state() {
|
|
340
|
+
return {
|
|
341
|
+
url: location.href,
|
|
342
|
+
title: document.title,
|
|
343
|
+
readyState: document.readyState,
|
|
344
|
+
viewport: {
|
|
345
|
+
width: window.innerWidth,
|
|
346
|
+
height: window.innerHeight,
|
|
347
|
+
scrollX: Math.round(window.scrollX),
|
|
348
|
+
scrollY: Math.round(window.scrollY),
|
|
349
|
+
scrollWidth: Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0),
|
|
350
|
+
scrollHeight: Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
|
|
355
|
+
/** Accessibility-style node list with stable refs. */
|
|
356
|
+
snapshot(params) {
|
|
357
|
+
const maxNodes = Math.min(Math.max(Number(params && params.maxNodes) || 400, 1), 2000)
|
|
358
|
+
return { ...this.state(), nodes: buildSnapshot(maxNodes) }
|
|
359
|
+
},
|
|
360
|
+
|
|
361
|
+
/** Visible text, optionally scoped to a selector. */
|
|
362
|
+
readText(params) {
|
|
363
|
+
const scope = params && params.selector ? document.querySelector(params.selector) : document.body
|
|
364
|
+
if (!scope) throw new Error('找不到元素:' + params.selector)
|
|
365
|
+
const text = (scope.innerText || scope.textContent || '').replace(/\\n{3,}/g, '\\n\\n').trim()
|
|
366
|
+
const limit = Math.min(Math.max(Number(params && params.maxChars) || 12000, 1), 200000)
|
|
367
|
+
return { text: text.slice(0, limit), truncated: text.length > limit, length: text.length }
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
/** Evaluate an expression in the page and return a JSON-safe value. */
|
|
371
|
+
evaluate(params) {
|
|
372
|
+
const expression = String(params && params.expression ? params.expression : '')
|
|
373
|
+
if (expression === '') throw new Error('expression 不能为空')
|
|
374
|
+
// eslint-disable-next-line no-new-func
|
|
375
|
+
const value = new Function('return (' + expression + ')')()
|
|
376
|
+
if (value === undefined) return null
|
|
377
|
+
if (value === null || typeof value !== 'object') return value
|
|
378
|
+
try { return JSON.parse(JSON.stringify(value)) } catch { return String(value) }
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
/** Scroll by a delta, or to an element. */
|
|
382
|
+
scroll(params) {
|
|
383
|
+
const target = params && (params.ref || params.selector) ? resolveTarget(params) : undefined
|
|
384
|
+
if (target !== undefined) {
|
|
385
|
+
target.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' })
|
|
386
|
+
} else {
|
|
387
|
+
window.scrollBy(Number(params && params.dx) || 0, Number(params && params.dy) || 0)
|
|
388
|
+
}
|
|
389
|
+
return this.state()
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
/** Click by ref, selector or viewport coordinate. */
|
|
393
|
+
click(params) {
|
|
394
|
+
const element = resolveTarget(params)
|
|
395
|
+
if (!element) throw new Error('缺少点击目标:请提供 ref、selector 或 x/y')
|
|
396
|
+
if (element.disabled === true) throw new Error('元素已禁用:' + (element.tagName || ''))
|
|
397
|
+
const point = params && Number.isFinite(params.x) && Number.isFinite(params.y)
|
|
398
|
+
? { x: params.x, y: params.y }
|
|
399
|
+
: centreOf(element)
|
|
400
|
+
element.focus({ preventScroll: true })
|
|
401
|
+
// A real press produces pointer events around the mouse events; pages built
|
|
402
|
+
// on pointer listeners (most modern frameworks) ignore mouse-only dispatch.
|
|
403
|
+
dispatchPointer(element, 'pointerover', point)
|
|
404
|
+
dispatchPointer(element, 'pointerenter', point)
|
|
405
|
+
dispatchPointer(element, 'pointerdown', point)
|
|
406
|
+
dispatchMouse(element, 'mousedown', point)
|
|
407
|
+
dispatchPointer(element, 'pointerup', point)
|
|
408
|
+
dispatchMouse(element, 'mouseup', point)
|
|
409
|
+
// HTMLElement.click() runs the activation behaviour a human click would:
|
|
410
|
+
// following a link, submitting a form, toggling a checkbox. Dispatching a
|
|
411
|
+
// bare MouseEvent does not, so a link "click" previously reported an href
|
|
412
|
+
// without ever navigating.
|
|
413
|
+
let activated = false
|
|
414
|
+
if (typeof element.click === 'function') {
|
|
415
|
+
element.click()
|
|
416
|
+
activated = true
|
|
417
|
+
}
|
|
418
|
+
if (!activated) dispatchMouse(element, 'click', point)
|
|
419
|
+
const link = element.closest && element.closest('a')
|
|
420
|
+
return {
|
|
421
|
+
clicked: describe(element),
|
|
422
|
+
...(link && link.href ? { href: link.href } : {}),
|
|
423
|
+
...this.state()
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
/** Hover an element by ref, selector or coordinate. */
|
|
428
|
+
hover(params) {
|
|
429
|
+
const element = resolveTarget(params)
|
|
430
|
+
if (!element) throw new Error('缺少悬停目标:请提供 ref、selector 或 x/y')
|
|
431
|
+
const point = params && Number.isFinite(params.x) && Number.isFinite(params.y)
|
|
432
|
+
? { x: params.x, y: params.y }
|
|
433
|
+
: centreOf(element)
|
|
434
|
+
dispatchPointer(element, 'pointerover', point)
|
|
435
|
+
dispatchPointer(element, 'pointerenter', point)
|
|
436
|
+
dispatchPointer(element, 'pointermove', point)
|
|
437
|
+
dispatchMouse(element, 'mouseover', point)
|
|
438
|
+
dispatchMouse(element, 'mouseenter', point)
|
|
439
|
+
dispatchMouse(element, 'mousemove', point)
|
|
440
|
+
return { hovered: point }
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
/** Type into a field, replacing its current value. */
|
|
444
|
+
type(params) {
|
|
445
|
+
const element = resolveTarget(params)
|
|
446
|
+
if (!element) throw new Error('缺少输入目标:请提供 ref、selector 或 x/y')
|
|
447
|
+
const text = String(params && params.text !== undefined ? params.text : '')
|
|
448
|
+
const clear = !params || params.clear !== false
|
|
449
|
+
const editable = isContentEditableHost(element)
|
|
450
|
+
if (!editable && !('value' in element)) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
'该元素不能输入文本:' + element.tagName + '(只支持 input / textarea / contenteditable)',
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
element.focus({ preventScroll: true })
|
|
457
|
+
if (editable) {
|
|
458
|
+
// Rich editors keep their state in the DOM, so write text nodes and let
|
|
459
|
+
// the framework observe a normal input event.
|
|
460
|
+
element.textContent = clear ? text : String(element.textContent ?? '') + text
|
|
461
|
+
} else {
|
|
462
|
+
const proto = element instanceof HTMLTextAreaElement
|
|
463
|
+
? HTMLTextAreaElement.prototype
|
|
464
|
+
: HTMLInputElement.prototype
|
|
465
|
+
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set
|
|
466
|
+
setter.call(element, clear ? text : String(element.value ?? '') + text)
|
|
467
|
+
}
|
|
468
|
+
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
|
469
|
+
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
|
|
470
|
+
return {
|
|
471
|
+
typed: text.length,
|
|
472
|
+
value: String(element.value !== undefined ? element.value : element.textContent ?? '').slice(0, 200),
|
|
473
|
+
target: describe(element)
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Send a key to the focused element (or a specific target).
|
|
479
|
+
*
|
|
480
|
+
* A page cannot synthesise OS-level key events, so the visible effect has to
|
|
481
|
+
* be produced here as well as reported: dispatching keydown alone never
|
|
482
|
+
* deletes a character, which made Backspace/Delete appear to do nothing in
|
|
483
|
+
* any input the page did not handle itself. Editing keys therefore apply
|
|
484
|
+
* their effect and fire input, the same shape a real key produces.
|
|
485
|
+
*
|
|
486
|
+
* Modifiers may be written the way a user would: Shift+Tab, Control+a.
|
|
487
|
+
*/
|
|
488
|
+
press(params) {
|
|
489
|
+
const raw = String(params && params.key ? params.key : '')
|
|
490
|
+
if (raw === '') throw new Error('key 不能为空')
|
|
491
|
+
const key = baseKeyOf(raw)
|
|
492
|
+
const modifiers = modifierOf(raw)
|
|
493
|
+
const element = (params && (params.ref || params.selector)) ? resolveTarget(params) : document.activeElement
|
|
494
|
+
const target = element || document.body
|
|
495
|
+
if (target && target.focus) target.focus({ preventScroll: true })
|
|
496
|
+
const init = { key, bubbles: true, cancelable: true, composed: true, ...modifiers }
|
|
497
|
+
target.dispatchEvent(new KeyboardEvent('keydown', init))
|
|
498
|
+
target.dispatchEvent(new KeyboardEvent('keypress', init))
|
|
499
|
+
|
|
500
|
+
// Apply the effect the key would have had on an editable element.
|
|
501
|
+
let edited = null
|
|
502
|
+
if (isEditable(target)) edited = applyEditKey(target, key, modifiers)
|
|
503
|
+
|
|
504
|
+
target.dispatchEvent(new KeyboardEvent('keyup', init))
|
|
505
|
+
// Enter on a focused form control should submit, as a real key press would.
|
|
506
|
+
if (key === 'Enter' && !modifiers.shiftKey) {
|
|
507
|
+
const form = target.closest && target.closest('form')
|
|
508
|
+
if (form) {
|
|
509
|
+
if (typeof form.requestSubmit === 'function') form.requestSubmit()
|
|
510
|
+
else form.submit()
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return {
|
|
514
|
+
pressed: raw,
|
|
515
|
+
target: describe(target),
|
|
516
|
+
...(edited === null ? {} : { edited }),
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
|
|
520
|
+
/** Choose an option in a <select>. */
|
|
521
|
+
select(params) {
|
|
522
|
+
const element = resolveTarget(params)
|
|
523
|
+
if (!element) throw new Error('缺少下拉目标:请提供 ref 或 selector')
|
|
524
|
+
if (element.tagName !== 'SELECT') throw new Error('目标不是 SELECT:' + element.tagName)
|
|
525
|
+
const wanted = String(params && params.value !== undefined ? params.value : params && params.label ? params.label : '')
|
|
526
|
+
let matched = false
|
|
527
|
+
for (const option of element.options) {
|
|
528
|
+
const hit = option.value === wanted || (option.text || '').trim() === wanted
|
|
529
|
+
if (hit) { element.value = option.value; matched = true; break }
|
|
530
|
+
}
|
|
531
|
+
if (!matched) throw new Error('没有匹配的选项:' + wanted)
|
|
532
|
+
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
|
533
|
+
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
|
|
534
|
+
return { value: element.value, label: element.options[element.selectedIndex]?.text ?? '' }
|
|
535
|
+
},
|
|
536
|
+
|
|
537
|
+
/** Wait until a selector appears or the text shows up. */
|
|
538
|
+
async wait(params) {
|
|
539
|
+
const selector = params && typeof params.selector === 'string' ? params.selector : ''
|
|
540
|
+
const text = params && typeof params.text === 'string' ? params.text : ''
|
|
541
|
+
const timeoutMs = Math.min(Math.max(Number(params && params.timeoutMs) || 10000, 50), 120000)
|
|
542
|
+
const started = Date.now()
|
|
543
|
+
const check = () => {
|
|
544
|
+
if (selector !== '') {
|
|
545
|
+
const found = document.querySelector(selector)
|
|
546
|
+
if (!found) return false
|
|
547
|
+
if (params && params.state === 'visible' && !isVisible(found)) return false
|
|
548
|
+
return true
|
|
549
|
+
}
|
|
550
|
+
// innerText is not available in every DOM implementation, so fall back to
|
|
551
|
+
// textContent: a wait for text must never crash the whole tool.
|
|
552
|
+
if (text !== '') {
|
|
553
|
+
const body = document.body
|
|
554
|
+
const content = body ? (body.innerText || body.textContent || '') : ''
|
|
555
|
+
return String(content).includes(text)
|
|
556
|
+
}
|
|
557
|
+
return true
|
|
558
|
+
}
|
|
559
|
+
while (Date.now() - started < timeoutMs) {
|
|
560
|
+
if (check()) return { matched: true, waitedMs: Date.now() - started, ...this.state() }
|
|
561
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
562
|
+
}
|
|
563
|
+
return { matched: false, waitedMs: Date.now() - started, ...this.state() }
|
|
564
|
+
},
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Attach files to a file input.
|
|
568
|
+
*
|
|
569
|
+
* The panel cannot open a native picker on the agent's behalf, so the host
|
|
570
|
+
* reads the bytes and passes them here as base64. Each entry becomes a real
|
|
571
|
+
* File object in the page, exactly as if the user had chosen it, so framework
|
|
572
|
+
* upload widgets (which read input.files) work unchanged.
|
|
573
|
+
*
|
|
574
|
+
* @param {{ref?: string, selector?: string, files?: Array<{name: string, type?: string, base64: string}>}} params
|
|
575
|
+
*/
|
|
576
|
+
files(params) {
|
|
577
|
+
const element = resolveTarget(params)
|
|
578
|
+
if (!element) throw new Error('缺少文件输入框')
|
|
579
|
+
if (element.tagName !== 'INPUT' || element.type !== 'file') {
|
|
580
|
+
throw new Error('目标不是文件输入框:' + element.tagName + (element.type ? '/' + element.type : ''))
|
|
581
|
+
}
|
|
582
|
+
const entries = Array.isArray(params && params.files) ? params.files : []
|
|
583
|
+
if (entries.length === 0) throw new Error('没有要上传的文件')
|
|
584
|
+
|
|
585
|
+
const transfer = new DataTransfer()
|
|
586
|
+
const attached = []
|
|
587
|
+
for (const entry of entries) {
|
|
588
|
+
const binary = atob(String(entry.base64 ?? ''))
|
|
589
|
+
const bytes = new Uint8Array(binary.length)
|
|
590
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
|
591
|
+
const file = new File([bytes], String(entry.name ?? 'file'), {
|
|
592
|
+
type: String(entry.type ?? 'application/octet-stream'),
|
|
593
|
+
})
|
|
594
|
+
transfer.items.add(file)
|
|
595
|
+
attached.push({ name: file.name, size: file.size, type: file.type })
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
element.files = transfer.files
|
|
599
|
+
element.dispatchEvent(new Event('input', { bubbles: true, composed: true }))
|
|
600
|
+
element.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
|
|
601
|
+
return { attached: transfer.files.length, files: attached }
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Render an element (or the viewport) to a PNG data URL.
|
|
606
|
+
*
|
|
607
|
+
* The plugin cannot screenshot the native guest, but whatever draws inside
|
|
608
|
+
* the page can hand its own pixels back. Charts are the reason this exists:
|
|
609
|
+
* they are <canvas> or <svg>, and both can be rasterised here.
|
|
610
|
+
*
|
|
611
|
+
* @param {{ref?: string, selector?: string, fullPage?: boolean, scale?: number}} params
|
|
612
|
+
*/
|
|
613
|
+
async capture(params) {
|
|
614
|
+
const scale = Math.min(Math.max(Number(params && params.scale) || 2, 1), 3)
|
|
615
|
+
|
|
616
|
+
/** Serialise an <svg> element, inlining the page's CSS so it renders. */
|
|
617
|
+
const svgToPng = async (svg) => {
|
|
618
|
+
const rect = svg.getBoundingClientRect()
|
|
619
|
+
if (rect.width === 0 || rect.height === 0) throw new Error('该图表尺寸为 0,可能还没渲染完成')
|
|
620
|
+
const clone = svg.cloneNode(true)
|
|
621
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
|
|
622
|
+
clone.setAttribute('width', Math.round(rect.width))
|
|
623
|
+
clone.setAttribute('height', Math.round(rect.height))
|
|
624
|
+
// Inline every readable stylesheet: a detached SVG has no cascade.
|
|
625
|
+
let css = ''
|
|
626
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
627
|
+
try {
|
|
628
|
+
for (const rule of Array.from(sheet.cssRules)) css += rule.cssText + '\\n'
|
|
629
|
+
} catch {
|
|
630
|
+
/* cross-origin sheet: skip */
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const style = document.createElementNS('http://www.w3.org/2000/svg', 'style')
|
|
634
|
+
style.textContent = css
|
|
635
|
+
clone.insertBefore(style, clone.firstChild)
|
|
636
|
+
const background = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
|
|
637
|
+
background.setAttribute('width', '100%')
|
|
638
|
+
background.setAttribute('height', '100%')
|
|
639
|
+
background.setAttribute('fill', '#ffffff')
|
|
640
|
+
clone.insertBefore(background, style.nextSibling)
|
|
641
|
+
|
|
642
|
+
const markup = new XMLSerializer().serializeToString(clone)
|
|
643
|
+
const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup)
|
|
644
|
+
const image = new Image()
|
|
645
|
+
await new Promise((resolve, reject) => {
|
|
646
|
+
image.onload = () => resolve()
|
|
647
|
+
image.onerror = () => reject(new Error('SVG 转图片失败'))
|
|
648
|
+
image.src = url
|
|
649
|
+
})
|
|
650
|
+
const canvas = document.createElement('canvas')
|
|
651
|
+
canvas.width = Math.round(rect.width * scale)
|
|
652
|
+
canvas.height = Math.round(rect.height * scale)
|
|
653
|
+
const context = canvas.getContext('2d')
|
|
654
|
+
context.fillStyle = '#ffffff'
|
|
655
|
+
context.fillRect(0, 0, canvas.width, canvas.height)
|
|
656
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height)
|
|
657
|
+
return { dataUrl: canvas.toDataURL('image/png'), width: canvas.width, height: canvas.height }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Copy a <canvas> as it stands. */
|
|
661
|
+
const canvasToPng = (canvas) => {
|
|
662
|
+
const out = document.createElement('canvas')
|
|
663
|
+
out.width = Math.round(canvas.width * scale)
|
|
664
|
+
out.height = Math.round(canvas.height * scale)
|
|
665
|
+
const context = out.getContext('2d')
|
|
666
|
+
context.fillStyle = '#ffffff'
|
|
667
|
+
context.fillRect(0, 0, out.width, out.height)
|
|
668
|
+
context.drawImage(canvas, 0, 0, out.width, out.height)
|
|
669
|
+
return { dataUrl: out.toDataURL('image/png'), width: out.width, height: out.height }
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 1. An explicit target: honour whatever kind of element it is.
|
|
673
|
+
const target = params && (params.ref || params.selector) ? resolveTarget(params) : undefined
|
|
674
|
+
if (target !== undefined) {
|
|
675
|
+
const tag = target.tagName.toLowerCase()
|
|
676
|
+
if (tag === 'canvas') {
|
|
677
|
+
return { ...canvasToPng(target), tag: 'canvas', ...this.state() }
|
|
678
|
+
}
|
|
679
|
+
if (tag === 'svg') {
|
|
680
|
+
return { ...(await svgToPng(target)), tag: 'svg', ...this.state() }
|
|
681
|
+
}
|
|
682
|
+
// A container: prefer a chart inside it, else the largest canvas/svg.
|
|
683
|
+
const inner = target.querySelector('canvas, svg')
|
|
684
|
+
if (inner !== null) {
|
|
685
|
+
const innerTag = inner.tagName.toLowerCase()
|
|
686
|
+
if (innerTag === 'canvas') return { ...canvasToPng(inner), tag: 'canvas', ...this.state() }
|
|
687
|
+
return { ...(await svgToPng(inner)), tag: 'svg', ...this.state() }
|
|
688
|
+
}
|
|
689
|
+
throw new Error('目标元素里没有 canvas 或 svg,无法导出图像')
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// 2. No target: take the biggest chart on the page (charts dominate area).
|
|
693
|
+
const charts = [
|
|
694
|
+
...Array.from(document.querySelectorAll('canvas')),
|
|
695
|
+
...Array.from(document.querySelectorAll('svg')),
|
|
696
|
+
]
|
|
697
|
+
.map((element) => {
|
|
698
|
+
const rect = element.getBoundingClientRect()
|
|
699
|
+
return { element, area: rect.width * rect.height, rect }
|
|
700
|
+
})
|
|
701
|
+
.filter((entry) => entry.area > 12000)
|
|
702
|
+
.sort((left, right) => right.area - left.area)
|
|
703
|
+
|
|
704
|
+
if (charts.length === 0) {
|
|
705
|
+
throw new Error('页面上没有可导出的图表(canvas/svg)。请用 browser_snapshot 或 browser_read_text 获取内容。')
|
|
706
|
+
}
|
|
707
|
+
const biggest = charts[0]
|
|
708
|
+
const tag = biggest.element.tagName.toLowerCase()
|
|
709
|
+
const rendered = tag === 'canvas'
|
|
710
|
+
? canvasToPng(biggest.element)
|
|
711
|
+
: await svgToPng(biggest.element)
|
|
712
|
+
return {
|
|
713
|
+
...rendered,
|
|
714
|
+
tag,
|
|
715
|
+
count: charts.length,
|
|
716
|
+
...this.state(),
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return { installed: true, reused: false }
|
|
722
|
+
})()
|
|
723
|
+
`
|