dsh-inner-browser-use 0.13.0 → 0.27.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/read.js ADDED
@@ -0,0 +1,981 @@
1
+ /**
2
+ * The read model: what the page is, said in as few tokens as the truth allows.
3
+ *
4
+ * Two rules shape this file.
5
+ *
6
+ * 1. A number means the same control twice. Playwright MCP and browser-use both
7
+ * hand out refs, and both pay for it by making the model re-read after every
8
+ * action. Here the page keeps a registry, so a ref survives a read, a
9
+ * checkbox and a tool boundary; only a navigation drops it, and a click on a
10
+ * dead ref says so instead of clicking something else.
11
+ *
12
+ * 2. Text is read from the viewport, not the document. A 60-paragraph article
13
+ * is 2.4k tokens if the whole page is printed. The person is looking at a
14
+ * screenful, the model is acting on a screenful, and scrolling is a call it
15
+ * can make on purpose.
16
+ */
17
+
18
+ /** Name of the page-side registry. */
19
+ const REGISTRY = '__dshRead'
20
+
21
+ /** Bumped when the page-side half changes, so a stale page is re-seeded. */
22
+ const VERSION = 2
23
+
24
+ /** Interactive elements worth numbering, in the order a reader would meet them. */
25
+ const SELECTOR = [
26
+ 'a[href]',
27
+ 'button',
28
+ 'input:not([type="hidden"])',
29
+ 'select',
30
+ 'textarea',
31
+ 'summary',
32
+ 'label[for]',
33
+ '[contenteditable="true"]',
34
+ '[tabindex]:not([tabindex="-1"])',
35
+ '[role="button"]',
36
+ '[role="link"]',
37
+ '[role="tab"]',
38
+ '[role="checkbox"]',
39
+ '[role="radio"]',
40
+ '[role="switch"]',
41
+ '[role="combobox"]',
42
+ '[role="menuitem"]',
43
+ '[role="option"]',
44
+ '[role="textbox"]',
45
+ '[role="searchbox"]',
46
+ /* A drawing surface has no words in the tree, but it is the whole page in
47
+ some products: number it, and let a picture be taken of it. */
48
+ 'canvas',
49
+ ].join(',')
50
+
51
+ /** How a role is written in the compact read: short enough to cost little. */
52
+ const SIGIL = { // ui-copy-ignore
53
+ a: 'link', // ui-copy-ignore
54
+ button: 'btn', // ui-copy-ignore
55
+ input: 'input', // ui-copy-ignore
56
+ select: 'select', // ui-copy-ignore
57
+ textarea: 'textarea', // ui-copy-ignore
58
+ summary: 'summary', // ui-copy-ignore
59
+ label: 'label', // ui-copy-ignore
60
+ role: 'role', // ui-copy-ignore
61
+ }
62
+
63
+ /**
64
+ * The page-side half. Installed on every read; it is a no-op when the registry
65
+ * is already there, which is what makes refs survive reads.
66
+ * @param {string} selector - interactive selector.
67
+ * @param {number} version - registry version.
68
+ * @returns {string} an expression to evaluate.
69
+ */
70
+ function seed(selector, version) {
71
+ return `(() => {
72
+ const w = window
73
+ const current = w.${REGISTRY}
74
+ if (current && current.version === ${version}) return 'kept'
75
+ const refs = new Map()
76
+ const ids = new WeakMap()
77
+ let seq = 0
78
+ w.${REGISTRY} = {
79
+ version: ${version},
80
+ refs,
81
+ /**
82
+ * Give an element the number it already has, or the next free one.
83
+ * @param {Element} el - element to number.
84
+ * @returns {number} its ref.
85
+ */
86
+ id(el) {
87
+ const known = ids.get(el)
88
+ if (known !== undefined && refs.get(known) === el) return known
89
+ seq += 1
90
+ ids.set(el, seq)
91
+ refs.set(seq, el)
92
+ return seq
93
+ },
94
+ /** @param {Element} el - element to forget. */
95
+ drop(el) {
96
+ const known = ids.get(el)
97
+ if (known !== undefined) refs.delete(known)
98
+ },
99
+ /**
100
+ * @param {number} ref - number from a read.
101
+ * @returns {{el: Element, ref: number} | null} the element, if still here.
102
+ */
103
+ find(ref) {
104
+ const el = refs.get(ref)
105
+ if (!el || !el.isConnected) { refs.delete(ref); return null }
106
+ return { el, ref }
107
+ },
108
+ selector: ${JSON.stringify(selector)},
109
+ }
110
+ return 'seeded'
111
+ })()`
112
+ }
113
+
114
+ /**
115
+ * Read the page. One evaluate, one JSON, no round trip per control.
116
+ * @param {string} mode - what to include: text, links, forms, full.
117
+ * @param {number} budget - characters of page text to keep.
118
+ * @param {number} max - controls to number at most.
119
+ * @returns {string} an expression to evaluate.
120
+ */
121
+ function snapshot(mode, budget, max) {
122
+ const wanted = JSON.stringify(mode)
123
+ return `(() => {
124
+ const reg = window.${REGISTRY}
125
+ if (!reg) return JSON.stringify({ broken: true })
126
+ const mode = ${wanted}
127
+ const budget = ${budget}
128
+ const max = ${max}
129
+ const name = (el) => {
130
+ const label = el.labels && el.labels[0] ? String(el.labels[0].innerText ?? '') : ''
131
+ const pointing = String(el.getAttribute?.('aria-labelledby') ?? '')
132
+ const named = pointing.split(/\s+/).filter(Boolean).map((id) => {
133
+ const other = el.ownerDocument.getElementById(id)
134
+ return other === null ? '' : String(other.innerText ?? other.textContent ?? '')
135
+ }).join(' ').replace(/\s+/g, ' ').trim()
136
+ const own = named || el.getAttribute?.('aria-label') || el.getAttribute?.('title') || el.getAttribute?.('placeholder') || ''
137
+ const text = String(el.innerText ?? '').replace(/\\s+/g, ' ').trim()
138
+ const value = el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '') : ''
139
+ if (el.tagName === 'CANVAS' && label === '' && own === '' && text === '' && value === '') {
140
+ const b = el.getBoundingClientRect()
141
+ return 'drawing ' + Math.round(b.width) + 'x' + Math.round(b.height)
142
+ }
143
+ return String(label || own || text || value || el.getAttribute?.('alt') || el.getAttribute?.('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 140)
144
+ }
145
+ const box = (el) => el.getBoundingClientRect()
146
+ const shown = (el) => {
147
+ const b = box(el)
148
+ if (b.width < 2 || b.height < 2) return false
149
+ const s = getComputedStyle(el)
150
+ if (s.visibility === 'hidden' || s.display === 'none') return false
151
+ if (Number(s.opacity) <= 0.05) return false
152
+ return true
153
+ }
154
+ const inView = (b) => b.bottom > 0 && b.right > 0 && b.top < innerHeight && b.left < innerWidth
155
+ /* Web components keep their controls in a shadow root, where
156
+ querySelectorAll on the document never looks. Open roots are collected
157
+ first, so a control inside one is numbered like any other. */
158
+ const roots = [document]
159
+ for (let i = 0; i < roots.length; i += 1) {
160
+ for (const el of roots[i].querySelectorAll('*')) {
161
+ if (el.shadowRoot) roots.push(el.shadowRoot)
162
+ }
163
+ }
164
+ const each = (fn) => {
165
+ for (const root of roots) for (const el of root.querySelectorAll(reg.selector)) fn(el)
166
+ }
167
+ const role = (el) => {
168
+ const explicit = el.getAttribute?.('role')
169
+ if (explicit) return explicit
170
+ const tag = el.tagName.toLowerCase()
171
+ if (tag === 'a') return 'link'
172
+ if (tag === 'summary') return 'button'
173
+ if (tag === 'input') {
174
+ const type = String(el.type ?? 'text')
175
+ if (type === 'checkbox' || type === 'radio' || type === 'submit' || type === 'button' || type === 'search') return type
176
+ return 'input'
177
+ }
178
+ return tag
179
+ }
180
+ const controls = []
181
+ const seen = new Set()
182
+ each((el) => {
183
+ if (controls.length >= max * 3) return
184
+ const b = box(el)
185
+ if (b.width < 2 || b.height < 2) return
186
+ if (!shown(el)) return
187
+ const label = name(el)
188
+ if (label === '' && el.tagName !== 'INPUT' && el.tagName !== 'SELECT' && el.tagName !== 'TEXTAREA') return
189
+ const kind = role(el)
190
+ if (mode === 'links' && kind !== 'link') return
191
+ if (mode === 'forms' && kind !== 'input' && kind !== 'select' && kind !== 'textarea' && kind !== 'btn' && kind !== 'button' && kind !== 'combobox' && kind !== 'checkbox' && kind !== 'radio') return
192
+ const view = inView(b)
193
+ /* Offscreen controls are numbered but sorted last: the person is acting on
194
+ what is on screen, and a scroll should not renumber everything. */
195
+ controls.push({
196
+ ref: reg.id(el),
197
+ kind,
198
+ name: label,
199
+ value: el.tagName === 'INPUT' && el.type !== 'password' ? String(el.value ?? '').slice(0, 80) : '',
200
+ checked: el.checked === true,
201
+ disabled: el.disabled === true || el.getAttribute?.('aria-disabled') === 'true',
202
+ view,
203
+ x: Math.round(b.left + b.width / 2),
204
+ y: Math.round(b.top + b.height / 2),
205
+ href: kind === 'link' ? String(el.href ?? '').slice(0, 120) : '',
206
+ })
207
+ seen.add(el)
208
+ })
209
+ /* Refs that pointed at elements now gone are dropped, so a later number
210
+ cannot quietly mean a different control. */
211
+ for (const ref of Array.from(reg.refs.keys())) {
212
+ const el = reg.refs.get(ref)
213
+ if (!el || !el.isConnected) reg.refs.delete(ref)
214
+ }
215
+ controls.sort((a, b) => Number(b.view) - Number(a.view))
216
+ /* Only the first screenful and a bit is worth a line each: an offscreen
217
+ control is a scroll away, and a hundred dead rows cost the reader tokens. */
218
+ const picked = controls.slice(0, max)
219
+ const parts = []
220
+ let used = 0
221
+ let full = 0
222
+ const take = (text) => {
223
+ if (text === '') return
224
+ full += text.length
225
+ if (used >= budget) return
226
+ const room = budget - used
227
+ parts.push(room >= text.length ? text : text.slice(0, room))
228
+ used += Math.min(room, text.length)
229
+ }
230
+ const main = document.querySelector('main, article, [role="main"]') ?? document.body
231
+ const texts = [main, ...roots.slice(1)]
232
+ if (mode !== 'links' && mode !== 'forms') {
233
+ const emitted = new Set()
234
+ for (const root of texts) {
235
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
236
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
237
+ const text = String(node.nodeValue ?? '').replace(/\\s+/g, ' ').trim()
238
+ const parent = node.parentElement
239
+ if (text === '' || !parent || emitted.has(parent)) continue
240
+ const b = box(parent)
241
+ const style = getComputedStyle(parent)
242
+ const fits = style.display !== 'none' && style.visibility !== 'hidden' && b.width > 1 && b.height > 1
243
+ if (!fits || !inView(b)) continue
244
+ emitted.add(parent)
245
+ take(text)
246
+ if (parent.tagName === 'P' || parent.tagName === 'DIV' || parent.tagName === 'LI' || /^H[1-6]$/.test(parent.tagName)) take('\\n')
247
+ }
248
+ }
249
+ }
250
+ /* A page with almost no words and one big picture is answering with the
251
+ picture. Say so here, in the same round trip, so a model that cannot open
252
+ files still learns that something came back: and sample it small to tell a
253
+ blank chart from a chart. */
254
+ const picture = (() => {
255
+ if (full >= 40) return null
256
+ let best = null
257
+ const missing = []
258
+ for (const el of document.querySelectorAll('img,canvas')) {
259
+ const b = box(el)
260
+ if (b.width < 64 || b.height < 64 || b.width * b.height < 8000) continue
261
+ const s = getComputedStyle(el)
262
+ if (s.visibility === 'hidden' || s.display === 'none') continue
263
+ /* A picture slot that never got its picture is the commonest way a
264
+ generated image goes missing, and it is silent by default. */
265
+ if (el.tagName === 'IMG' && el.complete && el.naturalWidth === 0) {
266
+ missing.push({ width: Math.round(b.width), height: Math.round(b.height), name: String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) })
267
+ continue
268
+ }
269
+ const w = el.tagName === 'CANVAS' ? el.width : el.naturalWidth || Math.round(b.width)
270
+ const h = el.tagName === 'CANVAS' ? el.height : el.naturalHeight || Math.round(b.height)
271
+ if (best === null || w * h > best.w * best.h) best = { el, w, h, kind: String(el.tagName).toLowerCase() }
272
+ }
273
+ if (best === null) return missing.length === 0 ? null : { missing }
274
+ /* What the read hands back about one picture: not a screen of fields. */
275
+ const said = { name: String(best.el.getAttribute?.('alt') ?? best.el.getAttribute?.('aria-label') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) }
276
+ said.kind = best.kind
277
+ said.width = best.w
278
+ said.height = best.h
279
+ said.colors = 0
280
+ said.flat = 0
281
+ try {
282
+ const board = document.createElement('canvas')
283
+ board.width = 32
284
+ board.height = 32
285
+ const pen = board.getContext('2d', { willReadFrequently: true })
286
+ if (pen === null) return said
287
+ pen.drawImage(best.el, 0, 0, 32, 32)
288
+ const dots = pen.getImageData(0, 0, 32, 32).data
289
+ const tally = new Map()
290
+ for (let at = 0; at < dots.length; at += 4) {
291
+ const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
292
+ tally.set(key, (tally.get(key) || 0) + 1)
293
+ }
294
+ let most = 0
295
+ for (const count of tally.values()) most = Math.max(most, count)
296
+ said.colors = tally.size
297
+ said.flat = Math.round((most / 1024) * 100) / 100
298
+ } catch {
299
+ /* A picture from elsewhere cannot be sampled: report its size only. */
300
+ }
301
+ return said
302
+ })()
303
+ return JSON.stringify({
304
+ url: location.href, // ui-copy-ignore
305
+ title: document.title, // ui-copy-ignore
306
+ text: parts.join(' ').replace(/\\n /g, '\\n').replace(/ {2,}/g, ' ').trim(),
307
+ textChars: full,
308
+ scroll: { y: Math.round(scrollY), height: Math.round(document.documentElement.scrollHeight), viewport: Math.round(innerHeight) },
309
+ focused: (() => { // ui-copy-ignore
310
+ const el = document.activeElement
311
+ if (!el || el === document.body) return null
312
+ return { ref: reg.id(el), kind: role(el), name: name(el) }
313
+ })(),
314
+ controls: picked,
315
+ picture,
316
+ })
317
+ })()`
318
+ }
319
+
320
+ /**
321
+ * Resolve a ref at action time, so a page that moved under the last read is
322
+ * clicked where the control is now, not where it was.
323
+ * @param {number} ref - ref from a read.
324
+ * @returns {string} an expression to evaluate.
325
+ */
326
+ export function resolve(ref) {
327
+ return `(() => {
328
+ const reg = window.${REGISTRY}
329
+ if (!reg) return JSON.stringify({ gone: true })
330
+ const hit = reg.find(${Number(ref)})
331
+ if (!hit) return JSON.stringify({ gone: true })
332
+ const el = hit.el
333
+ const b = el.getBoundingClientRect()
334
+ if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
335
+ if (b.top < 0 || b.left < 0 || b.bottom > innerHeight || b.right > innerWidth) el.scrollIntoView({ block: 'center', inline: 'center' })
336
+ const after = el.getBoundingClientRect()
337
+ const s = getComputedStyle(el)
338
+ return JSON.stringify({ gone: false, x: Math.round(after.left + after.width / 2), y: Math.round(after.top + after.height / 2), name: String(el.innerText ?? el.value ?? el.getAttribute?.('aria-label') ?? '').trim().slice(0, 80), kind: String(el.tagName).toLowerCase(), hidden: s.visibility === 'hidden' || s.display === 'none', disabled: el.disabled === true })
339
+ })()`
340
+ }
341
+
342
+ /**
343
+ * How big the control is and where it sits, so a picture can be of it alone.
344
+ * @param {number} ref - ref from a read.
345
+ * @returns {string} an expression that answers with the rectangle.
346
+ */
347
+ export function bounds(ref) {
348
+ return `(() => {
349
+ const reg = window.${REGISTRY}
350
+ if (!reg) return JSON.stringify({ gone: true })
351
+ const hit = reg.find(${Number(ref)})
352
+ if (!hit) return JSON.stringify({ gone: true })
353
+ const el = hit.el
354
+ if (el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'center' })
355
+ const b = el.getBoundingClientRect()
356
+ if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
357
+ return JSON.stringify({ gone: false, x: b.left, y: b.top, width: b.width, height: b.height })
358
+ })()`
359
+ }
360
+
361
+ /**
362
+ * The pictures on the page, as files a person can open.
363
+ * A generated image or a chart is often the whole answer, and there is no
364
+ * wording in the tree that stands for it: take the picture itself.
365
+ * @param {number} [ref] - only pictures at or inside this control.
366
+ * @param {number} [count] - how many, largest first. Default 1.
367
+ * @returns {string} an expression that answers with `{pictures: [...]}`.
368
+ */
369
+ export function pictures(ref, count) {
370
+ const scope = Number(ref)
371
+ const limit = Math.max(1, Math.min(4, Number(count) || 1))
372
+ return `(async () => {
373
+ const reg = window.${REGISTRY}
374
+ const where = ${Number.isFinite(scope) ? `(reg ? (reg.find(${scope}) || {}).el : null)` : 'null'}
375
+ const picked = []
376
+ const seen = new Set()
377
+ const add = (el, kind) => {
378
+ if (el === null || el === undefined || seen.has(el)) return
379
+ seen.add(el)
380
+ const b = el.getBoundingClientRect()
381
+ const w = kind === 'canvas' ? el.width : el.naturalWidth || b.width
382
+ const h = kind === 'canvas' ? el.height : el.naturalHeight || b.height
383
+ if (w < 48 || h < 48) return
384
+ if (b.width < 8 || b.height < 8) return
385
+ const s = getComputedStyle(el)
386
+ if (s.visibility === 'hidden' || s.display === 'none' || Number(s.opacity) < 0.05) return
387
+ picked.push({ el, kind, w, h, area: w * h })
388
+ }
389
+ const roots = where ? [where, ...where.querySelectorAll('*')] : [document, ...document.querySelectorAll('*')]
390
+ for (const node of roots) {
391
+ if (node === document || node === where) continue
392
+ const tag = node.tagName
393
+ if (tag === 'IMG') add(node, 'img')
394
+ else if (tag === 'CANVAS') add(node, 'canvas')
395
+ }
396
+ if (where && where.tagName === 'IMG') add(where, 'img')
397
+ if (where && where.tagName === 'CANVAS') add(where, 'canvas')
398
+ picked.sort((a, b) => b.area - a.area)
399
+ /* A picture nobody can look at still has to be describable: how much of it is
400
+ one colour tells a blank chart from a chart. Sampled at 32x32, which is
401
+ enough for that and costs nothing. */
402
+ const look = async (data) => {
403
+ try {
404
+ const img = new Image()
405
+ img.src = data
406
+ await img.decode()
407
+ const board = document.createElement('canvas')
408
+ board.width = 32
409
+ board.height = 32
410
+ const pen = board.getContext('2d', { willReadFrequently: true })
411
+ if (pen === null) return null
412
+ pen.drawImage(img, 0, 0, 32, 32)
413
+ const dots = pen.getImageData(0, 0, 32, 32).data
414
+ const tally = new Map()
415
+ for (let at = 0; at < dots.length; at += 4) {
416
+ const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
417
+ tally.set(key, (tally.get(key) || 0) + 1)
418
+ }
419
+ let most = 0
420
+ for (const count of tally.values()) most = Math.max(most, count)
421
+ return { colors: tally.size, flat: Math.round((most / 1024) * 100) / 100 }
422
+ } catch {
423
+ return null
424
+ }
425
+ }
426
+ const out = []
427
+ for (const item of picked.slice(0, ${limit})) {
428
+ let data = ''
429
+ let type = 'image/png'
430
+ try {
431
+ if (item.kind === 'canvas') {
432
+ data = item.el.toDataURL('image/png')
433
+ } else {
434
+ const url = item.el.currentSrc || item.el.src || ''
435
+ const answer = await fetch(url, { credentials: 'include' })
436
+ const blob = await answer.blob()
437
+ type = blob.type || type
438
+ data = await new Promise((done, fail) => {
439
+ const reader = new FileReader()
440
+ reader.onload = () => done(String(reader.result))
441
+ reader.onerror = () => fail(new Error('unreadable'))
442
+ reader.readAsDataURL(blob)
443
+ })
444
+ }
445
+ } catch {
446
+ data = ''
447
+ }
448
+ const feel = data ? await look(data) : null
449
+ out.push({ kind: item.kind, width: item.w, height: item.h, type, data, colors: feel?.colors ?? 0, flat: feel?.flat ?? 0 })
450
+ }
451
+ return JSON.stringify({ pictures: out })
452
+ })()`
453
+ }
454
+
455
+ /**
456
+ * What sits on top of a control at click time, when it is not the control.
457
+ * A cookie banner or a modal over the thing the agent meant to click is the
458
+ * commonest quiet failure on real pages: the click lands, nothing happens.
459
+ * @param {number} ref - ref from a read.
460
+ * @returns {string} an expression that answers `{tag, text, dismiss}` or an empty string.
461
+ */
462
+ export function covering(ref) {
463
+ return `(() => {
464
+ const reg = window.${REGISTRY}
465
+ if (!reg) return ''
466
+ const hit = reg.find(${Number(ref)})
467
+ if (!hit) return ''
468
+ const el = hit.el
469
+ const b = el.getBoundingClientRect()
470
+ const x = Math.round(b.left + b.width / 2)
471
+ const y = Math.round(b.top + b.height / 2)
472
+ if (x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) return ''
473
+ const top = document.elementFromPoint(x, y)
474
+ if (top === null || top === el || el.contains(top) || top.contains(el)) return ''
475
+ const raw = String(top.innerText ?? top.value ?? top.getAttribute?.('aria-label') ?? '').replace(/\\s+/g, ' ').trim().slice(0, 80)
476
+ /* The thing in the way usually carries its own way out, and taking it is the
477
+ step the agent would take next anyway. Find that control while the overlay
478
+ is already being measured, so clearing it costs no extra round trip. The
479
+ words are a narrow safe list on purpose: an overlay that offers to delete,
480
+ pay or confirm is reported, never dismissed on the person's behalf. */
481
+ let scope = top
482
+ for (let node = top.parentElement; node !== null && node !== document.body; node = node.parentElement) {
483
+ const r = node.getBoundingClientRect()
484
+ if (r.width >= innerWidth * 0.6 && r.height >= innerHeight * 0.5) scope = node
485
+ }
486
+ const box = scope.getBoundingClientRect()
487
+ const wide = box.width >= innerWidth * 0.6 && box.height >= innerHeight * 0.5
488
+ const claimed = String(scope.innerText ?? '').replace(/\\s+/g, ' ').slice(0, 400)
489
+ const risky = /(delete|remove|purchase|payment|pay now|buy now|sign out|log out|transfer|删除|支付|购买|付款|注销|退出登录|转账)/i.test(claimed)
490
+ const words = /^(accept|accept all|agree|allow|allow all|ok|okay|got it|close|dismiss|i agree|接受|同意|允许|允许全部|关闭|确定|知道了|好的)$/i
491
+ let dismiss = null
492
+ if (wide && !risky) {
493
+ const cands = scope.querySelectorAll('button,[role=button],a,input[type=button],input[type=submit]')
494
+ for (const node of [scope, ...cands]) {
495
+ if (node === el || el.contains(node) || node.contains(el)) continue
496
+ const name = String(node.innerText ?? node.value ?? node.getAttribute?.('aria-label') ?? '').replace(/\\s+/g, ' ').trim().slice(0, 40)
497
+ if (name === '' || !words.test(name)) continue
498
+ const r = node.getBoundingClientRect()
499
+ if (r.width < 2 || r.height < 2) continue
500
+ if (r.top < 0 || r.left < 0 || r.bottom > innerHeight || r.right > innerWidth) continue
501
+ dismiss = { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), name }
502
+ break
503
+ }
504
+ }
505
+ return JSON.stringify({ tag: String(top.tagName).toLowerCase(), text: raw, dismiss })
506
+ })()`
507
+ }
508
+
509
+ /** Where refs of controls inside iframes start. Main-frame refs stay small. */
510
+ const FRAME_BASE = 900_000
511
+
512
+ /** Room for the refs of one frame between FRAME_BASE steps. */
513
+ const FRAME_SLOT = 100_000
514
+
515
+ /** A frame is addressed either by its world or by the session of its own process. */
516
+ function asRoute(route) {
517
+ if (route === undefined || route === null) return {}
518
+ if (typeof route === 'number') return { contextId: route }
519
+ return route
520
+ }
521
+
522
+ /**
523
+ * Evaluate in a page, in one frame of it, or in the process of a frame Chrome
524
+ * keeps apart.
525
+ * @param {any} page - sidecar page.
526
+ * @param {string} expression - JavaScript.
527
+ * @param {number|{contextId?: number, sessionId?: string}} [route] - where to run it.
528
+ * @returns {Promise<any>} the JSON-safe value.
529
+ */
530
+ async function evaluateIn(page, expression, route) {
531
+ const where = asRoute(route)
532
+ const params = { expression, returnByValue: true }
533
+ if (where.contextId !== undefined) params.contextId = where.contextId
534
+ const { result } = await page.cdp.send('Runtime.evaluate', params, where.sessionId)
535
+ return result?.value
536
+ }
537
+
538
+ /** @param {any} page - sidecar page. @param {any} [route] - world to seed. */
539
+ async function seedPage(page, route) {
540
+ await evaluateIn(page, seed(SELECTOR, VERSION), route)
541
+ }
542
+
543
+ /**
544
+ * @param {any} page - sidecar page.
545
+ * @param {string} mode - read mode.
546
+ * @param {number} budget - text budget.
547
+ * @param {number} max - control cap.
548
+ * @param {any} [route] - world to read.
549
+ * @returns {Promise<any>} the snapshot.
550
+ */
551
+ async function evaluateSnapshot(page, mode, budget, max, route) {
552
+ const raw = String((await evaluateIn(page, snapshot(mode, budget, max), route)) ?? '{}')
553
+ try {
554
+ return JSON.parse(raw)
555
+ } catch {
556
+ return { broken: true }
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Where an iframe sits on the page. A ref inside a frame is resolved against
562
+ * the frame's own viewport, so its point needs the frame's corner added.
563
+ * @param {any} page - sidecar page.
564
+ * @param {{slot?: number, contextId?: number, sessionId?: string}} route - the frame.
565
+ * @returns {Promise<{x: number, y: number}>} the offset.
566
+ */
567
+ async function frameOffset(page, route) {
568
+ const where = asRoute(route)
569
+ if (where.contextId !== undefined) {
570
+ const own = await evaluateIn(page, `(() => { try {
571
+ const frame = window.frameElement
572
+ if (!frame) return null
573
+ const b = frame.getBoundingClientRect()
574
+ return { x: Math.round(b.left), y: Math.round(b.top) }
575
+ } catch (error) { return null } })()`, where)
576
+ if (own !== null && own !== undefined && Number.isFinite(own.x)) return own
577
+ }
578
+ if (where.frameId !== undefined && typeof page.frameBox === 'function') {
579
+ const own = await page.frameBox(where.frameId).catch(() => null)
580
+ if (own !== null) return own
581
+ }
582
+ const index = Math.max(0, Number(where.slot ?? 1) - 1)
583
+ const fallback = await evaluateIn(page, `(() => {
584
+ const frame = document.querySelectorAll('iframe')[${index}]
585
+ if (!frame) return null
586
+ const b = frame.getBoundingClientRect()
587
+ return { x: Math.round(b.left), y: Math.round(b.top) }
588
+ })()`)
589
+ if (fallback !== null && fallback !== undefined && Number.isFinite(fallback.x)) return fallback
590
+ return { x: 0, y: 0 }
591
+ }
592
+
593
+ /**
594
+ * Where a frame's refs live: its slot in the frame tree, and how to reach it —
595
+ * by world when it shares the page's process, by session when Chrome runs it
596
+ * apart.
597
+ * @param {any} page - sidecar page.
598
+ * @param {any} frame - one entry from the frame list.
599
+ * @param {number} slot - its position under the main frame.
600
+ * @returns {{slot: number, contextId?: number, sessionId?: string, frameId?: string}|null} the route.
601
+ */
602
+ function routeOf(page, frame, slot) {
603
+ const sessionId = frame.contextId === undefined && typeof page.childSession === 'function' ? page.childSession(frame.frameId) : undefined
604
+ if (frame.contextId === undefined && sessionId === undefined) return null
605
+ return { slot, contextId: frame.contextId, sessionId, frameId: frame.frameId }
606
+ }
607
+
608
+ /**
609
+ * Read the frames under the page, so a control inside an iframe is numbered
610
+ * like any other. A frame Chrome runs in its own process is read through the
611
+ * session of that process; the corner it sits at still comes from the page.
612
+ * @param {any} page - sidecar page.
613
+ * @param {string} mode - read mode.
614
+ * @param {number} budget - characters of text to keep.
615
+ * @returns {Promise<{text: string, controls: any[]}>} what the frames add.
616
+ */
617
+ async function readFrames(page, mode, budget) {
618
+ if (typeof page.frameList !== 'function') return { text: '', controls: [] }
619
+ let frames = []
620
+ try {
621
+ frames = await page.frameList()
622
+ } catch (error) {
623
+ return { text: '', controls: [] }
624
+ }
625
+ const children = frames.slice(1)
626
+ const controls = []
627
+ let text = ''
628
+ for (let slot = 0; slot < children.length && slot < 8; slot += 1) {
629
+ const route = routeOf(page, children[slot], slot + 1)
630
+ if (route === null) continue
631
+ try {
632
+ await seedPage(page, route)
633
+ const inside = await evaluateSnapshot(page, mode, Math.min(budget, 600), 40, route)
634
+ if (inside?.broken === true) continue
635
+ const offset = await frameOffset(page, route)
636
+ const tag = FRAME_BASE + slot * FRAME_SLOT
637
+ for (const item of inside.controls ?? []) {
638
+ controls.push({ ...item, ref: tag + Number(item.ref), frame: slot + 1, x: Number(item.x ?? 0) + offset.x, y: Number(item.y ?? 0) + offset.y })
639
+ }
640
+ if (text.length < 400 && typeof inside.text === 'string' && inside.text !== '') text += `${text === '' ? '' : '\n'}${inside.text.slice(0, 400)}`
641
+ } catch (error) {
642
+ continue
643
+ }
644
+ }
645
+ return { text, controls }
646
+ }
647
+
648
+ /** Decode a ref into the frame it lives in and the number that frame knows. */
649
+ function splitFrameRef(ref) {
650
+ const number = Number(ref)
651
+ if (!Number.isFinite(number) || number < FRAME_BASE) return null
652
+ const slot = Math.floor((number - FRAME_BASE) / FRAME_SLOT)
653
+ return { slot, local: number - FRAME_BASE - slot * FRAME_SLOT }
654
+ }
655
+
656
+ /**
657
+ * Where a ref lives, for a tool that has to look the element up itself.
658
+ * @param {any} page - sidecar page.
659
+ * @param {number} ref - ref from a read.
660
+ * @returns {Promise<{slot: number, local: number, contextId?: number, sessionId?: string, frameId?: string}|null>} the route, or nothing for the page itself.
661
+ */
662
+ export async function contextFor(page, ref) {
663
+ const split = splitFrameRef(ref)
664
+ if (split === null) return null
665
+ if (typeof page.frameList !== 'function') return null
666
+ const frames = await page.frameList().catch(() => [])
667
+ const frame = frames[split.slot + 1]
668
+ if (frame === undefined) return null
669
+ const route = routeOf(page, frame, split.slot + 1)
670
+ if (route === null) return null
671
+ return { ...route, local: split.local }
672
+ }
673
+
674
+ /**
675
+ * Read the page and hand back the shape the renderer wants.
676
+ * @param {any} page - sidecar page.
677
+ * @param {{mode?: string, maxChars?: number, max?: number}} [options] - how much to read.
678
+ * @returns {Promise<{url: string, title: string, text: string, textChars: number, scroll?: any, focused?: any, controls: any[]}>} the read.
679
+ */
680
+ export async function readPage(page, options = {}) {
681
+ const mode = String(options.mode ?? 'text')
682
+ const maxChars = Number.isFinite(Number(options.maxChars)) ? Math.max(400, Math.min(24_000, Number(options.maxChars))) : 6000
683
+ const max = Number.isFinite(Number(options.max)) ? Math.max(5, Math.min(200, Number(options.max))) : 90
684
+ await seedPage(page)
685
+ let read = await evaluateSnapshot(page, mode, maxChars, max)
686
+ if (read?.broken === true) {
687
+ await seedPage(page)
688
+ read = await evaluateSnapshot(page, mode, maxChars, max)
689
+ }
690
+ const framed = await readFrames(page, mode, Math.min(maxChars, 1200))
691
+ if (framed.controls.length > 0) read = { ...read, controls: [...(read.controls ?? []), ...framed.controls] }
692
+ if (framed.text !== '') read = { ...read, text: `${String(read.text ?? '')}${String(read.text ?? '') === '' ? '' : '\n'}${framed.text}` }
693
+ return {
694
+ url: String(read?.url ?? page.url),
695
+ title: String(read?.title ?? page.title),
696
+ text: String(read?.text ?? ''),
697
+ textChars: Number(read?.textChars ?? 0),
698
+ picture: read?.picture ?? null,
699
+ scroll: read?.scroll,
700
+ focused: read?.focused ?? null,
701
+ controls: Array.isArray(read?.controls) ? read.controls : [],
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Resolve a ref into a point, at the moment of the action.
707
+ * @param {any} page - sidecar page.
708
+ * @param {number} ref - ref from a read.
709
+ * @returns {Promise<{gone: boolean, x?: number, y?: number, name?: string, kind?: string, off?: boolean, hidden?: boolean, disabled?: boolean}>} where it is now.
710
+ */
711
+ export async function locate(page, ref) {
712
+ const split = splitFrameRef(ref)
713
+ let route = null
714
+ if (split !== null) {
715
+ route = await contextFor(page, ref)
716
+ if (route === null) return { gone: true }
717
+ }
718
+ const said = await evaluateIn(page, resolve(split === null ? ref : route.local), route).catch(() => undefined)
719
+ let spot = { gone: true }
720
+ try {
721
+ spot = JSON.parse(String(said ?? '{"gone":true}'))
722
+ } catch (error) {
723
+ spot = { gone: true }
724
+ }
725
+ if (split === null || spot.gone === true) return spot
726
+ const offset = await frameOffset(page, route)
727
+ return { ...spot, x: Number(spot.x ?? 0) + offset.x, y: Number(spot.y ?? 0) + offset.y }
728
+ }
729
+
730
+ /** Roles that read better without a sigil. */
731
+ function sigilOf(control) {
732
+ const kind = String(control.kind ?? '')
733
+ if (kind === 'link') return 'link'
734
+ if (kind === 'button') return 'btn'
735
+ if (kind === 'input' || kind === 'search') return 'input'
736
+ if (kind === 'checkbox' || kind === 'radio') return kind
737
+ return SIGIL[kind] ?? kind ?? 'el'
738
+ }
739
+
740
+ /**
741
+ * Render a read as compact text, controls first, then the viewport's words.
742
+ * @param {{url: string, title: string, text: string, textChars: number, scroll?: any, focused?: any, controls: any[]}} read - page read.
743
+ * @param {{mode?: string, maxChars?: number}} [options] - how it was read.
744
+ * @returns {string} the text a model sees.
745
+ */
746
+ export function renderPage(read, options = {}) {
747
+ const mode = String(options.mode ?? 'text')
748
+ const maxChars = Number(options.maxChars ?? 6000)
749
+ const lines = [read.url]
750
+ if (read.title !== '') lines.push(`# ${read.title}`)
751
+ const shown = read.controls.filter((control) => control.view !== false || mode === 'full')
752
+ const hidden = read.controls.length - shown.length
753
+ for (const control of shown) {
754
+ let line = `${control.ref} [${sigilOf(control)}] ${control.name === '' ? '(unnamed)' : control.name}`
755
+ if (control.kind === 'checkbox' || control.kind === 'radio') line += control.checked ? ' (checked)' : ' (unchecked)'
756
+ else if (control.value !== '' && control.value !== control.name) line += ` = ${control.value}`
757
+ if (control.disabled) line += ' (disabled)'
758
+ if (mode === 'links' && control.href !== '') line += ` → ${control.href}`
759
+ lines.push(line)
760
+ }
761
+ if (hidden > 0 && mode !== 'links' && mode !== 'forms') lines.push(`… ${hidden} more controls off screen`)
762
+ if (read.focused) lines.push(`focus ${read.focused.ref} [${read.focused.kind}] ${read.focused.name}`)
763
+ const body = read.text
764
+ if (body !== '') {
765
+ lines.push('')
766
+ lines.push(body)
767
+ if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
768
+ }
769
+ if (read.picture && Array.isArray(read.picture.missing)) {
770
+ /* Something was meant to be here and is not: say that instead of nothing. */
771
+ for (const gone of read.picture.missing.slice(0, 2)) {
772
+ const size = `${Math.round(Number(gone.width) || 0)}x${Math.round(Number(gone.height) || 0)}`
773
+ lines.push(`picture ${size}${gone.name === '' ? '' : ` "${gone.name}"`} did not load — the page may need a reload, or the picture is gone`)
774
+ }
775
+ } else if (read.picture) {
776
+ const pic = read.picture
777
+ const look = Number(pic.flat) >= 0.9 ? 'mostly one colour' : Number(pic.colors) <= 3 ? 'a few colours' : 'a picture with detail'
778
+ const size = `${Math.round(Number(pic.width) || 0)}x${Math.round(Number(pic.height) || 0)}`
779
+ lines.push(`picture ${size} ${look}${pic.name === '' ? '' : ` "${pic.name}"`} — sidecar_grab saves it as a file`)
780
+ }
781
+ if (read.scroll && read.scroll.height > read.scroll.viewport + 40) {
782
+ lines.push(`scroll ${read.scroll.y}/${read.scroll.height - read.scroll.viewport}`)
783
+ }
784
+ return lines.join('\n').slice(0, Math.max(600, maxChars) + 600)
785
+ }
786
+
787
+ /** A ref that was valid on the previous read and is no longer here. */
788
+ export const REF_GONE = 'That control is gone: the page changed since the read. Call sidecar_view and use the new number.'
789
+
790
+ /**
791
+ * The control itself, not its number: what it is, what it is called, and where
792
+ * it sat. Kept from the read that produced the ref so a re-render can be
793
+ * survived without asking the person to read the page again.
794
+ * @param {any} control - a control from a read.
795
+ * @returns {{kind: string, name: string, href: string, inert: boolean}} what to look for.
796
+ */
797
+ export function identityOf(control) {
798
+ const kind = String(control?.kind ?? '')
799
+ const name = String(control?.name ?? '')
800
+ const href = String(control?.href ?? '')
801
+ const inert = ['input', 'select', 'textarea', 'password', 'search', 'email', 'number', 'tel', 'url', 'date'].includes(kind)
802
+ return { kind, name, href, inert }
803
+ }
804
+
805
+ /**
806
+ * Whether a control read now is the one described by an identity: the same
807
+ * words, and not a container that happens to contain them.
808
+ * @param {{kind: string, name: string, href: string, inert: boolean}} want - identity.
809
+ * @param {any} control - a control from the current read.
810
+ * @returns {boolean} true when this is the same control.
811
+ */
812
+ export function sameThing(want, control) {
813
+ /* Nothing to look for is not a licence to click whatever is nearest. */
814
+ if (want.name === '') return false
815
+ if (String(control?.name ?? '') !== want.name) return false
816
+ if (String(control?.kind ?? '') !== want.kind) return false
817
+ /* A field and the button beside it can share their words; a field never
818
+ becomes a button, so the kind must match exactly rather than loosely. */
819
+ if (want.inert && !['input', 'select', 'textarea', 'search', 'email', 'number', 'tel', 'url', 'date'].includes(String(control?.kind ?? ''))) return false
820
+ if (want.href !== '' && String(control?.href ?? '') !== want.href) return false
821
+ return true
822
+ }
823
+
824
+ /** The in-page half shared by `labelSpot` and `selectSpot`: shadow roots, what says what. */
825
+ const NAME_PRELUDE = `
826
+ const roots = [document]
827
+ for (const el of document.querySelectorAll('*')) {
828
+ if (roots.length > 400) break
829
+ if (el.shadowRoot) roots.push(el.shadowRoot)
830
+ }
831
+ const norm = (s) => String(s ?? '').replace(/\\s+/g, ' ').trim()
832
+ const short = (s) => { const t = norm(s); return t !== '' && t.length <= 80 ? t : '' }
833
+ const editable = (el) => {
834
+ const tag = el.tagName
835
+ if (tag === 'TEXTAREA' || tag === 'SELECT') return true
836
+ if (tag === 'INPUT') {
837
+ const type = String(el.type ?? 'text').toLowerCase()
838
+ return !['hidden', 'button', 'submit', 'reset', 'checkbox', 'radio', 'file', 'image', 'range', 'color'].includes(type)
839
+ }
840
+ if (el.isContentEditable === true) return true
841
+ return ['textbox', 'searchbox', 'combobox'].includes(String(el.getAttribute('role') ?? ''))
842
+ }
843
+ const shown = (el) => {
844
+ const b = el.getBoundingClientRect()
845
+ if (b.width < 2 || b.height < 2) return false
846
+ const s = getComputedStyle(el)
847
+ return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) > 0.05
848
+ }
849
+ const names = (el) => {
850
+ const out = []
851
+ const add = (v) => { const t = short(v); if (t !== '') out.push(t) }
852
+ if (el.labels) for (const l of el.labels) add(l.innerText ?? l.textContent)
853
+ for (const id of String(el.getAttribute('aria-labelledby') ?? '').split(/\\s+/).filter(Boolean)) {
854
+ const other = document.getElementById(id)
855
+ if (other) add(other.innerText ?? other.textContent)
856
+ }
857
+ add(el.getAttribute('aria-label'))
858
+ add(el.getAttribute('placeholder'))
859
+ add(el.getAttribute('title'))
860
+ if (el.id) for (const l of document.querySelectorAll('label[for]')) if (l.htmlFor === el.id) add(l.innerText ?? l.textContent)
861
+ const prev = el.previousElementSibling
862
+ if (prev && editable(prev) !== true) add(prev.innerText ?? prev.textContent)
863
+ const parent = el.parentElement
864
+ if (parent && parent.querySelectorAll('input,textarea,select,[contenteditable=true]').length <= 1) add(parent.innerText ?? parent.textContent)
865
+ /* Wrapped a couple of levels deep (a styled box hiding a real input), the
866
+ words sit before the wrapper, not before the input. */
867
+ let up = parent
868
+ for (let depth = 0; depth < 3 && up; depth += 1) {
869
+ const before = up.previousElementSibling
870
+ if (before && editable(before) !== true) add(before.innerText ?? before.textContent)
871
+ up = up.parentElement
872
+ }
873
+ /* The attribute is the last resort for a name on screen: it is often the
874
+ developer's word for the field, not the one the user reads. */
875
+ add(el.getAttribute('name'))
876
+ return out
877
+ }
878
+ const squeeze = (s) => String(s).toLowerCase().replace(/\s+/g, '')
879
+ const scored = (el, want) => {
880
+ let score = 0
881
+ let name = ''
882
+ const flat = squeeze(want)
883
+ for (const nm of names(el)) {
884
+ const low = nm.toLowerCase()
885
+ if (low === want || squeeze(low) === flat) score = Math.max(score, 3)
886
+ else if (want.length >= 2 && low.includes(want)) score = Math.max(score, 2)
887
+ else if (flat.length >= 2 && squeeze(low).includes(flat)) score = Math.max(score, 2)
888
+ else if (low.length >= 3 && want.includes(low)) score = Math.max(score, 1)
889
+ if (score > 0 && name === '') name = nm
890
+ }
891
+ return { score, name }
892
+ }
893
+ const boxOf = (el) => {
894
+ for (let node = el; node; node = node.parentElement) {
895
+ const b = node.getBoundingClientRect()
896
+ if (b.width < 8 || b.height < 8) continue
897
+ const s = getComputedStyle(node)
898
+ if (s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) > 0.05) return b
899
+ }
900
+ return null
901
+ }
902
+ const bestOf = (selector, want, hiddenOk) => {
903
+ let best = null
904
+ for (const root of roots) {
905
+ for (const el of root.querySelectorAll(selector)) {
906
+ if (hiddenOk !== true && shown(el) !== true) continue
907
+ const hit = scored(el, want)
908
+ if (hit.score === 0) continue
909
+ if (best === null || hit.score > best.score) best = { el, score: hit.score, name: hit.name }
910
+ }
911
+ }
912
+ return best
913
+ }
914
+ `
915
+
916
+ /**
917
+ * The field a label belongs to, when nothing formally connects the two.
918
+ * ...
919
+ */
920
+ /**
921
+ * The field a label belongs to, when nothing formally connects the two.
922
+ *
923
+ * `{field: "Email"}` used to mean "click the control that says Email". On a real
924
+ * form the words live in a div above a bare input, so the click landed on the
925
+ * text and the typing went wherever focus already was — the classic silent wrong
926
+ * field. This finds the *editable* element the words point at: a real label, an
927
+ * aria name, a placeholder, a name attribute, the text right before it, or the
928
+ * text of the small wrapper it sits in.
929
+ * @param {string} words - what the field is called.
930
+ * @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
931
+ */
932
+ export function labelSpot(words) {
933
+ const wanted = JSON.stringify(String(words ?? '').toLowerCase())
934
+ return `(() => {
935
+ const want = ${wanted}
936
+ if (want === '') return JSON.stringify({})
937
+ ${NAME_PRELUDE}
938
+ const selector = 'input,textarea,select,[contenteditable=""],[contenteditable=true],[role=textbox],[role=searchbox],[role=combobox]'
939
+ let best = bestOf(selector, want)
940
+ let box = best === null ? null : boxOf(best.el)
941
+ let behind = false
942
+ if (best === null || box === null) {
943
+ /* The field a date picker shows is often a transparent box over a styled
944
+ div: the input stays out of sight, but the words still name it. */
945
+ const under = bestOf(selector, want, true)
946
+ const place = under === null ? null : boxOf(under.el)
947
+ if (under !== null && place !== null) { best = under; box = place; behind = true }
948
+ }
949
+ if (best === null || box === null) return JSON.stringify({})
950
+ if (editable(best.el) !== true) return JSON.stringify({})
951
+ best.el.scrollIntoView({ block: 'center', inline: 'nearest' })
952
+ const fresh = boxOf(best.el) ?? box
953
+ return JSON.stringify({ x: Math.round(fresh.left + fresh.width / 2), y: Math.round(fresh.top + fresh.height / 2), name: best.name, score: best.score, behind })
954
+ })()`
955
+ }
956
+
957
+ /**
958
+ * The dropdown the words belong to, with the wanted option already chosen.
959
+ * @param {string} words - what the dropdown is called on screen.
960
+ * @param {string} value - option label or value.
961
+ * @returns {string} an expression that picks the option and says what it did.
962
+ */
963
+ export function selectSpot(words, value) {
964
+ const wanted = JSON.stringify(String(words ?? '').toLowerCase())
965
+ const wanted2 = JSON.stringify(String(value ?? ''))
966
+ return `(() => {
967
+ const want = ${wanted}
968
+ const wanted = ${wanted2}
969
+ if (want === '') return 'no dropdown was named'
970
+ ${NAME_PRELUDE}
971
+ const best = bestOf('select', want)
972
+ if (best === null) return 'no dropdown says ' + JSON.stringify(want)
973
+ const select = best.el
974
+ const option = Array.from(select.options).find((item) => item.value === wanted || item.text.trim() === wanted || item.text.trim().toLowerCase() === wanted.toLowerCase())
975
+ if (!option) return 'no option ' + wanted + ' in ' + best.name + '; options are ' + Array.from(select.options).slice(0, 12).map((item) => item.text.trim()).join(' | ')
976
+ select.value = option.value
977
+ select.dispatchEvent(new Event('input', { bubbles: true }))
978
+ select.dispatchEvent(new Event('change', { bubbles: true }))
979
+ return 'chose ' + option.text.trim() + ' in ' + best.name
980
+ })()`
981
+ }