@buoy-gg/highlight-updates 7.0.34 → 7.0.36

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 (36) hide show
  1. package/lib/commonjs/highlight-updates/HighlightUpdatesOverlay.js +1 -1
  2. package/lib/commonjs/highlight-updates/components/HighlightFilterView.js +6 -6
  3. package/lib/commonjs/highlight-updates/components/IdentifierBadge.js +2 -2
  4. package/lib/commonjs/highlight-updates/components/RenderCauseBadge.js +7 -7
  5. package/lib/commonjs/highlight-updates/components/RenderDetailView.js +6 -6
  6. package/lib/commonjs/highlight-updates/components/RenderHistoryViewer.js +8 -8
  7. package/lib/commonjs/highlight-updates/components/RenderListItem.js +2 -2
  8. package/lib/commonjs/highlight-updates/components/RendersCopySettingsView.js +1 -1
  9. package/lib/commonjs/highlight-updates/components/StatsDisplay.js +1 -1
  10. package/lib/commonjs/highlight-updates/utils/screenElements.js +272 -70
  11. package/lib/commonjs/highlight-updates/utils/tapMatch.js +26 -1
  12. package/lib/commonjs/sync/highlightUpdatesSyncAdapter.js +1 -1
  13. package/lib/module/highlight-updates/HighlightUpdatesOverlay.js +2 -2
  14. package/lib/module/highlight-updates/components/HighlightFilterView.js +7 -7
  15. package/lib/module/highlight-updates/components/IdentifierBadge.js +3 -3
  16. package/lib/module/highlight-updates/components/RenderCauseBadge.js +8 -8
  17. package/lib/module/highlight-updates/components/RenderDetailView.js +7 -7
  18. package/lib/module/highlight-updates/components/RenderHistoryViewer.js +9 -9
  19. package/lib/module/highlight-updates/components/RenderListItem.js +3 -3
  20. package/lib/module/highlight-updates/components/RendersCopySettingsView.js +2 -2
  21. package/lib/module/highlight-updates/components/StatsDisplay.js +2 -2
  22. package/lib/module/highlight-updates/utils/screenElements.js +272 -70
  23. package/lib/module/highlight-updates/utils/tapMatch.js +25 -1
  24. package/lib/module/sync/highlightUpdatesSyncAdapter.js +1 -1
  25. package/lib/typescript/highlight-updates/components/HighlightFilterView.d.ts.map +1 -1
  26. package/lib/typescript/highlight-updates/components/IdentifierBadge.d.ts.map +1 -1
  27. package/lib/typescript/highlight-updates/components/RenderDetailView.d.ts.map +1 -1
  28. package/lib/typescript/highlight-updates/components/RenderHistoryViewer.d.ts.map +1 -1
  29. package/lib/typescript/highlight-updates/components/RendersCopySettingsView.d.ts.map +1 -1
  30. package/lib/typescript/highlight-updates/utils/screenElements.d.ts +52 -1
  31. package/lib/typescript/highlight-updates/utils/screenElements.d.ts.map +1 -1
  32. package/lib/typescript/highlight-updates/utils/tapMatch.d.ts +11 -0
  33. package/lib/typescript/highlight-updates/utils/tapMatch.d.ts.map +1 -1
  34. package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts +1 -1
  35. package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts.map +1 -1
  36. package/package.json +7 -7
@@ -40,6 +40,69 @@ function getReactDevToolsHook() {
40
40
  return global.__REACT_DEVTOOLS_GLOBAL_HOOK__ ?? null;
41
41
  }
42
42
 
