@restregular/dsh-visual-browser 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,528 @@
1
+ /**
2
+ * In-page scripts.
3
+ *
4
+ * Every function in this module is serialized with `Function.prototype.toString`
5
+ * and evaluated inside the page, so each one must be completely self-contained:
6
+ * no imports, no module-scope identifiers, no closures.
7
+ *
8
+ * The snapshot is the heart of the plugin. It returns a numbered list of the
9
+ * interactive elements that are actually visible in the viewport plus a short
10
+ * text excerpt — never the DOM, never the accessibility dump. That keeps an
11
+ * agent step cheap while still being enough to decide the next action.
12
+ */
13
+
14
+ /** Expression that reads the compact view. */
15
+ export function snapshotExpression(options = {}) {
16
+ return `(${pageSnapshot.toString()})(${JSON.stringify(options)})`;
17
+ }
18
+
19
+ /** Expression that resolves a snapshot ref (or CSS selector) to a click point. */
20
+ export function resolveExpression(target = {}) {
21
+ return `(${pageResolve.toString()})(${JSON.stringify(target)})`;
22
+ }
23
+
24
+ /** Expression that focuses an element and prepares a caret for typing. */
25
+ export function prepareTypingExpression(target = {}) {
26
+ return `(${pagePrepareTyping.toString()})(${JSON.stringify(target)})`;
27
+ }
28
+
29
+ /** Expression that picks a native <select> option. */
30
+ export function selectOptionExpression(target = {}) {
31
+ return `(${pageSelectOption.toString()})(${JSON.stringify(target)})`;
32
+ }
33
+
34
+ /** Expression that reads text (element or page). */
35
+ export function readTextExpression(options = {}) {
36
+ return `(${pageReadText.toString()})(${JSON.stringify(options)})`;
37
+ }
38
+
39
+ /** Expression that reads the current page facts without a full snapshot. */
40
+ export function pageFactsExpression() {
41
+ return `(${pageFacts.toString()})()`;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Page-side implementations (serialized; keep self-contained)
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function pageSnapshot(options) {
49
+ const maxElements = (options && options.maxElements) || 100;
50
+ const maxNameChars = (options && options.maxNameChars) || 64;
51
+ const maxTextChars = (options && options.maxTextChars) || 320;
52
+
53
+ const clean = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
54
+ const clip = (value, max) => {
55
+ const text = clean(value);
56
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
57
+ };
58
+
59
+ const INTERACTIVE = new Set([
60
+ 'button', 'link', 'textbox', 'searchbox', 'combobox', 'checkbox', 'radio',
61
+ 'switch', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option',
62
+ 'slider', 'spinbutton', 'listbox', 'treeitem', 'gridcell', 'file',
63
+ ]);
64
+
65
+ const roleOf = (el) => {
66
+ const explicit = clean(el.getAttribute('role')).toLowerCase();
67
+ if (explicit) return INTERACTIVE.has(explicit) ? explicit : null;
68
+ const tag = el.tagName.toLowerCase();
69
+ if (tag === 'a') return el.hasAttribute('href') ? 'link' : null;
70
+ if (tag === 'button') return 'button';
71
+ if (tag === 'select') return 'combobox';
72
+ if (tag === 'textarea') return 'textbox';
73
+ if (tag === 'summary') return 'button';
74
+ if (tag === 'input') {
75
+ const type = clean(el.type || 'text').toLowerCase();
76
+ if (type === 'hidden') return null;
77
+ if (type === 'button' || type === 'submit' || type === 'reset' || type === 'image') return 'button';
78
+ if (type === 'checkbox') return 'checkbox';
79
+ if (type === 'radio') return 'radio';
80
+ if (type === 'range') return 'slider';
81
+ if (type === 'number') return 'spinbutton';
82
+ if (type === 'search') return 'searchbox';
83
+ if (type === 'file') return 'file';
84
+ return 'textbox';
85
+ }
86
+ if (el.isContentEditable) return 'textbox';
87
+ return null;
88
+ };
89
+
90
+ const viewportWidth = window.innerWidth || 0;
91
+ const viewportHeight = window.innerHeight || 0;
92
+
93
+ const isVisible = (el) => {
94
+ try {
95
+ const rect = el.getBoundingClientRect();
96
+ if (rect.width < 2 || rect.height < 2) return false;
97
+ if (rect.bottom < -4 || rect.top > viewportHeight + 4 || rect.right < -4 || rect.left > viewportWidth + 4) return false;
98
+ const style = window.getComputedStyle(el);
99
+ if (!style || style.visibility === 'hidden' || style.display === 'none') return false;
100
+ if (Number.parseFloat(style.opacity) === 0) return false;
101
+ if (typeof el.checkVisibility === 'function' && !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true })) return false;
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ };
107
+
108
+ const labelText = (el) => {
109
+ try {
110
+ if (el.labels && el.labels.length > 0) {
111
+ const joined = clean(Array.from(el.labels).map((label) => label.innerText || label.textContent || '').join(' '));
112
+ if (joined) return joined;
113
+ }
114
+ } catch {
115
+ /* labels are a convenience, never a requirement */
116
+ }
117
+ return '';
118
+ };
119
+
120
+ const nameOf = (el, role) => {
121
+ const aria = clean(el.getAttribute('aria-label'));
122
+ if (aria) return aria;
123
+ const labelledBy = el.getAttribute('aria-labelledby');
124
+ if (labelledBy) {
125
+ const joined = clean(
126
+ labelledBy
127
+ .split(/\s+/)
128
+ .map((id) => {
129
+ const node = document.getElementById(id);
130
+ return node ? node.innerText || node.textContent || '' : '';
131
+ })
132
+ .join(' '),
133
+ );
134
+ if (joined) return joined;
135
+ }
136
+ const label = labelText(el);
137
+ if (label) return label;
138
+ const tag = el.tagName.toLowerCase();
139
+ if (tag === 'input') {
140
+ const type = clean(el.type).toLowerCase();
141
+ if (type === 'button' || type === 'submit' || type === 'reset') {
142
+ const value = clean(el.value);
143
+ if (value) return value;
144
+ }
145
+ }
146
+ const text = clean(el.innerText || el.textContent);
147
+ if (text) return text;
148
+ const placeholder = clean(el.getAttribute('placeholder'));
149
+ if (placeholder) return placeholder;
150
+ const alt = clean(el.getAttribute('alt'));
151
+ if (alt) return alt;
152
+ const title = clean(el.getAttribute('title'));
153
+ if (title) return title;
154
+ return clean(el.getAttribute('name'));
155
+ };
156
+
157
+ const valueOf = (el, role) => {
158
+ const tag = el.tagName.toLowerCase();
159
+ try {
160
+ if (tag === 'select') {
161
+ const option = el.selectedOptions && el.selectedOptions[0];
162
+ return option ? clean(option.textContent) : '';
163
+ }
164
+ if (role === 'checkbox' || role === 'radio' || role === 'switch') return '';
165
+ if (tag === 'input' || tag === 'textarea') {
166
+ if (clean(el.type).toLowerCase() === 'password') return el.value ? '••••••' : '';
167
+ return clean(el.value);
168
+ }
169
+ } catch {
170
+ return '';
171
+ }
172
+ return '';
173
+ };
174
+
175
+ const flagsOf = (el, role) => {
176
+ const flags = [];
177
+ try {
178
+ if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') flags.push('disabled');
179
+ if (role === 'checkbox' || role === 'radio' || role === 'switch') {
180
+ if (el.checked === true || el.getAttribute('aria-checked') === 'true') flags.push('checked');
181
+ }
182
+ if (el.required === true) flags.push('required');
183
+ if (el.readOnly === true) flags.push('readonly');
184
+ if (el.getAttribute('aria-expanded') === 'true') flags.push('expanded');
185
+ } catch {
186
+ /* states are best effort */
187
+ }
188
+ return flags;
189
+ };
190
+
191
+ // Clear refs from the previous snapshot so a stale ref can never resolve to
192
+ // a different element than the one the model just saw.
193
+ try {
194
+ for (const marked of document.querySelectorAll('[data-dsh-vb-ref]')) marked.removeAttribute('data-dsh-vb-ref');
195
+ } catch {
196
+ /* nothing to clear */
197
+ }
198
+
199
+ const selector = [
200
+ 'a[href]',
201
+ 'button',
202
+ 'input',
203
+ 'select',
204
+ 'textarea',
205
+ 'summary',
206
+ '[role]',
207
+ '[contenteditable=""]',
208
+ '[contenteditable="true"]',
209
+ '[tabindex]:not([tabindex="-1"])',
210
+ ].join(',');
211
+
212
+ const elements = [];
213
+ let total = 0;
214
+ let nodeList;
215
+ try {
216
+ nodeList = document.querySelectorAll(selector);
217
+ } catch {
218
+ nodeList = [];
219
+ }
220
+ for (const el of nodeList) {
221
+ const role = roleOf(el);
222
+ if (role === null) continue;
223
+ if (!isVisible(el)) continue;
224
+ total += 1;
225
+ if (elements.length >= maxElements) continue;
226
+ let rect;
227
+ try {
228
+ rect = el.getBoundingClientRect();
229
+ } catch {
230
+ continue;
231
+ }
232
+ if (!rect) continue;
233
+ const ref = elements.length + 1;
234
+ try {
235
+ el.setAttribute('data-dsh-vb-ref', String(ref));
236
+ } catch {
237
+ /* attribute writes on exotic nodes may fail */
238
+ }
239
+ const entry = {
240
+ ref,
241
+ role,
242
+ name: clip(nameOf(el, role), maxNameChars),
243
+ x: Math.round(rect.left + rect.width / 2),
244
+ y: Math.round(rect.top + rect.height / 2),
245
+ };
246
+ const value = valueOf(el, role);
247
+ if (value) entry.value = clip(value, maxNameChars);
248
+ const flags = flagsOf(el, role);
249
+ if (flags.length > 0) entry.state = flags;
250
+ if (role === 'link') {
251
+ const href = clean(el.getAttribute('href'));
252
+ if (href) entry.href = clip(href, 120);
253
+ }
254
+ elements.push(entry);
255
+ }
256
+
257
+ const headings = [];
258
+ try {
259
+ for (const heading of document.querySelectorAll('h1,h2,h3')) {
260
+ if (headings.length >= 8) break;
261
+ const text = clean(heading.innerText || heading.textContent);
262
+ if (text) headings.push(clip(text, 60));
263
+ }
264
+ } catch {
265
+ /* headings are optional */
266
+ }
267
+
268
+ let text = '';
269
+ try {
270
+ text = clip(document.body ? document.body.innerText : document.documentElement.innerText, maxTextChars);
271
+ } catch {
272
+ text = '';
273
+ }
274
+
275
+ let active = null;
276
+ try {
277
+ const el = document.activeElement;
278
+ if (el && el !== document.body && el !== document.documentElement) {
279
+ const role = roleOf(el);
280
+ active = {
281
+ ref: Number(el.getAttribute('data-dsh-vb-ref')) || 0,
282
+ role: role || el.tagName.toLowerCase(),
283
+ name: clip(nameOf(el, role || ''), maxNameChars),
284
+ };
285
+ }
286
+ } catch {
287
+ active = null;
288
+ }
289
+
290
+ let docHeight = 0;
291
+ try {
292
+ docHeight = Math.max(
293
+ document.body ? document.body.scrollHeight : 0,
294
+ document.documentElement ? document.documentElement.scrollHeight : 0,
295
+ );
296
+ } catch {
297
+ docHeight = 0;
298
+ }
299
+
300
+ let frames = 0;
301
+ try {
302
+ frames = document.querySelectorAll('iframe').length;
303
+ } catch {
304
+ frames = 0;
305
+ }
306
+
307
+ return {
308
+ url: location.href,
309
+ title: clean(document.title),
310
+ viewport: { width: viewportWidth, height: viewportHeight },
311
+ scrollY: Math.round(window.scrollY || 0),
312
+ docHeight: Math.round(docHeight),
313
+ readyState: document.readyState,
314
+ elements,
315
+ total,
316
+ truncated: total > elements.length,
317
+ headings,
318
+ text,
319
+ active,
320
+ frames,
321
+ };
322
+ }
323
+
324
+ function pageResolve(target) {
325
+ const clean = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
326
+ let el = null;
327
+ let reason = 'not-found';
328
+ try {
329
+ if (target && target.ref) {
330
+ el = document.querySelector(`[data-dsh-vb-ref="${Number(target.ref)}"]`);
331
+ if (!el) reason = 'stale-ref';
332
+ } else if (target && target.selector) {
333
+ el = document.querySelector(String(target.selector));
334
+ }
335
+ } catch (error) {
336
+ return { ok: false, reason: 'bad-selector', detail: String(error && error.message ? error.message : error) };
337
+ }
338
+ if (!el) return { ok: false, reason };
339
+ try {
340
+ el.scrollIntoView({ block: 'center', inline: 'center' });
341
+ } catch {
342
+ /* scrolling is best effort */
343
+ }
344
+ let rect;
345
+ try {
346
+ rect = el.getBoundingClientRect();
347
+ } catch {
348
+ return { ok: false, reason: 'detached' };
349
+ }
350
+ const style = window.getComputedStyle(el);
351
+ const hidden =
352
+ !rect ||
353
+ rect.width < 1 ||
354
+ rect.height < 1 ||
355
+ (style && (style.visibility === 'hidden' || style.display === 'none'));
356
+ if (hidden) {
357
+ return {
358
+ ok: false,
359
+ reason: 'not-visible',
360
+ name: clean(el.innerText || el.getAttribute('aria-label') || el.getAttribute('placeholder') || ''),
361
+ };
362
+ }
363
+ return {
364
+ ok: true,
365
+ x: Math.round(rect.left + rect.width / 2),
366
+ y: Math.round(rect.top + rect.height / 2),
367
+ tag: el.tagName.toLowerCase(),
368
+ type: clean(el.getAttribute('type')).toLowerCase(),
369
+ name: clean(
370
+ el.getAttribute('aria-label') ||
371
+ el.innerText ||
372
+ el.getAttribute('placeholder') ||
373
+ el.getAttribute('title') ||
374
+ el.value ||
375
+ '',
376
+ ).slice(0, 80),
377
+ href: el.tagName.toLowerCase() === 'a' ? clean(el.getAttribute('href')).slice(0, 160) : '',
378
+ rect: { x: Math.round(rect.left), y: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) },
379
+ };
380
+ }
381
+
382
+ function pagePrepareTyping(target) {
383
+ const clean = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
384
+ let el = null;
385
+ try {
386
+ if (target && target.ref) {
387
+ el = document.querySelector(`[data-dsh-vb-ref="${Number(target.ref)}"]`);
388
+ } else if (target && target.selector) {
389
+ el = document.querySelector(String(target.selector));
390
+ }
391
+ } catch (error) {
392
+ return { ok: false, reason: 'bad-selector', detail: String(error && error.message ? error.message : error) };
393
+ }
394
+ if (!el) return { ok: false, reason: target && target.ref ? 'stale-ref' : 'not-found' };
395
+ try {
396
+ el.scrollIntoView({ block: 'center', inline: 'center' });
397
+ } catch {
398
+ /* best effort */
399
+ }
400
+ let readonly = false;
401
+ let disabled = false;
402
+ try {
403
+ readonly = el.readOnly === true || el.getAttribute('aria-readonly') === 'true';
404
+ disabled = el.disabled === true || el.getAttribute('aria-disabled') === 'true';
405
+ } catch {
406
+ /* ignore */
407
+ }
408
+ if (disabled) return { ok: false, reason: 'disabled' };
409
+ if (readonly) return { ok: false, reason: 'readonly' };
410
+ try {
411
+ el.focus({ preventScroll: true });
412
+ } catch {
413
+ try {
414
+ el.focus();
415
+ } catch {
416
+ /* focus refused */
417
+ }
418
+ }
419
+ const mode = (target && target.mode) || 'replace';
420
+ try {
421
+ if (typeof el.setSelectionRange === 'function' && typeof el.value === 'string') {
422
+ const start = mode === 'append' ? el.value.length : 0;
423
+ const end = mode === 'append' ? el.value.length : el.value.length;
424
+ el.setSelectionRange(start, end);
425
+ } else if (mode === 'replace' && el.isContentEditable) {
426
+ const range = document.createRange();
427
+ range.selectNodeContents(el);
428
+ const selection = window.getSelection();
429
+ selection.removeAllRanges();
430
+ selection.addRange(range);
431
+ }
432
+ } catch {
433
+ /* caret positioning is best effort */
434
+ }
435
+ return {
436
+ ok: true,
437
+ tag: el.tagName.toLowerCase(),
438
+ type: clean(el.getAttribute('type')).toLowerCase(),
439
+ name: clean(el.getAttribute('aria-label') || el.getAttribute('placeholder') || '').slice(0, 80),
440
+ valueBefore: typeof el.value === 'string' ? clean(el.value).slice(0, 120) : '',
441
+ focused: document.activeElement === el,
442
+ };
443
+ }
444
+
445
+ function pageSelectOption(target) {
446
+ let el = null;
447
+ try {
448
+ if (target && target.ref) el = document.querySelector(`[data-dsh-vb-ref="${Number(target.ref)}"]`);
449
+ else if (target && target.selector) el = document.querySelector(String(target.selector));
450
+ } catch (error) {
451
+ return { ok: false, reason: 'bad-selector' };
452
+ }
453
+ if (!el) return { ok: false, reason: 'not-found' };
454
+ if (el.tagName.toLowerCase() !== 'select') return { ok: false, reason: 'not-a-select', tag: el.tagName.toLowerCase() };
455
+ const wanted = String(target && target.value != null ? target.value : '').trim();
456
+ const options = Array.from(el.options || []).map((option) => ({
457
+ value: option.value,
458
+ text: String(option.textContent || '').replace(/\s+/g, ' ').trim(),
459
+ }));
460
+ const match =
461
+ options.find((option) => option.value === wanted) ||
462
+ options.find((option) => option.text === wanted) ||
463
+ options.find((option) => option.text.toLowerCase().includes(wanted.toLowerCase()) && wanted.length > 0);
464
+ if (!match) return { ok: false, reason: 'option-not-found', options: options.slice(0, 20) };
465
+ el.value = match.value;
466
+ el.dispatchEvent(new Event('input', { bubbles: true }));
467
+ el.dispatchEvent(new Event('change', { bubbles: true }));
468
+ return { ok: true, value: match.value, text: match.text };
469
+ }
470
+
471
+ function pageReadText(options) {
472
+ const clean = (value) => String(value == null ? '' : value).replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
473
+ const maxChars = (options && options.maxChars) || 4000;
474
+ let el = null;
475
+ try {
476
+ if (options && options.ref) el = document.querySelector(`[data-dsh-vb-ref="${Number(options.ref)}"]`);
477
+ else if (options && options.selector) el = document.querySelector(String(options.selector));
478
+ } catch (error) {
479
+ return { ok: false, reason: 'bad-selector' };
480
+ }
481
+ if (options && (options.ref || options.selector) && !el) {
482
+ return { ok: false, reason: options.ref ? 'stale-ref' : 'not-found' };
483
+ }
484
+ let raw = '';
485
+ let kind = 'page';
486
+ if (el) {
487
+ kind = 'element';
488
+ raw = el.innerText || el.textContent || '';
489
+ } else {
490
+ raw = document.body ? document.body.innerText : document.documentElement.innerText || '';
491
+ }
492
+ const text = clean(raw);
493
+ const facts = {
494
+ url: location.href,
495
+ title: clean(document.title),
496
+ };
497
+ return {
498
+ ok: true,
499
+ kind,
500
+ ...facts,
501
+ length: text.length,
502
+ truncated: text.length > maxChars,
503
+ text: text.slice(0, maxChars),
504
+ href: el && el.tagName && el.tagName.toLowerCase() === 'a' ? clean(el.getAttribute('href')) : '',
505
+ value: el && typeof el.value === 'string' ? clean(el.value).slice(0, 500) : '',
506
+ };
507
+ }
508
+
509
+ function pageFacts() {
510
+ const clean = (value) => String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
511
+ let docHeight = 0;
512
+ try {
513
+ docHeight = Math.max(
514
+ document.body ? document.body.scrollHeight : 0,
515
+ document.documentElement ? document.documentElement.scrollHeight : 0,
516
+ );
517
+ } catch {
518
+ docHeight = 0;
519
+ }
520
+ return {
521
+ url: location.href,
522
+ title: clean(document.title),
523
+ readyState: document.readyState,
524
+ scrollY: Math.round(window.scrollY || 0),
525
+ docHeight,
526
+ viewport: { width: window.innerWidth || 0, height: window.innerHeight || 0 },
527
+ };
528
+ }