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.
@@ -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
+ `