43
+ /**
44
+ * React commits observed since this counter was installed.
45
+ *
46
+ * `tapElement` reads it either side of firing a handler to say whether
47
+ * ANYTHING re-rendered afterwards. Without that a tap that matched a button
48
+ * on the screen BENEATH the current one (a stack keeps it mounted) came back
49
+ * `tapped:true` — the handler really did run — and an agent then built its
50
+ * next three turns on a sign-in that never happened. Zero commits inside the
51
+ * settle window is the honest signal: the handler ran and the app did nothing
52
+ * visible with it.
53
+ *
54
+ * Installed once and chained, the same way CommitProfiler wraps the hook;
55
+ * counting is the only work it does on the commit path.
56
+ */
57
+ let commitCount = 0;
58
+ let commitCounterInstalled = false;
59
+ function ensureCommitCounter() {
60
+ if (commitCounterInstalled) return;
61
+ const hook = getReactDevToolsHook();
62
+ if (!hook) return;
63
+ const original = hook.onCommitFiberRoot;
64
+ hook.onCommitFiberRoot = function (...args) {
65
+ commitCount++;
66
+ return typeof original === "function" ? original.apply(hook, args) : undefined;
67
+ };
68
+ commitCounterInstalled = true;
69
+ }
70
+
71
+ /** How long a tap waits to see the app react before reporting. */
72
+ const TAP_SETTLE_MS = 250;
73
+
74
+ /**
75
+ * Per-walk memo of "is this fiber under Buoy's own overlay". Every candidate
76
+ * asks, and they share ancestors, so the answer is cached along the path
77
+ * walked rather than recomputed 400 levels deep per row.
78
+ */
79
+ function makeChromeCheck() {
80
+ const memo = new Map();
81
+ return fiber => {
82
+ const path = [];
83
+ let cur = fiber;
84
+ let depth = 0;
85
+ let inChrome = false;
86
+ while (cur && depth < 400) {
87
+ const known = memo.get(cur);
88
+ if (known !== undefined) {
89
+ inChrome = known;
90
+ break;
91
+ }
92
+ if (getProps(cur)?.__buoyDevToolsRoot) {
93
+ inChrome = true;
94
+ memo.set(cur, true);
95
+ break;
96
+ }
97
+ path.push(cur);
98
+ cur = cur.return;
99
+ depth++;
100
+ }
101
+ for (const f of path) memo.set(f, inChrome);
102
+ return inChrome;
103
+ };
104
+ }
105
+
43
106
  /**
44
107
  * Collect fiber roots across ALL renderers. A single renderer id is not safe
45
108
  * to assume: secondary reconcilers (react-native-skia, react-native-svg, …)
@@ -258,13 +321,48 @@ function getOwningComponentName(fiber) {
258
321
  function getProps(fiber) {
259
322
  return fiber?.memoizedProps ?? fiber?.pendingProps ?? null;
260
323
  }
324
+
325
+ /**
326
+ * The text of a `children` value, INCLUDING one assembled from several pieces.
327
+ *
328
+ * `<Text>{value} {label}</Text>` compiles to `children: ["1,450", " ", "Points"]`
329
+ * and `Check Back Soon{"\n"}For Available Offers` to three strings. This used
330
+ * to accept a lone string or number and nothing else, so EVERY composed label
331
+ * in an app was invisible to describeScreen, tapElement and waitFor — while
332
+ * native accessibility, which concatenates the same children, could read them.
333
+ * Measured on a live QA run: five waits failed against an empty state that was
334
+ * on screen the whole time, and the agent then reported the app was missing
335
+ * one; a points balance could not be found by its own number.
336
+ *
337
+ * Nested React ELEMENTS are not walked here — `collectDescendantText` owns
338
+ * that, with its stop-at-a-nested-button rule — so this stays a leaf join, and
339
+ * adjacent pieces are concatenated exactly as React renders them.
340
+ */
341
+ function flattenText(value, depth = 0) {
342
+ if (typeof value === "string") return value;
343
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
344
+ if (Array.isArray(value) && depth < 4) {
345
+ const parts = [];
346
+ for (const child of value) {
347
+ const part = flattenText(child, depth + 1);
348
+ if (part) parts.push(part);
349
+ }
350
+ return parts.length > 0 ? parts.join("") : undefined;
351
+ }
352
+ return undefined;
353
+ }
354
+
355
+ /** What a person reads: newlines and runs of spaces collapse to one space. */
356
+ function normalizeVisibleText(value) {
357
+ return value.replace(/\s+/g, " ").trim();
358
+ }
261
359
  function getVisibleText(props) {
262
360
  if (!props) return undefined;
263
361
  const pick = v => {
264
- if (typeof v === "string") return v.trim() ? v.trim().slice(0, 80) : undefined;
265
- // Numeric children are common (counters, badges) — coerce them too.
266
- if (typeof v === "number" && Number.isFinite(v)) return String(v);
267
- return undefined;
362
+ const flat = flattenText(v);
363
+ if (flat === undefined) return undefined;
364
+ const text = normalizeVisibleText(flat);
365
+ return text ? text.slice(0, 80) : undefined;
268
366
  };
269
367
  return pick(props.children) ?? pick(props.title) ?? pick(props.label) ?? pick(props.placeholder) ?? undefined;
270
368
  }
