@mobileaidev/ai-app-bridge-web 0.4.5 → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/src/index.js +134 -26
package/README.md
CHANGED
|
@@ -23,6 +23,14 @@ unchecked. Use the current SDK with the current Host; missing `checked` is an
|
|
|
23
23
|
invalid snapshot. A semantic click must still be followed by a fresh observation
|
|
24
24
|
and an independent business check where persistence matters.
|
|
25
25
|
|
|
26
|
+
Visible controls without text, an ARIA label or a placeholder may also expose
|
|
27
|
+
`context.ancestors`: up to two distinct text samples from their actual DOM
|
|
28
|
+
ancestors, within six parent levels and 160 characters per sample. Collection
|
|
29
|
+
is bounded to 64 nodes per ancestor, excludes form values and hidden branches,
|
|
30
|
+
and stops before BODY/HTML. Truncation flags describe the limits. This fresh
|
|
31
|
+
observation helps an agent distinguish otherwise unlabelled record menus; it
|
|
32
|
+
does not infer a business row or change selectors and action identity.
|
|
33
|
+
|
|
26
34
|
## Capture windows and network bodies
|
|
27
35
|
|
|
28
36
|
Network body capture is explicitly enabled with `captureRequestBodies: true`
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -187,8 +187,11 @@
|
|
|
187
187
|
return;
|
|
188
188
|
}
|
|
189
189
|
if (message.payload?.name !== 'domSnapshot') { reply(message, { ok: false, error: 'unknown_web_read' }); return; }
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
try {
|
|
191
|
+
const dom = observeDom(message.payload.args ?? {});
|
|
192
|
+
reply(message, dom.ok ? { ok: true, dom } : dom);
|
|
193
|
+
} catch (error) { reply(message, { ok: false, error: 'web_dom_query_failed', message: error.message }); }
|
|
194
|
+
return;
|
|
192
195
|
}
|
|
193
196
|
if (message.type === 'cancel') {
|
|
194
197
|
const identity = message.payload;
|
|
@@ -326,7 +329,8 @@
|
|
|
326
329
|
}
|
|
327
330
|
|
|
328
331
|
function documentTarget(doc) {
|
|
329
|
-
if (!documentTargets.has(doc)) documentTargets.set(doc, { navigationId: newIdentity(), available: true,
|
|
332
|
+
if (!documentTargets.has(doc)) documentTargets.set(doc, { navigationId: newIdentity(), available: true,
|
|
333
|
+
elements: new WeakMap(), elementsById: new Map(), nextElement: 0 });
|
|
330
334
|
return documentTargets.get(doc);
|
|
331
335
|
}
|
|
332
336
|
function documentPageRef(binding) {
|
|
@@ -358,6 +362,7 @@
|
|
|
358
362
|
function elementIdentity(element, doc = element.ownerDocument || globalValue('document')) {
|
|
359
363
|
const state = documentTarget(doc);
|
|
360
364
|
if (!state.elements.has(element)) state.elements.set(element, `e${++state.nextElement}`);
|
|
365
|
+
state.elementsById.set(state.elements.get(element), element);
|
|
361
366
|
const sensitive = isSensitiveInput(element);
|
|
362
367
|
return { elementId: state.elements.get(element), tag: text(element.tagName).toLowerCase(), id: text(element.id),
|
|
363
368
|
name: attr(element, 'name'), type: attr(element, 'type'), role: attr(element, 'role'), ariaLabel: attr(element, 'aria-label'),
|
|
@@ -408,26 +413,86 @@
|
|
|
408
413
|
role: attr(hit, 'role'), className: typeof hit.className === 'string' ? trimText(hit.className, 300) : '' } : null };
|
|
409
414
|
}
|
|
410
415
|
|
|
416
|
+
function ancestorContext(element, identity, cache) {
|
|
417
|
+
if ([identity.text, identity.ariaLabel, identity.placeholder].some(value => value.trim())) return null;
|
|
418
|
+
const ancestors = [], seen = new Set();
|
|
419
|
+
let parent = element.parentElement, depth = 1;
|
|
420
|
+
while (parent && !['BODY', 'HTML'].includes(parent.tagName) && depth <= 6 && ancestors.length < 2) {
|
|
421
|
+
const sample = ancestorText(parent, cache);
|
|
422
|
+
if (sample.text && !seen.has(sample.text)) {
|
|
423
|
+
seen.add(sample.text);
|
|
424
|
+
const id = text(parent.id), role = attr(parent, 'role');
|
|
425
|
+
ancestors.push({ depth, tag: text(parent.tagName).toLowerCase(), id: id.slice(0, 80), role: role.slice(0, 40),
|
|
426
|
+
text: sample.text, textTruncated: sample.truncated, identityTruncated: id.length > 80 || role.length > 40 });
|
|
427
|
+
}
|
|
428
|
+
parent = parent.parentElement; depth++;
|
|
429
|
+
}
|
|
430
|
+
// DOM context describes actual ancestors, not a guessed business row or a visibility guarantee.
|
|
431
|
+
return ancestors.length ? { source: 'ancestor-dom-text', ancestors,
|
|
432
|
+
truncated: ancestors.some(item => item.textTruncated || item.identityTruncated)
|
|
433
|
+
|| Boolean(parent && !['BODY', 'HTML'].includes(parent.tagName)) } : null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function ancestorText(root, cache) {
|
|
437
|
+
if (cache.has(root)) return cache.get(root);
|
|
438
|
+
let node = root, visited = 0, value = '', truncated = false;
|
|
439
|
+
while (node && visited < 64) {
|
|
440
|
+
visited++;
|
|
441
|
+
let descend = true;
|
|
442
|
+
if (node.nodeType === 1) {
|
|
443
|
+
const style = node.ownerDocument?.defaultView?.getComputedStyle(node);
|
|
444
|
+
descend = !['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT', 'INPUT', 'TEXTAREA', 'SELECT'].includes(node.tagName)
|
|
445
|
+
&& !isSensitiveInput(node) && !node.hidden && attr(node, 'aria-hidden') !== 'true'
|
|
446
|
+
&& style?.display !== 'none' && !['hidden', 'collapse'].includes(style?.visibility) && style?.opacity !== '0';
|
|
447
|
+
} else if (node.nodeType === 3) {
|
|
448
|
+
const raw = text(node.nodeValue);
|
|
449
|
+
// Normalize a bounded raw sample so indentation does not consume the readable-text budget.
|
|
450
|
+
const part = raw.slice(0, 1024).replace(/\s+/g, ' ').trim();
|
|
451
|
+
const next = value + (value && part ? ' ' : '') + part;
|
|
452
|
+
value = next.slice(0, 160);
|
|
453
|
+
if (raw.length > 1024 || next.length > 160) { truncated = true; break; }
|
|
454
|
+
}
|
|
455
|
+
if (descend && node.firstChild) node = node.firstChild;
|
|
456
|
+
else {
|
|
457
|
+
while (node !== root && !node.nextSibling) node = node.parentNode;
|
|
458
|
+
node = node === root ? null : node.nextSibling;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const result = { text: value, truncated: truncated || Boolean(node) };
|
|
462
|
+
cache.set(root, result);
|
|
463
|
+
return result;
|
|
464
|
+
}
|
|
465
|
+
|
|
411
466
|
function snapshotDom(options = {}) {
|
|
412
467
|
const doc = options.document || globalValue('document');
|
|
413
468
|
if (!doc || !doc.querySelectorAll) {
|
|
414
469
|
return { ok: false, error: 'document_unavailable' };
|
|
415
470
|
}
|
|
416
471
|
const selector = options.selector || defaultControlSelector;
|
|
472
|
+
if (options.query && options.selector) return { ok: false, error: 'invalid_web_dom_query' };
|
|
473
|
+
const state = documentTarget(doc);
|
|
474
|
+
for (const [id, element] of state.elementsById) {
|
|
475
|
+
if (!element.isConnected || element.ownerDocument !== doc) state.elementsById.delete(id);
|
|
476
|
+
}
|
|
417
477
|
const maxControls = Number(options.maxControls || 200);
|
|
478
|
+
const offset = options.offset ?? 0;
|
|
418
479
|
const controls = [];
|
|
419
|
-
const
|
|
420
|
-
const
|
|
480
|
+
const contextCache = new WeakMap();
|
|
481
|
+
const matched = options.query ? matchingElements(options.query, doc) : doc.querySelectorAll(selector);
|
|
482
|
+
const elements = Array.prototype.slice.call(matched, offset, offset + maxControls);
|
|
421
483
|
elements.forEach((element, index) => {
|
|
422
484
|
const sensitive = isSensitiveInput(element);
|
|
423
485
|
const elementValue = text(element.value);
|
|
486
|
+
const identity = elementIdentity(element, doc), visible = elementVisible(element);
|
|
487
|
+
const context = visible ? ancestorContext(element, identity, contextCache) : null;
|
|
424
488
|
controls.push({
|
|
425
|
-
...
|
|
426
|
-
|
|
489
|
+
...identity,
|
|
490
|
+
...(context ? { context } : {}),
|
|
491
|
+
index: offset + index,
|
|
427
492
|
sensitive,
|
|
428
493
|
...(sensitive ? { valueLength: elementValue.length } : {}),
|
|
429
494
|
disabled: elementDisabled(element),
|
|
430
|
-
visible
|
|
495
|
+
visible,
|
|
431
496
|
editable: elementEditable(element),
|
|
432
497
|
checked: elementChecked(element),
|
|
433
498
|
ariaChecked: element.getAttribute('aria-checked'),
|
|
@@ -445,7 +510,10 @@
|
|
|
445
510
|
bodyTextTruncated: text(doc.body && doc.body.innerText).length > 20000,
|
|
446
511
|
controls,
|
|
447
512
|
controlCount: controls.length,
|
|
448
|
-
|
|
513
|
+
totalControls: matched.length, offset,
|
|
514
|
+
nextOffset: offset + controls.length < matched.length ? offset + controls.length : null,
|
|
515
|
+
...(options.query ? { query: options.query } : {}),
|
|
516
|
+
truncated: offset > 0 || matched.length > controls.length,
|
|
449
517
|
updatedAtMs: Date.now(),
|
|
450
518
|
};
|
|
451
519
|
}
|
|
@@ -453,7 +521,7 @@
|
|
|
453
521
|
function clickElement(args, task) {
|
|
454
522
|
const selected = actionTarget(args, task);
|
|
455
523
|
ensureTarget(selected, task, { hit: true }); task.permit(); selected.element.click();
|
|
456
|
-
return { ok: true, resolved: selected.ref };
|
|
524
|
+
return { ok: true, resolved: selected.ref, ...targetWarnings(selected) };
|
|
457
525
|
}
|
|
458
526
|
|
|
459
527
|
function inputElement(args, task) {
|
|
@@ -479,11 +547,31 @@
|
|
|
479
547
|
element.dispatchEvent(new win.InputEvent('input', { bubbles: true, inputType: 'insertReplacementText', data: value }));
|
|
480
548
|
ensureTarget(selected, task, { focus: true, edited: true }); task.permit(); element.dispatchEvent(new win.Event('change', { bubbles: true }));
|
|
481
549
|
}
|
|
482
|
-
|
|
550
|
+
// The final input/change dispatch may collapse, disable or remove its own
|
|
551
|
+
// editor. Report what happened to that original object; never reselect it
|
|
552
|
+
// or treat its resulting layout as a precondition for the completed input.
|
|
553
|
+
const targetState = { samePage: samePage(task.pageRef(), selected.ref.pageRef),
|
|
554
|
+
sameDocument: element.ownerDocument === globalValue('document'), connected: element.isConnected,
|
|
555
|
+
visible: elementVisible(element), disabled: elementDisabled(element), editable: elementEditable(element),
|
|
556
|
+
focused: doc.activeElement === element, bounds: bounds(element) };
|
|
557
|
+
if (!targetState.samePage || !targetState.sameDocument || !targetState.connected) {
|
|
558
|
+
return { ok: false, error: targetState.samePage ? 'web_element_changed' : 'reobserve_required',
|
|
559
|
+
resolved: selected.ref, inputDispatched: true, valueState: 'unconfirmed', targetState,
|
|
560
|
+
message: 'Input was dispatched, but its original element is no longer in the current page. Reobserve before continuing.' };
|
|
561
|
+
}
|
|
562
|
+
recordTargetChanges(selected, elementIdentity(element), true);
|
|
563
|
+
const warnings = targetWarnings(selected).warnings ?? [];
|
|
564
|
+
if (!targetState.visible || targetState.disabled || !targetState.editable || !targetState.focused)
|
|
565
|
+
warnings.push({ code: 'target_state_changed_after_input',
|
|
566
|
+
message: 'Input was dispatched; the original element state changed afterwards. Inspect targetState before another action.' });
|
|
483
567
|
const sensitive = isSensitiveInput(element);
|
|
484
568
|
return {
|
|
485
569
|
ok: true,
|
|
486
570
|
resolved: selected.ref,
|
|
571
|
+
inputDispatched: true,
|
|
572
|
+
valueState: 'observed',
|
|
573
|
+
targetState,
|
|
574
|
+
...(warnings.length ? { warnings } : {}),
|
|
487
575
|
sensitive,
|
|
488
576
|
valueLength: (richText ? element.innerText : element.value).length,
|
|
489
577
|
...(sensitive ? {} : { value: richText ? element.innerText : element.value }),
|
|
@@ -499,7 +587,7 @@
|
|
|
499
587
|
key: args.key, code: args.key, bubbles: true, cancelable: true, composed: true,
|
|
500
588
|
});
|
|
501
589
|
task.permit(); element.dispatchEvent(event);
|
|
502
|
-
return { ok: true, resolved: selected.ref, key: args.key, eventType: 'keydown',
|
|
590
|
+
return { ok: true, resolved: selected.ref, ...targetWarnings(selected), key: args.key, eventType: 'keydown',
|
|
503
591
|
trusted: event.isTrusted, defaultPrevented: event.defaultPrevented };
|
|
504
592
|
}
|
|
505
593
|
|
|
@@ -539,12 +627,12 @@
|
|
|
539
627
|
if (args.mode === 'into-view') {
|
|
540
628
|
if (!selected) return { ok: false, error: 'web_selector_required' };
|
|
541
629
|
ensureTarget(selected, task); task.permit(); selected.element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
|
|
542
|
-
return { ok: true, resolved: selected.ref };
|
|
630
|
+
return { ok: true, resolved: selected.ref, ...targetWarnings(selected) };
|
|
543
631
|
}
|
|
544
632
|
if (args.mode !== 'by') return { ok: false, error: 'invalid_web_scroll_mode' };
|
|
545
633
|
if (selected) {
|
|
546
634
|
ensureTarget(selected, task); task.permit(); selected.element.scrollBy({ left: deltaX, top: deltaY, behavior: 'instant' });
|
|
547
|
-
return { ok: true, resolved: selected.ref };
|
|
635
|
+
return { ok: true, resolved: selected.ref, ...targetWarnings(selected) };
|
|
548
636
|
}
|
|
549
637
|
const win = globalValue('window');
|
|
550
638
|
if (win && win.scrollBy) {
|
|
@@ -554,20 +642,28 @@
|
|
|
554
642
|
return { ok: false, error: 'scroll_unavailable' };
|
|
555
643
|
}
|
|
556
644
|
|
|
557
|
-
function
|
|
558
|
-
const doc = globalValue('document');
|
|
559
|
-
if (!doc) return null;
|
|
645
|
+
function matchingElements(selector, doc) {
|
|
560
646
|
if (!selector || typeof selector !== 'object') throw new Error('invalid_web_selector');
|
|
561
647
|
const identities = ['elementId', 'text', 'ariaLabel', 'css'].filter(key => typeof selector[key] === 'string' && selector[key].length > 0);
|
|
562
|
-
if (identities.length
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
if (!elementVisible(element)) return false;
|
|
648
|
+
if (!identities.length) throw new Error('invalid_web_selector');
|
|
649
|
+
const known = selector.elementId === undefined ? null : documentTarget(doc).elementsById.get(selector.elementId);
|
|
650
|
+
const elements = selector.elementId === undefined ? doc.querySelectorAll(selector.css ?? defaultControlSelector) : known ? [known] : [];
|
|
651
|
+
return Array.from(elements).filter(element => {
|
|
652
|
+
if (element.ownerDocument !== doc || !elementVisible(element) || selector.elementId !== undefined && selector.css !== undefined && !element.matches(selector.css)) return false;
|
|
567
653
|
const ref = elementIdentity(element);
|
|
568
654
|
return ['elementId', 'text', 'ariaLabel', 'tag', 'role'].every(key => selector[key] === undefined || selector[key] === ref[key]);
|
|
569
655
|
});
|
|
570
|
-
|
|
656
|
+
}
|
|
657
|
+
function findElement(selector) {
|
|
658
|
+
const doc = globalValue('document');
|
|
659
|
+
if (!doc) return null;
|
|
660
|
+
const matches = matchingElements(selector, doc);
|
|
661
|
+
if (matches.length > 1) throw Object.assign(new Error('web_element_ambiguous'), { details: {
|
|
662
|
+
selectionRequired: true, matched: matches.length, candidatesTruncated: matches.length > 20,
|
|
663
|
+
...(matches.length > 20 ? { candidateQuery: { query: selector, offset: 20, maxControls: 200 } } : {}),
|
|
664
|
+
candidates: matches.slice(0, 20).map(element => { const ref = elementIdentity(element); return { text: ref.text, tag: ref.tag,
|
|
665
|
+
role: ref.role, ariaLabel: ref.ariaLabel, disabled: elementDisabled(element), bounds: bounds(element), selector: { elementId: ref.elementId } }; }),
|
|
666
|
+
} });
|
|
571
667
|
return matches[0] ?? null;
|
|
572
668
|
}
|
|
573
669
|
|
|
@@ -576,8 +672,19 @@
|
|
|
576
672
|
if (!element) throw new Error('web_element_not_found');
|
|
577
673
|
const ref = { pageRef: task.pageRef(), element: elementIdentity(element) };
|
|
578
674
|
if (args.expectedTarget && (!samePage(ref.pageRef, args.expectedTarget.pageRef)
|
|
579
|
-
||
|
|
580
|
-
|
|
675
|
+
|| ref.element.elementId !== args.expectedTarget.element.elementId)) throw new Error('reobserve_required');
|
|
676
|
+
const selected = { element, ref, observed: args.expectedTarget?.element ?? ref.element, changedFields: new Set() };
|
|
677
|
+
recordTargetChanges(selected, ref.element);
|
|
678
|
+
return selected;
|
|
679
|
+
}
|
|
680
|
+
function recordTargetChanges(selected, current, edited = false) {
|
|
681
|
+
for (const key of elementKeys) {
|
|
682
|
+
if ((!edited || key !== 'text') && current[key] !== selected.observed[key]) selected.changedFields.add(key);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
function targetWarnings(selected) {
|
|
686
|
+
return selected.changedFields.size ? { warnings: [{ code: 'target_attributes_changed', fields: [...selected.changedFields],
|
|
687
|
+
message: 'The observed DOM element is still present; its attributes changed.' }] } : {};
|
|
581
688
|
}
|
|
582
689
|
function samePage(a, b) {
|
|
583
690
|
return ['schemaVersion', 'sessionId', 'runtimeEpoch', 'targetId', 'navigationId', 'url'].every(key => a[key] === b?.[key]);
|
|
@@ -590,7 +697,8 @@
|
|
|
590
697
|
if (!element.isConnected || element.ownerDocument !== doc || elementDisabled(element) || !elementVisible(element)
|
|
591
698
|
|| (focus && doc.activeElement !== element)) throw new Error('web_element_changed');
|
|
592
699
|
const current = elementIdentity(element);
|
|
593
|
-
if (
|
|
700
|
+
if (current.elementId !== ref.element.elementId) throw new Error('reobserve_required');
|
|
701
|
+
recordTargetChanges(selected, current, edited);
|
|
594
702
|
if (hit) {
|
|
595
703
|
const interaction = interactionState(element);
|
|
596
704
|
if (interaction.status !== 'ready') throw Object.assign(new Error(interaction.status === 'outside-viewport'
|