@mobileaidev/ai-app-bridge-web 0.4.5 → 0.4.6

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +58 -23
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.6",
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'),
@@ -414,16 +419,22 @@
414
419
  return { ok: false, error: 'document_unavailable' };
415
420
  }
416
421
  const selector = options.selector || defaultControlSelector;
422
+ if (options.query && options.selector) return { ok: false, error: 'invalid_web_dom_query' };
423
+ const state = documentTarget(doc);
424
+ for (const [id, element] of state.elementsById) {
425
+ if (!element.isConnected || element.ownerDocument !== doc) state.elementsById.delete(id);
426
+ }
417
427
  const maxControls = Number(options.maxControls || 200);
428
+ const offset = options.offset ?? 0;
418
429
  const controls = [];
419
- const matched = doc.querySelectorAll(selector);
420
- const elements = Array.prototype.slice.call(matched, 0, maxControls);
430
+ const matched = options.query ? matchingElements(options.query, doc) : doc.querySelectorAll(selector);
431
+ const elements = Array.prototype.slice.call(matched, offset, offset + maxControls);
421
432
  elements.forEach((element, index) => {
422
433
  const sensitive = isSensitiveInput(element);
423
434
  const elementValue = text(element.value);
424
435
  controls.push({
425
436
  ...elementIdentity(element, doc),
426
- index,
437
+ index: offset + index,
427
438
  sensitive,
428
439
  ...(sensitive ? { valueLength: elementValue.length } : {}),
429
440
  disabled: elementDisabled(element),
@@ -445,7 +456,10 @@
445
456
  bodyTextTruncated: text(doc.body && doc.body.innerText).length > 20000,
446
457
  controls,
447
458
  controlCount: controls.length,
448
- truncated: matched.length > maxControls,
459
+ totalControls: matched.length, offset,
460
+ nextOffset: offset + controls.length < matched.length ? offset + controls.length : null,
461
+ ...(options.query ? { query: options.query } : {}),
462
+ truncated: offset > 0 || matched.length > controls.length,
449
463
  updatedAtMs: Date.now(),
450
464
  };
451
465
  }
@@ -453,7 +467,7 @@
453
467
  function clickElement(args, task) {
454
468
  const selected = actionTarget(args, task);
455
469
  ensureTarget(selected, task, { hit: true }); task.permit(); selected.element.click();
456
- return { ok: true, resolved: selected.ref };
470
+ return { ok: true, resolved: selected.ref, ...targetWarnings(selected) };
457
471
  }
458
472
 
459
473
  function inputElement(args, task) {
@@ -484,6 +498,7 @@
484
498
  return {
485
499
  ok: true,
486
500
  resolved: selected.ref,
501
+ ...targetWarnings(selected),
487
502
  sensitive,
488
503
  valueLength: (richText ? element.innerText : element.value).length,
489
504
  ...(sensitive ? {} : { value: richText ? element.innerText : element.value }),
@@ -499,7 +514,7 @@
499
514
  key: args.key, code: args.key, bubbles: true, cancelable: true, composed: true,
500
515
  });
501
516
  task.permit(); element.dispatchEvent(event);
502
- return { ok: true, resolved: selected.ref, key: args.key, eventType: 'keydown',
517
+ return { ok: true, resolved: selected.ref, ...targetWarnings(selected), key: args.key, eventType: 'keydown',
503
518
  trusted: event.isTrusted, defaultPrevented: event.defaultPrevented };
504
519
  }
505
520
 
@@ -539,12 +554,12 @@
539
554
  if (args.mode === 'into-view') {
540
555
  if (!selected) return { ok: false, error: 'web_selector_required' };
541
556
  ensureTarget(selected, task); task.permit(); selected.element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
542
- return { ok: true, resolved: selected.ref };
557
+ return { ok: true, resolved: selected.ref, ...targetWarnings(selected) };
543
558
  }
544
559
  if (args.mode !== 'by') return { ok: false, error: 'invalid_web_scroll_mode' };
545
560
  if (selected) {
546
561
  ensureTarget(selected, task); task.permit(); selected.element.scrollBy({ left: deltaX, top: deltaY, behavior: 'instant' });
547
- return { ok: true, resolved: selected.ref };
562
+ return { ok: true, resolved: selected.ref, ...targetWarnings(selected) };
548
563
  }
549
564
  const win = globalValue('window');
550
565
  if (win && win.scrollBy) {
@@ -554,20 +569,28 @@
554
569
  return { ok: false, error: 'scroll_unavailable' };
555
570
  }
556
571
 
557
- function findElement(selector) {
558
- const doc = globalValue('document');
559
- if (!doc) return null;
572
+ function matchingElements(selector, doc) {
560
573
  if (!selector || typeof selector !== 'object') throw new Error('invalid_web_selector');
561
574
  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;
575
+ if (!identities.length) throw new Error('invalid_web_selector');
576
+ const known = selector.elementId === undefined ? null : documentTarget(doc).elementsById.get(selector.elementId);
577
+ const elements = selector.elementId === undefined ? doc.querySelectorAll(selector.css ?? defaultControlSelector) : known ? [known] : [];
578
+ return Array.from(elements).filter(element => {
579
+ if (element.ownerDocument !== doc || !elementVisible(element) || selector.elementId !== undefined && selector.css !== undefined && !element.matches(selector.css)) return false;
567
580
  const ref = elementIdentity(element);
568
581
  return ['elementId', 'text', 'ariaLabel', 'tag', 'role'].every(key => selector[key] === undefined || selector[key] === ref[key]);
569
582
  });
570
- if (matches.length > 1) throw new Error('web_element_ambiguous');
583
+ }
584
+ function findElement(selector) {
585
+ const doc = globalValue('document');
586
+ if (!doc) return null;
587
+ const matches = matchingElements(selector, doc);
588
+ if (matches.length > 1) throw Object.assign(new Error('web_element_ambiguous'), { details: {
589
+ selectionRequired: true, matched: matches.length, candidatesTruncated: matches.length > 20,
590
+ ...(matches.length > 20 ? { candidateQuery: { query: selector, offset: 20, maxControls: 200 } } : {}),
591
+ candidates: matches.slice(0, 20).map(element => { const ref = elementIdentity(element); return { text: ref.text, tag: ref.tag,
592
+ role: ref.role, ariaLabel: ref.ariaLabel, disabled: elementDisabled(element), bounds: bounds(element), selector: { elementId: ref.elementId } }; }),
593
+ } });
571
594
  return matches[0] ?? null;
572
595
  }
573
596
 
@@ -576,8 +599,19 @@
576
599
  if (!element) throw new Error('web_element_not_found');
577
600
  const ref = { pageRef: task.pageRef(), element: elementIdentity(element) };
578
601
  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 };
602
+ || ref.element.elementId !== args.expectedTarget.element.elementId)) throw new Error('reobserve_required');
603
+ const selected = { element, ref, observed: args.expectedTarget?.element ?? ref.element, changedFields: new Set() };
604
+ recordTargetChanges(selected, ref.element);
605
+ return selected;
606
+ }
607
+ function recordTargetChanges(selected, current, edited = false) {
608
+ for (const key of elementKeys) {
609
+ if ((!edited || key !== 'text') && current[key] !== selected.observed[key]) selected.changedFields.add(key);
610
+ }
611
+ }
612
+ function targetWarnings(selected) {
613
+ return selected.changedFields.size ? { warnings: [{ code: 'target_attributes_changed', fields: [...selected.changedFields],
614
+ message: 'The observed DOM element is still present; its attributes changed.' }] } : {};
581
615
  }
582
616
  function samePage(a, b) {
583
617
  return ['schemaVersion', 'sessionId', 'runtimeEpoch', 'targetId', 'navigationId', 'url'].every(key => a[key] === b?.[key]);
@@ -590,7 +624,8 @@
590
624
  if (!element.isConnected || element.ownerDocument !== doc || elementDisabled(element) || !elementVisible(element)
591
625
  || (focus && doc.activeElement !== element)) throw new Error('web_element_changed');
592
626
  const current = elementIdentity(element);
593
- if (!elementKeys.filter(key => !edited || key !== 'text').every(key => current[key] === ref.element[key])) throw new Error('reobserve_required');
627
+ if (current.elementId !== ref.element.elementId) throw new Error('reobserve_required');
628
+ recordTargetChanges(selected, current, edited);
594
629
  if (hit) {
595
630
  const interaction = interactionState(element);
596
631
  if (interaction.status !== 'ready') throw Object.assign(new Error(interaction.status === 'outside-viewport'