@@ -378,7 +476,19 @@ function collectCandidates() {
378
476
  const roots = getAllFiberRoots();
379
477
  if (roots.length === 0) return [];
380
478
  const out = [];
381
- const seenInstances = new Set();
479
+ /**
480
+ * De-dup indexes. Two fibers resolving to the same host node collapse to one
481
+ * row — a Pressable and its inner View, but ALSO the same native view reached
482
+ * twice by the walk, which produces two different `instance` objects for one
483
+ * `nativeTag`. Measured on a live QA run: a 141-row screen dump held 70
484
+ * distinct elements, every one listed twice, and the agent told the user
485
+ * their layout had duplicate elements. Keyed both ways so either collision
486
+ * collapses, and as Maps because the old `out.find(…)` was a linear scan per
487
+ * candidate on a list that can run to hundreds.
488
+ */
489
+ const byInstance = new Map();
490
+ const byTag = new Map();
491
+ const inBuoyChrome = makeChromeCheck();
382
492
  for (const root of roots) {
383
493
  traverseFiberTree(root.current, fiber => {
384
494
  const props = getProps(fiber);
@@ -436,25 +546,25 @@ function collectCandidates() {
436
546
  text = collectDescendantText(fiber, 6);
437
547
  }
438
548
  const instance = host?.instance ?? null;
439
- // De-dup: two fibers resolving to the same host instance (e.g. Pressable
440
- // and its inner View) collapse to one row — keep the more interactive.
441
- if (instance && seenInstances.has(instance)) {
442
- const existing = out.find(c => c.instance === instance);
443
- if (existing) {
444
- if (!existing.press && press) existing.press = press;else if (existing.press && press?.longPress && !existing.press.longPress) {
445
- existing.press.longPress = press.longPress;
446
- }
447
- if (!existing.valueControl && valueControl) existing.valueControl = valueControl;
448
- if (!existing.textControl && textControl) existing.textControl = textControl;
449
- if (!existing.testID && testID) existing.testID = testID;
450
- if (!existing.label && label) existing.label = label;
451
- if (!existing.text && text) existing.text = text;
452
- return;
549
+ const nativeTag = host?.nativeTag ?? null;
550
+ const existing = (instance ? byInstance.get(instance) : undefined) ?? (nativeTag != null ? byTag.get(nativeTag) : undefined);
551
+ if (existing) {
552
+ // Keep the more interactive / better-labelled of the two.
553
+ if (!existing.press && press) existing.press = press;else if (existing.press && press?.longPress && !existing.press.longPress) {
554
+ existing.press.longPress = press.longPress;
453
555
  }
556
+ if (!existing.valueControl && valueControl) existing.valueControl = valueControl;
557
+ if (!existing.textControl && textControl) existing.textControl = textControl;
558
+ if (!existing.testID && testID) existing.testID = testID;
559
+ if (!existing.label && label) existing.label = label;
560
+ if (!existing.text && text) existing.text = text;
561
+ // Index the alias too, so a third sighting through either key finds it.
562
+ if (instance && !byInstance.has(instance)) byInstance.set(instance, existing);
563
+ if (nativeTag != null && !byTag.has(nativeTag)) byTag.set(nativeTag, existing);
564
+ return;
454
565
  }
455
- if (instance) seenInstances.add(instance);
456
- out.push({
457
- nativeTag: host?.nativeTag ?? null,
566
+ const candidate = {
567
+ nativeTag,
458
568
  name,
459
569
  role,
460
570
  testID,
@@ -466,12 +576,48 @@ function collectCandidates() {
466
576
  press,
467
577
  valueControl,
468
578
  textControl,
469
- rect: null
470
- });
579
+ rect: null,
580
+ buoy: inBuoyChrome(fiber)
581
+ };
582
+ out.push(candidate);
583
+ if (instance) byInstance.set(instance, candidate);
584
+ if (nativeTag != null) byTag.set(nativeTag, candidate);
471
585
  });
472
586
  }
473
587
  return out;
474
588
  }
589
+
590
+ /**
591
+ * Whether the caller named THIS element, rather than described one like it.
592
+ * An exact nativeTag or testID is a deliberate choice, and is honoured even
593
+ * inside Buoy's own chrome — that is how a test or an editor agent drives a
594
+ * Buoy tool on purpose. A fuzzy `query` is not.
595
+ */
596
+ function explicitlyTargets(c, params) {
597
+ if (params.nativeTag != null) return c.nativeTag === params.nativeTag;
598
+ if (params.testID && c.testID) return c.testID.toLowerCase() === params.testID.toLowerCase();
599
+ return false;
600
+ }
601
+
602
+ /**
603
+ * The app under test, not the tool looking at it.
604
+ *
605
+ * Buoy runs INSIDE the app, so its own sheet is in the same fiber tree as the
606
+ * screens it inspects — and Ask Buoy's activity log echoes whatever the model
607
+ * is searching for back as visible text. Left in, that echo was the best
608
+ * match for the next fuzzy tap or wait, so every text-driven action landed on
609
+ * Buoy's own panel and nothing in the app moved. The agent diagnosed it
610
+ * itself, four chats in ("every text-based tap and wait I've done this
611
+ * session has been landing on Buoy's panel"), and still could not escape it.
612
+ *
613
+ * `includeBuoy` lists the chrome for callers that want it; an exact id gets
614
+ * through regardless (see `explicitlyTargets`).
615
+ */
616
+ function appCandidates(params) {
617
+ const all = collectCandidates();
618
+ if (params.includeBuoy) return all;
619
+ return all.filter(c => !c.buoy || explicitlyTargets(c, params));
620
+ }
475
621
  async function measureCandidates(candidates) {
476
622
  await Promise.all(candidates.map(async c => {
477
623
  if (c.instance) c.rect = await measureInstance(c.instance);
@@ -480,8 +626,10 @@ async function measureCandidates(candidates) {
480
626
 
481
627
  // --- describeScreen -------------------------------------------------------
482
628
 
483
- export async function describeScreen() {
484
- const candidates = collectCandidates();
629
+ export async function describeScreen(params = {}) {
630
+ const all = collectCandidates();
631
+ const candidates = params.includeBuoy ? all : all.filter(c => !c.buoy);
632
+ const hiddenBuoy = all.length - candidates.length;
485
633
  await measureCandidates(candidates);
486
634
  const win = Dimensions.get("window");
487
635
  const screenW = Math.round(win.width) || 1;
@@ -543,7 +691,10 @@ export async function describeScreen() {
543
691
  height: screenH
544
692
  },
545
693
  count: elements.length,
546
- elements
694
+ elements,
695
+ ...(hiddenBuoy > 0 ? {
696
+ hiddenBuoy
697
+ } : {})
547
698
  };
548
699
  }
549
700
  function round2(n) {
@@ -599,7 +750,7 @@ function makeSyntheticPressEvent(rect) {
599
750
  };
600
751
  }
601
752
  export async function tapElement(params) {
602
- const candidates = collectCandidates();
753
+ const candidates = appCandidates(params);
603
754
  if (candidates.length === 0) {
604
755
  return {
605
756
  tapped: false,
@@ -648,6 +799,21 @@ export async function tapElement(params) {
648
799
  // sit below 15 text-score peers.
649
800
  const target = top.find(r => isInteractive(r.c)) ?? ranked.find(r => isInteractive(r.c)) ?? top[0];
650
801
  const c = target.c;
802
+
803
+ // Everything else that matched just as well. A fuzzy "SIGN IN" on a stack
804
+ // that still has the home screen mounted underneath has two equal answers,
805
+ // and the caller has to know that before trusting whichever one fired.
806
+ const sameControl = (a, b) => a.press && a.press.handler === b.press?.handler || a.valueControl && a.valueControl.handler === b.valueControl?.handler || a.textControl && a.textControl.handler === b.textControl?.handler;
807
+ const alsoMatched = top.filter(r => r !== target && isInteractive(r.c) && r.score === target.score)
808
+ // The label Text inside the tapped button matches its own button's text
809
+ // and inherits its onPress: same control, not a rival.
810
+ .filter(r => !sameControl(r.c, c)).filter((r, i, arr) => arr.findIndex(o => sameControl(o.c, r.c)) === i).slice(0, 3).map(r => ({
811
+ nativeTag: r.c.nativeTag,
812
+ name: r.c.name,
813
+ testID: r.c.testID,
814
+ text: r.c.text,
815
+ tap: tapPoint(r.c.rect, screenW, screenH)
816
+ }));
651
817
  if (!isInteractive(c)) {
652
818
  return {
653
819
  tapped: false,
@@ -685,6 +851,28 @@ export async function tapElement(params) {
685
851
  label: c.label,
686
852
  text: c.text
687
853
  };
854
+
855
+ // Fire, then watch. The handler is the app's own; what it did — or did
856
+ // not do — shows up as commits (or none) in the settle window.
857
+ ensureCommitCounter();
858
+ // Read BEFORE the handler runs: a synchronous setState commits inside it.
859
+ const c0 = commitCount;
860
+ const t0 = Date.now();
861
+ const fired = async matched => {
862
+ await new Promise(resolve => setTimeout(resolve, TAP_SETTLE_MS));
863
+ return {
864
+ tapped: true,
865
+ scrolled,
866
+ matched,
867
+ effect: {
868
+ commits: commitCount - c0,
869
+ observedMs: Date.now() - t0
870
+ },
871
+ ...(alsoMatched.length ? {
872
+ alsoMatched
873
+ } : {})
874
+ };
875
+ };
688
876
  try {
689
877
  if (c.press) {
690
878
  if (params.longPress) {
@@ -701,24 +889,16 @@ export async function tapElement(params) {
701
889
  };
702
890
  }
703
891
  lp(makeSyntheticPressEvent(c.rect));
704
- return {
705
- tapped: true,
706
- scrolled,
707
- matched: {
708
- ...base,
709
- via: "onLongPress"
710
- }
711
- };
892
+ return await fired({
893
+ ...base,
894
+ via: "onLongPress"
895
+ });
712
896
  }
713
897
  c.press.handler(makeSyntheticPressEvent(c.rect));
714
- return {
715
- tapped: true,
716
- scrolled,
717
- matched: {
718
- ...base,
719
- via: c.press.kind
720
- }
721
- };
898
+ return await fired({
899
+ ...base,
900
+ via: c.press.kind
901
+ });
722
902
  }
723
903
  if (c.valueControl) {
724
904
  const vc = c.valueControl;
@@ -736,28 +916,20 @@ export async function tapElement(params) {
736
916
  }
737
917
  }
738
918
  vc.handler(next);
739
- return {
740
- tapped: true,
741
- scrolled,
742
- matched: {
743
- ...base,
744
- via: "onValueChange",
745
- value: next
746
- }
747
- };
919
+ return await fired({
920
+ ...base,
921
+ via: "onValueChange",
922
+ value: next
923
+ });
748
924
  }
749
925
  // textControl
750
926
  const next = params.text ?? (typeof params.value === "string" ? params.value : "");
751
927
  c.textControl.handler(next);
752
- return {
753
- tapped: true,
754
- scrolled,
755
- matched: {
756
- ...base,
757
- via: "onChangeText",
758
- value: next
759
- }
760
- };
928
+ return await fired({
929
+ ...base,
930
+ via: "onChangeText",
931
+ value: next
932
+ });
761
933
  } catch (e) {
762
934
  return {
763
935
  tapped: false,
@@ -806,7 +978,9 @@ const WAIT_DEFAULT_POLL_MS = 150;
806
978
  * waiting for.
807
979
  */
808
980
  export async function waitFor(params) {
809
- const hasTarget = params.nativeTag != null || !!params.testID?.trim() || !!params.query?.trim();
981
+ // Coerced, not optional-chained: a model sends `query: 1450` often enough,
982
+ // and `.trim()` on a number throws before the wait ever starts.
983
+ const hasTarget = params.nativeTag != null || !!String(params.testID ?? "").trim() || !!String(params.query ?? "").trim();
810
984
  if (!hasTarget) {
811
985
  return {
812
986
  ok: false,
@@ -822,7 +996,10 @@ export async function waitFor(params) {
822
996
  let polls = 0;
823
997
  for (;;) {
824
998
  polls += 1;
825
- const hit = await findOnScreen(params);
999
+ const {
1000
+ hit,
1001
+ closest
1002
+ } = await findOnScreen(params);
826
1003
  if (wantGone ? !hit : !!hit) {
827
1004
  return {
828
1005
  ok: true,
@@ -832,12 +1009,18 @@ export async function waitFor(params) {
832
1009
  };
833
1010
  }
834
1011
  if (Date.now() - started >= timeoutMs) {
1012
+ const nearMisses = closest?.length ? closest : undefined;
1013
+ // Say which of the two failures this is. "Nothing is called that" and
1014
+ // "it exists but is not visible" need opposite next moves, and a caller
1015
+ // that cannot tell them apart tries the same wait with new words.
1016
+ const why = wantGone ? `Still on screen after ${timeoutMs}ms.` : nearMisses ? `Not on screen after ${timeoutMs}ms — ${nearMisses.length === 1 ? "one element" : `${nearMisses.length} elements`} matched but ${nearMisses.some(n => n.onScreen) ? "not well enough" : "none is visible"}. See \`closest\`; do not retry with different words, call describeScreen.` : `Not on screen after ${timeoutMs}ms, and nothing matched those words at all. Call describeScreen to see what is there.`;
835
1017
  return {
836
1018
  ok: false,
837
1019
  waitedMs: Date.now() - started,
838
1020
  polls,
839
- reason: wantGone ? `Still on screen after ${timeoutMs}ms.` : `Not on screen after ${timeoutMs}ms. Call describeScreen to see what is.`,
840
- matched: hit ?? undefined
1021
+ reason: why,
1022
+ matched: hit ?? undefined,
1023
+ closest: nearMisses
841
1024
  };
842
1025
  }
843
1026
  await new Promise(resolve => setTimeout(resolve, pollMs));
@@ -846,22 +1029,26 @@ export async function waitFor(params) {
846
1029
 
847
1030
  /** One scan: rank by identity, then confirm the best match is really visible. */
848
1031
  async function findOnScreen(params) {
849
- const ranked = collectCandidates().map(c => ({
1032
+ const ranked = appCandidates(params).map(c => ({
850
1033
  c,
851
1034
  score: matchScore(c, params)
852
1035
  })).filter(r => r.score > 0).sort((a, b) => b.score - a.score)
853
1036
  // Only the plausible few get measured; a broad query can match dozens.
854
1037
  .slice(0, 8);
855
- if (ranked.length === 0) return null;
1038
+ if (ranked.length === 0) return {
1039
+ hit: null,
1040
+ closest: undefined
1041
+ };
856
1042
  await measureCandidates(ranked.map(r => r.c));
857
1043
  const win = Dimensions.get("window");
858
1044
  const screenW = Math.round(win.width) || 1;
859
1045
  const screenH = Math.round(win.height) || 1;
1046
+ let hit = null;
860
1047
  for (const {
861
1048
  c
862
1049
  } of ranked) {
863
1050
  if (!isRectCenterOnScreen(c.rect, screenW, screenH)) continue;
864
- return {
1051
+ hit = {
865
1052
  nativeTag: c.nativeTag,
866
1053
  name: c.name,
867
1054
  testID: c.testID,
@@ -869,8 +1056,23 @@ async function findOnScreen(params) {
869
1056
  text: c.text,
870
1057
  interactive: !!(c.press || c.valueControl || c.textControl)
871
1058
  };
1059
+ break;
872
1060
  }
873
- return null;
1061
+
1062
+ // Built from the same measured ranking, so a failed wait can say what it
1063
+ // nearly matched and whether that thing was visible.
1064
+ const closest = ranked.slice(0, 3).map(({
1065
+ c
1066
+ }) => ({
1067
+ name: c.name,
1068
+ testID: c.testID,
1069
+ text: c.text,
1070
+ onScreen: isRectCenterOnScreen(c.rect, screenW, screenH)
1071
+ }));
1072
+ return {
1073
+ hit,
1074
+ closest
1075
+ };
874
1076
  }
875
1077
 
876
1078
  // --- resolveTouchTarget ---------------------------------------------------
@@ -27,15 +27,39 @@ export function isRectCenterOnScreen(rect, screenW, screenH) {
27
27
  const slackY = screenH * 0.1;
28
28
  return cx >= -slackX && cx <= screenW + slackX && cy >= -slackY && cy <= screenH + slackY;
29
29
  }
30
+
31
+ /**
32
+ * The comparable form of a piece of text.
33
+ *
34
+ * "1,450", `1450` and "1,450 Points" are one request as far as a person is
35
+ * concerned. Measured on a live QA run: waits for BOTH "1,450" and 1450 failed
36
+ * against a balance that was on screen, because the thousands separator made
37
+ * every comparison miss. Lowercased, whitespace collapsed, digit-group
38
+ * separators dropped. A number arrives here as a number often enough that the
39
+ * coercion is not optional — `.trim()` on one is a crash.
40
+ */
41
+ export function looseText(value) {
42
+ return String(value ?? "").toLowerCase().replace(/\s+/g, " ").replace(/(\d)[,\u00a0\u202f](?=\d{3}\b)/g, "$1").trim();
43
+ }
30
44
  export function scoreTapMatch(c, params) {
31
45
  if (params.nativeTag != null && c.nativeTag === params.nativeTag) return 1000;
32
- const q = (params.query ?? params.testID ?? "").trim().toLowerCase();
46
+ const q = String(params.query ?? params.testID ?? "").trim().toLowerCase();
33
47
  if (!q) return 0;
34
48
  const fields = [c.testID, c.label, c.text, c.name].filter(Boolean).map(s => s.toLowerCase());
35
49
  let best = 0;
36
50
  for (const f of fields) {
37
51
  if (f === q) best = Math.max(best, 100);else if (f.startsWith(q)) best = Math.max(best, 50);else if (f.includes(q)) best = Math.max(best, 25);
38
52
  }
53
+ // Same tiers on the loose form, one notch lower so an exact hit still wins
54
+ // when both match. Skipped when the loose query is empty, or `includes("")`
55
+ // would score every candidate on the screen.
56
+ const looseQuery = looseText(q);
57
+ if (looseQuery) {
58
+ for (const f of fields) {
59
+ const loose = looseText(f);
60
+ if (loose === looseQuery) best = Math.max(best, 90);else if (loose.startsWith(looseQuery)) best = Math.max(best, 45);else if (loose.includes(looseQuery)) best = Math.max(best, 22);
61
+ }
62
+ }
39
63
  if (params.testID && c.testID && c.testID.toLowerCase() === params.testID.toLowerCase()) {
40
64
  best = Math.max(best, 200);
41
65
  }
@@ -200,7 +200,7 @@ export const highlightUpdatesSyncAdapter = {
200
200
  // Walk the live fiber tree and return the current screen's meaningful /
201
201
  // interactive elements, each with normalized tap coordinates. Powers the
202
202
  // MCP `describe_screen` reader — no screenshot, no silent-tracking needed.
203
- describeScreen: () => describeScreen(),
203
+ describeScreen: params => describeScreen(params ?? {}),
204
204
  // Tap an element purely in JS: resolve it (by nativeTag / testID / query),
205
205
  // find the nearest onPress up its fiber chain, and invoke it with a
206
206
  // synthetic press event. Powers the MCP `tap_element` action.
@@ -1 +1 @@
1
- {"version":3,"file":"HighlightFilterView.d.ts","sourceRoot":"","sources":["../../../../src/highlight-updates/components/HighlightFilterView.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAgC,MAAM,OAAO,CAAC;AAwBrD,OAAO,KAAK,EACV,YAAY,EAGZ,qBAAqB,EAGtB,MAAM,wBAAwB,CAAC;AAOhC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAG1D,UAAU,wBAAwB;IAChC,OAAO,EAAE,YAAY,CAAC;IACtB,cAAc,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC;IACzD,QAAQ,EAAE,qBAAqB,CAAC;IAChC,gBAAgB,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,qBAAqB,CAAC,KAAK,IAAI,CAAC;IACrE,cAAc,EAAE;QACd,SAAS,EAAE,MAAM,EAAE,CAAC;QACpB,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,SAAS,EAAE,MAAM,EAAE,CAAC;QACpB,cAAc,EAAE,MAAM,EAAE,CAAC;QACzB,mBAAmB,EAAE,MAAM,EAAE,CAAC;KAC/B,CAAC;IACF,SAAS,EAAE,aAAa,CAAC;CAC1B;AAwRD,wBAAgB,mBAAmB,CAAC,EAClC,OAAO,EACP,cAAc,EACd,QAAQ,EACR,gBAAgB,EAChB,cAAc,EACd,SAAS,GACV,EAAE,wBAAwB,qBAm8B1B;AAibD,eAAe,mBAAmB,CAAC"}
1
+ {"version":3,"file":"HighlightFilterView.d.ts","sourceRoot":"","sources":["../../../../src/highlight-updates/components/HighlightFilterView.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAgC,MAAM,OAAO,CAAC;AAyBrD,OAAO,KAAK,EACV,YAAY,EAGZ,qBAAqB,EAGtB,MAAM,wBAAwB,CAAC;AAOhC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAG1D,UAAU,wBAAwB;IAChC,OAAO,EAAE,YAAY,CAAC;IACtB,cAAc,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC;IACzD,QAAQ,EAAE,qBAAqB,CAAC;IAChC,gBAAgB,EAAE,CAAC,QAAQ,EAAE,OAAO,CAAC,qBAAqB,CAAC,KAAK,IAAI,CAAC;IACrE,cAAc,EAAE;QACd,SAAS,EAAE,MAAM,EAAE,CAAC;QACpB,OAAO,EAAE,MAAM,EAAE,CAAC;QAClB,SAAS,EAAE,MAAM,EAAE,CAAC;QACpB,cAAc,EAAE,MAAM,EAAE,CAAC;QACzB,mBAAmB,EAAE,MAAM,EAAE,CAAC;KAC/B,CAAC;IACF,SAAS,EAAE,aAAa,CAAC;CAC1B;AAwRD,wBAAgB,mBAAmB,CAAC,EAClC,OAAO,EACP,cAAc,EACd,QAAQ,EACR,gBAAgB,EAChB,cAAc,EACd,SAAS,GACV,EAAE,wBAAwB,qBAm8B1B;AAibD,eAAe,mBAAmB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"IdentifierBadge.d.ts","sourceRoot":"","sources":["../../../../src/highlight-updates/components/IdentifierBadge.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,EAAE,GAAG,EAA0D,MAAM,oBAAoB,CAAC;AAGjG,MAAM,MAAM,cAAc,GACtB,UAAU,GACV,QAAQ,GACR,UAAU,GACV,WAAW,GACX,oBAAoB,GACpB,WAAW,GACX,KAAK,CAAC;AAGV,eAAO,MAAM,iBAAiB,EAAE,MAAM,CACpC,cAAc,EACd;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,GAAG,CAAA;CAAE,CA4CvE,CAAC;AAEF,UAAU,oBAAoB;IAC5B,IAAI,EAAE,cAAc,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,wCAAwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wCAAwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,sBAAsB;IACtB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yBAAyB;IACzB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,eAAe,CAAC,EAC9B,IAAI,EACJ,KAAK,EACL,OAAe,EACf,SAAiB,EACjB,SAAiB,EACjB,UAAkB,EAClB,QAAgB,EAChB,MAAM,GACP,EAAE,oBAAoB,qBAkEtB;AAGD,UAAU,kBAAkB;IAC1B,IAAI,EAAE,cAAc,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,KAAK,EACL,UAAkB,EAClB,QAAe,GAChB,EAAE,kBAAkB,qBA4BpB;AAuGD,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"IdentifierBadge.d.ts","sourceRoot":"","sources":["../../../../src/highlight-updates/components/IdentifierBadge.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,MAAM,OAAO,CAAC;AAE1B,OAAO,EAAE,GAAG,EAAoE,MAAM,oBAAoB,CAAC;AAG3G,MAAM,MAAM,cAAc,GACtB,UAAU,GACV,QAAQ,GACR,UAAU,GACV,WAAW,GACX,oBAAoB,GACpB,WAAW,GACX,KAAK,CAAC;AAGV,eAAO,MAAM,iBAAiB,EAAE,MAAM,CACpC,cAAc,EACd;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,GAAG,CAAA;CAAE,CA4CvE,CAAC;AAEF,UAAU,oBAAoB;IAC5B,IAAI,EAAE,cAAc,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,wCAAwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,wCAAwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,sBAAsB;IACtB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,yBAAyB;IACzB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,eAAe,CAAC,EAC9B,IAAI,EACJ,KAAK,EACL,OAAe,EACf,SAAiB,EACjB,SAAiB,EACjB,UAAkB,EAClB,QAAgB,EAChB,MAAM,GACP,EAAE,oBAAoB,qBAkEtB;AAGD,UAAU,kBAAkB;IAC1B,IAAI,EAAE,cAAc,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAgB,aAAa,CAAC,EAC5B,IAAI,EACJ,KAAK,EACL,UAAkB,EAClB,QAAe,GAChB,EAAE,kBAAkB,qBA4BpB;AAuGD,eAAe,eAAe,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"RenderDetailView.d.ts","sourceRoot":"","sources":["../../../../src/highlight-updates/components/RenderDetailView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAyC,MAAM,OAAO,CAAC;AAa9D,OAAO,KAAK,EAAE,aAAa,EAAc,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAIvF,UAAU,qBAAqB;IAC7B,MAAM,EAAE,aAAa,CAAC;IACtB,iEAAiE;IACjE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,yDAAyD;IACzD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wDAAwD;IACxD,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,4DAA4D;IAC5D,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,SAAS,GAAG,SAAS,KAAK,IAAI,CAAC;IAC5E,8DAA8D;IAC9D,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAID,wBAAgB,gBAAgB,CAAC,EAC/B,MAAM,EACN,qBAA6B,EAC7B,kBAAkB,EAAE,aAAa,EACjC,kBAAkB,EAAE,gBAAgB,EACpC,WAAW,EACX,KAAa,GACd,EAAE,qBAAqB,qBA4IvB;AAqqBD,eAAe,gBAAgB,CAAC"}
1
+ {"version":3,"file":"RenderDetailView.d.ts","sourceRoot":"","sources":["../../../../src/highlight-updates/components/RenderDetailView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAyC,MAAM,OAAO,CAAC;AAc9D,OAAO,KAAK,EAAE,aAAa,EAAc,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAIvF,UAAU,qBAAqB;IAC7B,MAAM,EAAE,aAAa,CAAC;IACtB,iEAAiE;IACjE,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,yDAAyD;IACzD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wDAAwD;IACxD,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,4DAA4D;IAC5D,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,SAAS,GAAG,SAAS,KAAK,IAAI,CAAC;IAC5E,8DAA8D;IAC9D,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAID,wBAAgB,gBAAgB,CAAC,EAC/B,MAAM,EACN,qBAA6B,EAC7B,kBAAkB,EAAE,aAAa,EACjC,kBAAkB,EAAE,gBAAgB,EACpC,WAAW,EACX,KAAa,GACd,EAAE,qBAAqB,qBA4IvB;AAqqBD,eAAe,gBAAgB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"RenderHistoryViewer.d.ts","sourceRoot":"","sources":["../../../../src/highlight-updates/components/RenderHistoryViewer.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAyC,MAAM,OAAO,CAAC;AAiB9D,OAAO,EAAiB,KAAK,aAAa,EAAoB,MAAM,wBAAwB,CAAC;AAG7F,UAAU,wBAAwB;IAChC,MAAM,EAAE,aAAa,CAAC;IACtB,uDAAuD;IACvD,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,wCAAwC;IACxC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wCAAwC;IACxC,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,gEAAgE;IAChE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAqBD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,qBAA6B,EAC7B,kBAAkB,EAAE,aAAa,EACjC,kBAAkB,EAAE,gBAAgB,EACpC,KAAa,GACd,EAAE,wBAAwB,qBAiN1B;AAyRD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,kBAAsB,EACtB,kBAA6B,EAC7B,KAAa,GACd,EAAE;IACD,MAAM,EAAE,aAAa,CAAC;IACtB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,4BA6CA;AA+YD,eAAe,mBAAmB,CAAC"}
1
+ {"version":3,"file":"RenderHistoryViewer.d.ts","sourceRoot":"","sources":["../../../../src/highlight-updates/components/RenderHistoryViewer.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAyC,MAAM,OAAO,CAAC;AAkB9D,OAAO,EAAiB,KAAK,aAAa,EAAoB,MAAM,wBAAwB,CAAC;AAG7F,UAAU,wBAAwB;IAChC,MAAM,EAAE,aAAa,CAAC;IACtB,uDAAuD;IACvD,qBAAqB,CAAC,EAAE,OAAO,CAAC;IAChC,wCAAwC;IACxC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,wCAAwC;IACxC,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,gEAAgE;IAChE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAqBD;;GAEG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,qBAA6B,EAC7B,kBAAkB,EAAE,aAAa,EACjC,kBAAkB,EAAE,gBAAgB,EACpC,KAAa,GACd,EAAE,wBAAwB,qBAiN1B;AAyRD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,EAClC,MAAM,EACN,kBAAsB,EACtB,kBAA6B,EAC7B,KAAa,GACd,EAAE;IACD,MAAM,EAAE,aAAa,CAAC;IACtB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,kBAAkB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7C,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,4BA6CA;AA+YD,eAAe,mBAAmB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"RendersCopySettingsView.d.ts","sourceRoot":"","sources":["../../../../src/highlight-updates/components/RendersCopySettingsView.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAsBH,OAAO,KAAK,EAAE,aAAa,EAAmB,MAAM,wBAAwB,CAAC;AAmB7E,UAAU,4BAA4B;IACpC,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B;AA+ED,wBAAgB,uBAAuB,CAAC,EACtC,OAAO,GACR,EAAE,4BAA4B,+BAuY9B"}
1
+ {"version":3,"file":"RendersCopySettingsView.d.ts","sourceRoot":"","sources":["../../../../src/highlight-updates/components/RendersCopySettingsView.tsx"],"names":[],"mappings":"AAAA;;;;;GAKG;AAuBH,OAAO,KAAK,EAAE,aAAa,EAAmB,MAAM,wBAAwB,CAAC;AAmB7E,UAAU,4BAA4B;IACpC,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B;AA+ED,wBAAgB,uBAAuB,CAAC,EACtC,OAAO,GACR,EAAE,4BAA4B,+BAuY9B"}
@@ -49,6 +49,14 @@ export interface ScreenElement {
49
49
  height: number;
50
50
  };
51
51
  }
52
+ export interface DescribeScreenParams {
53
+ /**
54
+ * Also list Buoy's own overlay — the dial, tool sheets, Ask Buoy's chat.
55
+ * Off by default: the screen being described is the app's, and Buoy's
56
+ * chrome echoing the caller's own words back is how taps went astray.
57
+ */
58
+ includeBuoy?: boolean;
59
+ }
52
60
  export interface DescribeScreenResult {
53
61
  screen: {
54
62
  width: number;
@@ -56,11 +64,15 @@ export interface DescribeScreenResult {
56
64
  };
57
65
  count: number;
58
66
  elements: ScreenElement[];
67
+ /** Elements under Buoy's own overlay that were left out (pass includeBuoy to see them). */
68
+ hiddenBuoy?: number;
59
69
  }
60
70
  export interface TapElementParams {
61
71
  nativeTag?: number;
62
72
  testID?: string;
63
73
  query?: string;
74
+ /** Let a fuzzy `query` match Buoy's own overlay. Exact nativeTag/testID always may. */
75
+ includeBuoy?: boolean;
64
76
  /** Scroll the target into view first if it's off-screen. Default true. */
65
77
  scrollIntoView?: boolean;
66
78
  /** Invoke the element's onLongPress instead of onPress. Fails if it has none. */
@@ -88,6 +100,26 @@ export interface TapElementResult {
88
100
  /** New value set, for toggle/slider/text controls. */
89
101
  value?: number | boolean | string;
90
102
  };
103
+ /**
104
+ * What the app did after the handler ran. `commits` is the number of React
105
+ * commits inside the settle window; 0 means the handler fired and nothing
106
+ * re-rendered — an occluded button, a no-op press, a guard that bailed.
107
+ */
108
+ effect?: {
109
+ commits: number;
110
+ observedMs: number;
111
+ };
112
+ /** Other interactive elements that matched equally well. Non-empty means the choice was a coin toss. */
113
+ alsoMatched?: Array<{
114
+ nativeTag: number | null;
115
+ name: string;
116
+ testID?: string;
117
+ text?: string;
118
+ tap?: {
119
+ x: number;
120
+ y: number;
121
+ };
122
+ }>;
91
123
  candidates?: Array<{
92
124
  nativeTag: number | null;
93
125
  name: string;
@@ -95,12 +127,14 @@ export interface TapElementResult {
95
127
  text?: string;
96
128
  }>;
97
129
  }
98
- export declare function describeScreen(): Promise<DescribeScreenResult>;
130
+ export declare function describeScreen(params?: DescribeScreenParams): Promise<DescribeScreenResult>;
99
131
  export declare function tapElement(params: TapElementParams): Promise<TapElementResult>;
100
132
  export interface WaitForParams {
101
133
  nativeTag?: number;
102
134
  testID?: string;
103
135
  query?: string;
136
+ /** Let a fuzzy `query` match Buoy's own overlay. Exact nativeTag/testID always may. */
137
+ includeBuoy?: boolean;
104
138
  /** Wait for the element to be GONE instead of present. Default false. */
105
139
  gone?: boolean;
106
140
  /** Give up after this long. Default 5000ms, capped at 30000. */
@@ -122,6 +156,23 @@ export interface WaitForResult {
122
156
  text?: string;
123
157
  interactive: boolean;
124
158
  };
159
+ /**
160
+ * What DID rank for this query on the last scan, when the wait failed.
161
+ *
162
+ * A failed wait used to say only "not on screen" — so a caller could not tell
163
+ * "nothing is called that" from "it is there but scrolled off", and the
164
+ * cheapest next move looked like another wait with different words. Measured
165
+ * on a live QA run: five consecutive waits, 99 seconds, against text that was
166
+ * on screen the whole time. Naming the near misses ends that loop in one
167
+ * result instead of five calls.
168
+ */
169
+ closest?: {
170
+ name: string;
171
+ testID?: string;
172
+ text?: string;
173
+ /** False means it matched but its measured centre is outside the window. */
174
+ onScreen: boolean;
175
+ }[];
125
176
  }
126
177
  /**
127
178
  * Block until an element appears (or disappears), then report how long it took.