@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.
Files changed (3) hide show
  1. package/README.md +8 -0
  2. package/package.json +1 -1
  3. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobileaidev/ai-app-bridge-web",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Browser SDK for AI App Bridge Web sessions.",
5
5
  "main": "src/index.js",
6
6
  "browser": "src/index.js",
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
- const dom = observeDom(message.payload.args ?? {});
191
- reply(message, dom.ok ? { ok: true, dom } : dom); return;
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, elements: new WeakMap(), nextElement: 0 });
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 matched = doc.querySelectorAll(selector);
420
- const elements = Array.prototype.slice.call(matched, 0, maxControls);
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
- ...elementIdentity(element, doc),
426
- index,
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: elementVisible(element),
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
- truncated: matched.length > maxControls,
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
- ensureTarget(selected, task, { edited: true });
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 findElement(selector) {
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 !== 1) throw new Error('invalid_web_selector');
563
- const elements = doc.querySelectorAll(selector.css ?? defaultControlSelector);
564
- if (elements.length > 1000) throw new Error('web_selector_scan_limit');
565
- const matches = Array.from(elements).filter(element => {
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
- if (matches.length > 1) throw new Error('web_element_ambiguous');
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
- || !elementKeys.every(key => ref.element[key] === args.expectedTarget.element[key]))) throw new Error('reobserve_required');
580
- return { element, ref };
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 (!elementKeys.filter(key => !edited || key !== 'text').every(key => current[key] === ref.element[key])) throw new Error('reobserve_required');
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'