dsh-inner-browser-use 0.18.0 → 0.34.28
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/client.js +103 -8
- package/docs/ARTIFACTS.md +19 -0
- package/docs/BENCH.md +485 -0
- package/docs/ROADMAP.md +146 -0
- package/docs/VERIFY.md +14 -0
- package/index.js +320 -29
- package/package.json +9 -3
- package/read.js +403 -39
- package/review.js +213 -0
- package/sidecar.js +717 -40
- package/tools.js +764 -115
package/read.js
CHANGED
|
@@ -43,6 +43,9 @@ const SELECTOR = [
|
|
|
43
43
|
'[role="option"]',
|
|
44
44
|
'[role="textbox"]',
|
|
45
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',
|
|
46
49
|
].join(',')
|
|
47
50
|
|
|
48
51
|
/** How a role is written in the compact read: short enough to cost little. */
|
|
@@ -133,6 +136,10 @@ function snapshot(mode, budget, max) {
|
|
|
133
136
|
const own = named || el.getAttribute?.('aria-label') || el.getAttribute?.('title') || el.getAttribute?.('placeholder') || ''
|
|
134
137
|
const text = String(el.innerText ?? '').replace(/\\s+/g, ' ').trim()
|
|
135
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
|
+
}
|
|
136
143
|
return String(label || own || text || value || el.getAttribute?.('alt') || el.getAttribute?.('name') || '').replace(/\\s+/g, ' ').trim().slice(0, 140)
|
|
137
144
|
}
|
|
138
145
|
const box = (el) => el.getBoundingClientRect()
|
|
@@ -240,6 +247,98 @@ function snapshot(mode, budget, max) {
|
|
|
240
247
|
}
|
|
241
248
|
}
|
|
242
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
|
+
/* A picture that failed to arrive shrinks to an error icon, so its own
|
|
261
|
+
declared size is what the page meant to show. */
|
|
262
|
+
const askW = Number(el.getAttribute?.('width')) || 0
|
|
263
|
+
const askH = Number(el.getAttribute?.('height')) || 0
|
|
264
|
+
const wide = Math.max(b.width, askW)
|
|
265
|
+
const high = Math.max(b.height, askH)
|
|
266
|
+
if (wide < 64 || high < 64 || wide * high < 8000) continue
|
|
267
|
+
const s = getComputedStyle(el)
|
|
268
|
+
if (s.visibility === 'hidden' || s.display === 'none') continue
|
|
269
|
+
/* A picture slot that never got its picture is the commonest way a
|
|
270
|
+
generated image goes missing, and it is silent by default. */
|
|
271
|
+
if (el.tagName === 'IMG' && el.complete && el.naturalWidth === 0) {
|
|
272
|
+
missing.push({ width: Math.round(wide), height: Math.round(high) })
|
|
273
|
+
missing[missing.length - 1].name = String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60)
|
|
274
|
+
continue
|
|
275
|
+
}
|
|
276
|
+
const w = el.tagName === 'CANVAS' ? el.width : el.naturalWidth || Math.round(b.width)
|
|
277
|
+
const h = el.tagName === 'CANVAS' ? el.height : el.naturalHeight || Math.round(b.height)
|
|
278
|
+
if (best === null || w * h > best.w * best.h) best = { el, w, h, kind: String(el.tagName).toLowerCase() }
|
|
279
|
+
}
|
|
280
|
+
if (best === null) return missing.length === 0 ? null : { missing }
|
|
281
|
+
/* What the read hands back about one picture: not a screen of fields. */
|
|
282
|
+
const said = { name: String(best.el.getAttribute?.('alt') ?? best.el.getAttribute?.('aria-label') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) }
|
|
283
|
+
said.kind = best.kind
|
|
284
|
+
said.width = best.w
|
|
285
|
+
said.height = best.h
|
|
286
|
+
said.colors = 0
|
|
287
|
+
said.flat = 0
|
|
288
|
+
try {
|
|
289
|
+
const board = document.createElement('canvas')
|
|
290
|
+
board.width = 32
|
|
291
|
+
board.height = 32
|
|
292
|
+
const pen = board.getContext('2d', { willReadFrequently: true })
|
|
293
|
+
if (pen === null) return said
|
|
294
|
+
pen.drawImage(best.el, 0, 0, 32, 32)
|
|
295
|
+
const dots = pen.getImageData(0, 0, 32, 32).data
|
|
296
|
+
const tally = new Map()
|
|
297
|
+
for (let at = 0; at < dots.length; at += 4) {
|
|
298
|
+
const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
|
|
299
|
+
tally.set(key, (tally.get(key) || 0) + 1)
|
|
300
|
+
}
|
|
301
|
+
let most = 0
|
|
302
|
+
for (const count of tally.values()) most = Math.max(most, count)
|
|
303
|
+
said.colors = tally.size
|
|
304
|
+
said.flat = Math.round((most / 1024) * 100) / 100
|
|
305
|
+
} catch {
|
|
306
|
+
/* A picture from elsewhere cannot be sampled: report its size only. */
|
|
307
|
+
}
|
|
308
|
+
return said
|
|
309
|
+
})()
|
|
310
|
+
/* What no amount of text will tell the reader: the screen shows something
|
|
311
|
+
that has no words in the tree. Counted here so every read can say it. */
|
|
312
|
+
const blind = (() => {
|
|
313
|
+
const out = { pictures: 0, canvases: 0, videos: 0, frames: 0 }
|
|
314
|
+
const speaks = (el) => {
|
|
315
|
+
const said = String(el.getAttribute?.('alt') ?? '').trim() || String(el.getAttribute?.('aria-label') ?? '').trim() || String(el.getAttribute?.('title') ?? '').trim()
|
|
316
|
+
if (said.length > 1) return true
|
|
317
|
+
let near = el.closest?.('figure, picture')
|
|
318
|
+
if (near) {
|
|
319
|
+
const words = String(near.querySelector?.('figcaption')?.textContent ?? '').trim()
|
|
320
|
+
if (words.length > 1) return true
|
|
321
|
+
}
|
|
322
|
+
return false
|
|
323
|
+
}
|
|
324
|
+
const shows = (el) => {
|
|
325
|
+
const b = box(el)
|
|
326
|
+
if (b.width < 48 || b.height < 48) return false
|
|
327
|
+
if (!inView(b)) return false
|
|
328
|
+
const st = getComputedStyle(el)
|
|
329
|
+
return st.visibility !== 'hidden' && st.display !== 'none' && Number(st.opacity) > 0.05
|
|
330
|
+
}
|
|
331
|
+
for (const el of document.querySelectorAll('img')) {
|
|
332
|
+
if (!shows(el) || speaks(el)) continue
|
|
333
|
+
out.pictures += 1
|
|
334
|
+
}
|
|
335
|
+
for (const el of document.querySelectorAll('canvas')) if (shows(el)) out.canvases += 1
|
|
336
|
+
for (const el of document.querySelectorAll('video')) if (shows(el)) out.videos += 1
|
|
337
|
+
/* A tracking pixel of a frame is not something the reader is missing: only
|
|
338
|
+
frames that occupy the screen are worth a sentence. */
|
|
339
|
+
for (const el of document.querySelectorAll('iframe')) if (shows(el)) out.frames += 1
|
|
340
|
+
return out
|
|
341
|
+
})()
|
|
243
342
|
return JSON.stringify({
|
|
244
343
|
url: location.href, // ui-copy-ignore
|
|
245
344
|
title: document.title, // ui-copy-ignore
|
|
@@ -252,6 +351,8 @@ function snapshot(mode, budget, max) {
|
|
|
252
351
|
return { ref: reg.id(el), kind: role(el), name: name(el) }
|
|
253
352
|
})(),
|
|
254
353
|
controls: picked,
|
|
354
|
+
picture,
|
|
355
|
+
blind,
|
|
255
356
|
})
|
|
256
357
|
})()`
|
|
257
358
|
}
|
|
@@ -278,6 +379,130 @@ export function resolve(ref) {
|
|
|
278
379
|
})()`
|
|
279
380
|
}
|
|
280
381
|
|
|
382
|
+
/**
|
|
383
|
+
* How big the control is and where it sits, so a picture can be of it alone.
|
|
384
|
+
* @param {number} ref - ref from a read.
|
|
385
|
+
* @returns {string} an expression that answers with the rectangle.
|
|
386
|
+
*/
|
|
387
|
+
export function bounds(ref) {
|
|
388
|
+
return `(() => {
|
|
389
|
+
const reg = window.${REGISTRY}
|
|
390
|
+
if (!reg) return JSON.stringify({ gone: true })
|
|
391
|
+
const hit = reg.find(${Number(ref)})
|
|
392
|
+
if (!hit) return JSON.stringify({ gone: true })
|
|
393
|
+
const el = hit.el
|
|
394
|
+
if (el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'center' })
|
|
395
|
+
const b = el.getBoundingClientRect()
|
|
396
|
+
if (b.width < 2 || b.height < 2) return JSON.stringify({ gone: true, off: true })
|
|
397
|
+
return JSON.stringify({ gone: false, x: b.left, y: b.top, width: b.width, height: b.height })
|
|
398
|
+
})()`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The pictures on the page, as files a person can open.
|
|
403
|
+
* A generated image or a chart is often the whole answer, and there is no
|
|
404
|
+
* wording in the tree that stands for it: take the picture itself.
|
|
405
|
+
* @param {number} [ref] - only pictures at or inside this control.
|
|
406
|
+
* @param {number} [count] - how many, largest first. Default 1.
|
|
407
|
+
* @returns {string} an expression that answers with `{pictures: [...]}`.
|
|
408
|
+
*/
|
|
409
|
+
export function pictures(ref, count) {
|
|
410
|
+
const scope = Number(ref)
|
|
411
|
+
const limit = Math.max(1, Math.min(4, Number(count) || 1))
|
|
412
|
+
return `(async () => {
|
|
413
|
+
const reg = window.${REGISTRY}
|
|
414
|
+
const where = ${Number.isFinite(scope) ? `(reg ? (reg.find(${scope}) || {}).el : null)` : 'null'}
|
|
415
|
+
const picked = []
|
|
416
|
+
const seen = new Set()
|
|
417
|
+
const add = (el, kind) => {
|
|
418
|
+
if (el === null || el === undefined || seen.has(el)) return
|
|
419
|
+
seen.add(el)
|
|
420
|
+
const b = el.getBoundingClientRect()
|
|
421
|
+
const w = kind === 'canvas' ? el.width : kind === 'video' ? el.videoWidth || b.width : el.naturalWidth || b.width
|
|
422
|
+
const h = kind === 'canvas' ? el.height : kind === 'video' ? el.videoHeight || b.height : el.naturalHeight || b.height
|
|
423
|
+
if (w < 48 || h < 48) return
|
|
424
|
+
if (b.width < 8 || b.height < 8) return
|
|
425
|
+
const s = getComputedStyle(el)
|
|
426
|
+
if (s.visibility === 'hidden' || s.display === 'none' || Number(s.opacity) < 0.05) return
|
|
427
|
+
picked.push({ el, kind, w, h, area: w * h })
|
|
428
|
+
}
|
|
429
|
+
const roots = where ? [where, ...where.querySelectorAll('*')] : [document, ...document.querySelectorAll('*')]
|
|
430
|
+
for (const node of roots) {
|
|
431
|
+
if (node === document || node === where) continue
|
|
432
|
+
const tag = node.tagName
|
|
433
|
+
if (tag === 'IMG') add(node, 'img')
|
|
434
|
+
else if (tag === 'CANVAS') add(node, 'canvas')
|
|
435
|
+
/* A paused video is the frame the person is looking at; it never arrives as
|
|
436
|
+
an image file, so it has to be drawn out of the element. */
|
|
437
|
+
else if (tag === 'VIDEO' && (node.videoWidth || 0) > 0) add(node, 'video')
|
|
438
|
+
}
|
|
439
|
+
if (where && where.tagName === 'IMG') add(where, 'img')
|
|
440
|
+
if (where && where.tagName === 'CANVAS') add(where, 'canvas')
|
|
441
|
+
if (where && where.tagName === 'VIDEO' && (where.videoWidth || 0) > 0) add(where, 'video')
|
|
442
|
+
picked.sort((a, b) => b.area - a.area)
|
|
443
|
+
/* A picture nobody can look at still has to be describable: how much of it is
|
|
444
|
+
one colour tells a blank chart from a chart. Sampled at 32x32, which is
|
|
445
|
+
enough for that and costs nothing. */
|
|
446
|
+
const look = async (data) => {
|
|
447
|
+
try {
|
|
448
|
+
const img = new Image()
|
|
449
|
+
img.src = data
|
|
450
|
+
await img.decode()
|
|
451
|
+
const board = document.createElement('canvas')
|
|
452
|
+
board.width = 32
|
|
453
|
+
board.height = 32
|
|
454
|
+
const pen = board.getContext('2d', { willReadFrequently: true })
|
|
455
|
+
if (pen === null) return null
|
|
456
|
+
pen.drawImage(img, 0, 0, 32, 32)
|
|
457
|
+
const dots = pen.getImageData(0, 0, 32, 32).data
|
|
458
|
+
const tally = new Map()
|
|
459
|
+
for (let at = 0; at < dots.length; at += 4) {
|
|
460
|
+
const key = (dots[at] >> 5) * 64 + (dots[at + 1] >> 5) * 8 + (dots[at + 2] >> 5)
|
|
461
|
+
tally.set(key, (tally.get(key) || 0) + 1)
|
|
462
|
+
}
|
|
463
|
+
let most = 0
|
|
464
|
+
for (const count of tally.values()) most = Math.max(most, count)
|
|
465
|
+
return { colors: tally.size, flat: Math.round((most / 1024) * 100) / 100 }
|
|
466
|
+
} catch {
|
|
467
|
+
return null
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
const out = []
|
|
471
|
+
for (const item of picked.slice(0, ${limit})) {
|
|
472
|
+
let data = ''
|
|
473
|
+
let type = 'image/png'
|
|
474
|
+
try {
|
|
475
|
+
if (item.kind === 'canvas' || item.kind === 'video') {
|
|
476
|
+
/* Same path for both: a video frame is copied onto a board and read as
|
|
477
|
+
a picture. Nothing here needs the video to be playing. */
|
|
478
|
+
const board = document.createElement('canvas')
|
|
479
|
+
board.width = Math.max(1, Math.round(item.w))
|
|
480
|
+
board.height = Math.max(1, Math.round(item.h))
|
|
481
|
+
const pen = board.getContext('2d')
|
|
482
|
+
pen.drawImage(item.el, 0, 0, board.width, board.height)
|
|
483
|
+
data = board.toDataURL('image/png')
|
|
484
|
+
} else {
|
|
485
|
+
const url = item.el.currentSrc || item.el.src || ''
|
|
486
|
+
const answer = await fetch(url, { credentials: 'include' })
|
|
487
|
+
const blob = await answer.blob()
|
|
488
|
+
type = blob.type || type
|
|
489
|
+
data = await new Promise((done, fail) => {
|
|
490
|
+
const reader = new FileReader()
|
|
491
|
+
reader.onload = () => done(String(reader.result))
|
|
492
|
+
reader.onerror = () => fail(new Error('unreadable'))
|
|
493
|
+
reader.readAsDataURL(blob)
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
} catch {
|
|
497
|
+
data = ''
|
|
498
|
+
}
|
|
499
|
+
const feel = data ? await look(data) : null
|
|
500
|
+
out.push({ kind: item.kind, width: item.w, height: item.h, type, data, colors: feel?.colors ?? 0, flat: feel?.flat ?? 0 })
|
|
501
|
+
}
|
|
502
|
+
return JSON.stringify({ pictures: out })
|
|
503
|
+
})()`
|
|
504
|
+
}
|
|
505
|
+
|
|
281
506
|
/**
|
|
282
507
|
* What sits on top of a control at click time, when it is not the control.
|
|
283
508
|
* A cookie banner or a modal over the thing the agent meant to click is the
|
|
@@ -438,15 +663,15 @@ function routeOf(page, frame, slot) {
|
|
|
438
663
|
* @param {any} page - sidecar page.
|
|
439
664
|
* @param {string} mode - read mode.
|
|
440
665
|
* @param {number} budget - characters of text to keep.
|
|
441
|
-
* @returns {Promise<{text: string, controls: any[]}>} what the frames add.
|
|
666
|
+
* @returns {Promise<{text: string, controls: any[], frames: number}>} what the frames add.
|
|
442
667
|
*/
|
|
443
668
|
async function readFrames(page, mode, budget) {
|
|
444
|
-
if (typeof page.frameList !== 'function') return { text: '', controls: [] }
|
|
669
|
+
if (typeof page.frameList !== 'function') return { text: '', controls: [], frames: 0 }
|
|
445
670
|
let frames = []
|
|
446
671
|
try {
|
|
447
672
|
frames = await page.frameList()
|
|
448
673
|
} catch (error) {
|
|
449
|
-
return { text: '', controls: [] }
|
|
674
|
+
return { text: '', controls: [], frames: 0 }
|
|
450
675
|
}
|
|
451
676
|
const children = frames.slice(1)
|
|
452
677
|
const controls = []
|
|
@@ -468,7 +693,7 @@ async function readFrames(page, mode, budget) {
|
|
|
468
693
|
continue
|
|
469
694
|
}
|
|
470
695
|
}
|
|
471
|
-
return { text, controls }
|
|
696
|
+
return { text, controls, frames: children.length }
|
|
472
697
|
}
|
|
473
698
|
|
|
474
699
|
/** Decode a ref into the frame it lives in and the number that frame knows. */
|
|
@@ -521,18 +746,55 @@ export async function readPage(page, options = {}) {
|
|
|
521
746
|
title: String(read?.title ?? page.title),
|
|
522
747
|
text: String(read?.text ?? ''),
|
|
523
748
|
textChars: Number(read?.textChars ?? 0),
|
|
749
|
+
picture: read?.picture ?? null,
|
|
524
750
|
scroll: read?.scroll,
|
|
525
751
|
focused: read?.focused ?? null,
|
|
526
752
|
controls: Array.isArray(read?.controls) ? read.controls : [],
|
|
753
|
+
blind: blindSpots(read?.blind, framed.frames),
|
|
527
754
|
}
|
|
528
755
|
}
|
|
529
756
|
|
|
757
|
+
/**
|
|
758
|
+
* Merge what the page counted with what the frame reader managed to see.
|
|
759
|
+
* @param {any} said - counts from the page.
|
|
760
|
+
* @param {number} [read] - frames that answered.
|
|
761
|
+
* @returns {{pictures: number, canvases: number, videos: number, frames: number}} what text cannot show.
|
|
762
|
+
*/
|
|
763
|
+
export function blindSpots(said, read) {
|
|
764
|
+
const n = (value) => (Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : 0)
|
|
765
|
+
/* The page knows which frames take up the screen; the frame reader knows how
|
|
766
|
+
many exist, tracking pixels included. The screen is what the reader is
|
|
767
|
+
missing, so its count wins whenever the page could count at all. */
|
|
768
|
+
const frames = said !== undefined && said !== null && said.frames !== undefined ? n(said.frames) : n(read)
|
|
769
|
+
return { pictures: n(said?.pictures), canvases: n(said?.canvases), videos: n(said?.videos), frames }
|
|
770
|
+
}
|
|
771
|
+
|
|
530
772
|
/**
|
|
531
773
|
* Resolve a ref into a point, at the moment of the action.
|
|
532
774
|
* @param {any} page - sidecar page.
|
|
533
775
|
* @param {number} ref - ref from a read.
|
|
534
776
|
* @returns {Promise<{gone: boolean, x?: number, y?: number, name?: string, kind?: string, off?: boolean, hidden?: boolean, disabled?: boolean}>} where it is now.
|
|
535
777
|
*/
|
|
778
|
+
/**
|
|
779
|
+
* Whether a number still means what it meant when it was read.
|
|
780
|
+
*
|
|
781
|
+
* A page can keep the same element and change its words: "Send" becomes
|
|
782
|
+
* "Stop", a row is reused for another order. Pressing the old number then
|
|
783
|
+
* does something the person never asked for.
|
|
784
|
+
* @param {any} known - the control the read handed out.
|
|
785
|
+
* @param {any} spot - what the number resolves to now.
|
|
786
|
+
* @returns {string} what changed, or '' when it is the same control.
|
|
787
|
+
*/
|
|
788
|
+
export function differs(known, spot) {
|
|
789
|
+
if (known === undefined || known === null || spot === undefined || spot === null) return ''
|
|
790
|
+
const before = String(known.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
|
|
791
|
+
const now = String(spot.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
|
|
792
|
+
if (before === '' || now === '' || before === now) return ''
|
|
793
|
+
const head = Math.min(12, before.length, now.length)
|
|
794
|
+
if (head >= 4 && (before.slice(0, head) === now.slice(0, head) || before.includes(now.slice(0, head)) || now.includes(before.slice(0, head)))) return ''
|
|
795
|
+
return `that number now points at "${now}" instead of "${before}" — read the page again and use the new number.`
|
|
796
|
+
}
|
|
797
|
+
|
|
536
798
|
export async function locate(page, ref) {
|
|
537
799
|
const split = splitFrameRef(ref)
|
|
538
800
|
let route = null
|
|
@@ -572,6 +834,10 @@ export function renderPage(read, options = {}) {
|
|
|
572
834
|
const mode = String(options.mode ?? 'text')
|
|
573
835
|
const maxChars = Number(options.maxChars ?? 6000)
|
|
574
836
|
const lines = [read.url]
|
|
837
|
+
/* A browser error page is not a site with nothing on it: it is a site that
|
|
838
|
+
never arrived. Saying so is the only way the reader stops looking for the
|
|
839
|
+
content that would have been there. */
|
|
840
|
+
if (String(read.url).startsWith('chrome-error://')) lines.push('the page did not load — sidecar_nav to open the address again, or sidecar_open if the browser is gone')
|
|
575
841
|
if (read.title !== '') lines.push(`# ${read.title}`)
|
|
576
842
|
const shown = read.controls.filter((control) => control.view !== false || mode === 'full')
|
|
577
843
|
const hidden = read.controls.length - shown.length
|
|
@@ -591,6 +857,28 @@ export function renderPage(read, options = {}) {
|
|
|
591
857
|
lines.push(body)
|
|
592
858
|
if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
|
|
593
859
|
}
|
|
860
|
+
if (read.blind !== undefined && read.blind !== null) {
|
|
861
|
+
const bits = []
|
|
862
|
+
if (read.blind.pictures > 0) bits.push(`${read.blind.pictures} picture(s) with no words`)
|
|
863
|
+
if (read.blind.canvases > 0) bits.push(`${read.blind.canvases} drawing surface(s)`)
|
|
864
|
+
if (read.blind.videos > 0) bits.push(`${read.blind.videos} video(s)`)
|
|
865
|
+
if (read.blind.frames > 0) bits.push(`${read.blind.frames} embedded frame(s)`)
|
|
866
|
+
/* Only when something is actually out of reach: a line saying "nothing is
|
|
867
|
+
hidden" on every read is a line the reader learns to skip. */
|
|
868
|
+
if (bits.length > 0) lines.push(`text cannot show: ${bits.join(', ')} — sidecar_grab takes the picture, sidecar_shot shows the screen`)
|
|
869
|
+
}
|
|
870
|
+
if (read.picture && Array.isArray(read.picture.missing)) {
|
|
871
|
+
/* Something was meant to be here and is not: say that instead of nothing. */
|
|
872
|
+
for (const gone of read.picture.missing.slice(0, 2)) {
|
|
873
|
+
const size = `${Math.round(Number(gone.width) || 0)}x${Math.round(Number(gone.height) || 0)}`
|
|
874
|
+
lines.push(`picture ${size}${gone.name === '' ? '' : ` "${gone.name}"`} did not load — the page may need a reload, or the picture is gone`)
|
|
875
|
+
}
|
|
876
|
+
} else if (read.picture) {
|
|
877
|
+
const pic = read.picture
|
|
878
|
+
const look = Number(pic.flat) >= 0.9 ? 'mostly one colour' : Number(pic.colors) <= 3 ? 'a few colours' : 'a picture with detail'
|
|
879
|
+
const size = `${Math.round(Number(pic.width) || 0)}x${Math.round(Number(pic.height) || 0)}`
|
|
880
|
+
lines.push(`picture ${size} ${look}${pic.name === '' ? '' : ` "${pic.name}"`} — sidecar_grab saves it as a file`)
|
|
881
|
+
}
|
|
594
882
|
if (read.scroll && read.scroll.height > read.scroll.viewport + 40) {
|
|
595
883
|
lines.push(`scroll ${read.scroll.y}/${read.scroll.height - read.scroll.viewport}`)
|
|
596
884
|
}
|
|
@@ -634,23 +922,8 @@ export function sameThing(want, control) {
|
|
|
634
922
|
return true
|
|
635
923
|
}
|
|
636
924
|
|
|
637
|
-
/**
|
|
638
|
-
|
|
639
|
-
*
|
|
640
|
-
* `{field: "Email"}` used to mean "click the control that says Email". On a real
|
|
641
|
-
* form the words live in a div above a bare input, so the click landed on the
|
|
642
|
-
* text and the typing went wherever focus already was — the classic silent wrong
|
|
643
|
-
* field. This finds the *editable* element the words point at: a real label, an
|
|
644
|
-
* aria name, a placeholder, a name attribute, the text right before it, or the
|
|
645
|
-
* text of the small wrapper it sits in.
|
|
646
|
-
* @param {string} words - what the field is called.
|
|
647
|
-
* @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
|
|
648
|
-
*/
|
|
649
|
-
export function labelSpot(words) {
|
|
650
|
-
const wanted = JSON.stringify(String(words ?? '').toLowerCase())
|
|
651
|
-
return `(() => {
|
|
652
|
-
const want = ${wanted}
|
|
653
|
-
if (want === '') return JSON.stringify({})
|
|
925
|
+
/** The in-page half shared by `labelSpot` and `selectSpot`: shadow roots, what says what. */
|
|
926
|
+
const NAME_PRELUDE = `
|
|
654
927
|
const roots = [document]
|
|
655
928
|
for (const el of document.querySelectorAll('*')) {
|
|
656
929
|
if (roots.length > 400) break
|
|
@@ -685,34 +958,125 @@ export function labelSpot(words) {
|
|
|
685
958
|
add(el.getAttribute('aria-label'))
|
|
686
959
|
add(el.getAttribute('placeholder'))
|
|
687
960
|
add(el.getAttribute('title'))
|
|
688
|
-
add(el.getAttribute('name'))
|
|
689
961
|
if (el.id) for (const l of document.querySelectorAll('label[for]')) if (l.htmlFor === el.id) add(l.innerText ?? l.textContent)
|
|
690
962
|
const prev = el.previousElementSibling
|
|
691
963
|
if (prev && editable(prev) !== true) add(prev.innerText ?? prev.textContent)
|
|
692
964
|
const parent = el.parentElement
|
|
693
965
|
if (parent && parent.querySelectorAll('input,textarea,select,[contenteditable=true]').length <= 1) add(parent.innerText ?? parent.textContent)
|
|
966
|
+
/* Wrapped a couple of levels deep (a styled box hiding a real input), the
|
|
967
|
+
words sit before the wrapper, not before the input. */
|
|
968
|
+
let up = parent
|
|
969
|
+
for (let depth = 0; depth < 3 && up; depth += 1) {
|
|
970
|
+
const before = up.previousElementSibling
|
|
971
|
+
if (before && editable(before) !== true) add(before.innerText ?? before.textContent)
|
|
972
|
+
up = up.parentElement
|
|
973
|
+
}
|
|
974
|
+
/* The attribute is the last resort for a name on screen: it is often the
|
|
975
|
+
developer's word for the field, not the one the user reads. */
|
|
976
|
+
add(el.getAttribute('name'))
|
|
694
977
|
return out
|
|
695
978
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
979
|
+
const squeeze = (s) => String(s).toLowerCase().replace(/\s+/g, '')
|
|
980
|
+
const scored = (el, want) => {
|
|
981
|
+
let score = 0
|
|
982
|
+
let name = ''
|
|
983
|
+
const flat = squeeze(want)
|
|
984
|
+
for (const nm of names(el)) {
|
|
985
|
+
const low = nm.toLowerCase()
|
|
986
|
+
if (low === want || squeeze(low) === flat) score = Math.max(score, 3)
|
|
987
|
+
else if (want.length >= 2 && low.includes(want)) score = Math.max(score, 2)
|
|
988
|
+
else if (flat.length >= 2 && squeeze(low).includes(flat)) score = Math.max(score, 2)
|
|
989
|
+
else if (low.length >= 3 && want.includes(low)) score = Math.max(score, 1)
|
|
990
|
+
if (score > 0 && name === '') name = nm
|
|
991
|
+
}
|
|
992
|
+
return { score, name }
|
|
993
|
+
}
|
|
994
|
+
const boxOf = (el) => {
|
|
995
|
+
for (let node = el; node; node = node.parentElement) {
|
|
996
|
+
const b = node.getBoundingClientRect()
|
|
997
|
+
if (b.width < 8 || b.height < 8) continue
|
|
998
|
+
const s = getComputedStyle(node)
|
|
999
|
+
if (s.display !== 'none' && s.visibility !== 'hidden' && Number(s.opacity) > 0.05) return b
|
|
1000
|
+
}
|
|
1001
|
+
return null
|
|
1002
|
+
}
|
|
1003
|
+
const bestOf = (selector, want, hiddenOk) => {
|
|
1004
|
+
let best = null
|
|
1005
|
+
for (const root of roots) {
|
|
1006
|
+
for (const el of root.querySelectorAll(selector)) {
|
|
1007
|
+
if (hiddenOk !== true && shown(el) !== true) continue
|
|
1008
|
+
const hit = scored(el, want)
|
|
1009
|
+
if (hit.score === 0) continue
|
|
1010
|
+
if (best === null || hit.score > best.score) best = { el, score: hit.score, name: hit.name }
|
|
708
1011
|
}
|
|
709
|
-
if (score === 0) continue
|
|
710
|
-
if (best === null || score > best.score) best = { el, score, name }
|
|
711
1012
|
}
|
|
1013
|
+
return best
|
|
712
1014
|
}
|
|
713
|
-
|
|
1015
|
+
`
|
|
1016
|
+
|
|
1017
|
+
/**
|
|
1018
|
+
* The field a label belongs to, when nothing formally connects the two.
|
|
1019
|
+
* ...
|
|
1020
|
+
*/
|
|
1021
|
+
/**
|
|
1022
|
+
* The field a label belongs to, when nothing formally connects the two.
|
|
1023
|
+
*
|
|
1024
|
+
* `{field: "Email"}` used to mean "click the control that says Email". On a real
|
|
1025
|
+
* form the words live in a div above a bare input, so the click landed on the
|
|
1026
|
+
* text and the typing went wherever focus already was — the classic silent wrong
|
|
1027
|
+
* field. This finds the *editable* element the words point at: a real label, an
|
|
1028
|
+
* aria name, a placeholder, a name attribute, the text right before it, or the
|
|
1029
|
+
* text of the small wrapper it sits in.
|
|
1030
|
+
* @param {string} words - what the field is called.
|
|
1031
|
+
* @returns {string} an expression that evaluates to a spot, or {} when nothing fits.
|
|
1032
|
+
*/
|
|
1033
|
+
export function labelSpot(words) {
|
|
1034
|
+
const wanted = JSON.stringify(String(words ?? '').toLowerCase())
|
|
1035
|
+
return `(() => {
|
|
1036
|
+
const want = ${wanted}
|
|
1037
|
+
if (want === '') return JSON.stringify({})
|
|
1038
|
+
${NAME_PRELUDE}
|
|
1039
|
+
const selector = 'input,textarea,select,[contenteditable=""],[contenteditable=true],[role=textbox],[role=searchbox],[role=combobox]'
|
|
1040
|
+
let best = bestOf(selector, want)
|
|
1041
|
+
let box = best === null ? null : boxOf(best.el)
|
|
1042
|
+
let behind = false
|
|
1043
|
+
if (best === null || box === null) {
|
|
1044
|
+
/* The field a date picker shows is often a transparent box over a styled
|
|
1045
|
+
div: the input stays out of sight, but the words still name it. */
|
|
1046
|
+
const under = bestOf(selector, want, true)
|
|
1047
|
+
const place = under === null ? null : boxOf(under.el)
|
|
1048
|
+
if (under !== null && place !== null) { best = under; box = place; behind = true }
|
|
1049
|
+
}
|
|
1050
|
+
if (best === null || box === null) return JSON.stringify({})
|
|
1051
|
+
if (editable(best.el) !== true) return JSON.stringify({})
|
|
714
1052
|
best.el.scrollIntoView({ block: 'center', inline: 'nearest' })
|
|
715
|
-
const
|
|
716
|
-
return JSON.stringify({ x: Math.round(
|
|
1053
|
+
const fresh = boxOf(best.el) ?? box
|
|
1054
|
+
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 })
|
|
1055
|
+
})()`
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* The dropdown the words belong to, with the wanted option already chosen.
|
|
1060
|
+
* @param {string} words - what the dropdown is called on screen.
|
|
1061
|
+
* @param {string} value - option label or value.
|
|
1062
|
+
* @returns {string} an expression that picks the option and says what it did.
|
|
1063
|
+
*/
|
|
1064
|
+
export function selectSpot(words, value) {
|
|
1065
|
+
const wanted = JSON.stringify(String(words ?? '').toLowerCase())
|
|
1066
|
+
const wanted2 = JSON.stringify(String(value ?? ''))
|
|
1067
|
+
return `(() => {
|
|
1068
|
+
const want = ${wanted}
|
|
1069
|
+
const wanted = ${wanted2}
|
|
1070
|
+
if (want === '') return 'no dropdown was named'
|
|
1071
|
+
${NAME_PRELUDE}
|
|
1072
|
+
const best = bestOf('select', want)
|
|
1073
|
+
if (best === null) return 'no dropdown says ' + JSON.stringify(want)
|
|
1074
|
+
const select = best.el
|
|
1075
|
+
const option = Array.from(select.options).find((item) => item.value === wanted || item.text.trim() === wanted || item.text.trim().toLowerCase() === wanted.toLowerCase())
|
|
1076
|
+
if (!option) return 'no option ' + wanted + ' in ' + best.name + '; options are ' + Array.from(select.options).slice(0, 12).map((item) => item.text.trim()).join(' | ')
|
|
1077
|
+
select.value = option.value
|
|
1078
|
+
select.dispatchEvent(new Event('input', { bubbles: true }))
|
|
1079
|
+
select.dispatchEvent(new Event('change', { bubbles: true }))
|
|
1080
|
+
return 'chose ' + option.text.trim() + ' in ' + best.name
|
|
717
1081
|
})()`
|
|
718
1082
|
}
|