@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.
- package/lib/commonjs/highlight-updates/HighlightUpdatesOverlay.js +1 -1
- package/lib/commonjs/highlight-updates/components/HighlightFilterView.js +6 -6
- package/lib/commonjs/highlight-updates/components/IdentifierBadge.js +2 -2
- package/lib/commonjs/highlight-updates/components/RenderCauseBadge.js +7 -7
- package/lib/commonjs/highlight-updates/components/RenderDetailView.js +6 -6
- package/lib/commonjs/highlight-updates/components/RenderHistoryViewer.js +8 -8
- package/lib/commonjs/highlight-updates/components/RenderListItem.js +2 -2
- package/lib/commonjs/highlight-updates/components/RendersCopySettingsView.js +1 -1
- package/lib/commonjs/highlight-updates/components/StatsDisplay.js +1 -1
- package/lib/commonjs/highlight-updates/utils/screenElements.js +272 -70
- package/lib/commonjs/highlight-updates/utils/tapMatch.js +26 -1
- package/lib/commonjs/sync/highlightUpdatesSyncAdapter.js +1 -1
- package/lib/module/highlight-updates/HighlightUpdatesOverlay.js +2 -2
- package/lib/module/highlight-updates/components/HighlightFilterView.js +7 -7
- package/lib/module/highlight-updates/components/IdentifierBadge.js +3 -3
- package/lib/module/highlight-updates/components/RenderCauseBadge.js +8 -8
- package/lib/module/highlight-updates/components/RenderDetailView.js +7 -7
- package/lib/module/highlight-updates/components/RenderHistoryViewer.js +9 -9
- package/lib/module/highlight-updates/components/RenderListItem.js +3 -3
- package/lib/module/highlight-updates/components/RendersCopySettingsView.js +2 -2
- package/lib/module/highlight-updates/components/StatsDisplay.js +2 -2
- package/lib/module/highlight-updates/utils/screenElements.js +272 -70
- package/lib/module/highlight-updates/utils/tapMatch.js +25 -1
- package/lib/module/sync/highlightUpdatesSyncAdapter.js +1 -1
- package/lib/typescript/highlight-updates/components/HighlightFilterView.d.ts.map +1 -1
- package/lib/typescript/highlight-updates/components/IdentifierBadge.d.ts.map +1 -1
- package/lib/typescript/highlight-updates/components/RenderDetailView.d.ts.map +1 -1
- package/lib/typescript/highlight-updates/components/RenderHistoryViewer.d.ts.map +1 -1
- package/lib/typescript/highlight-updates/components/RendersCopySettingsView.d.ts.map +1 -1
- package/lib/typescript/highlight-updates/utils/screenElements.d.ts +52 -1
- package/lib/typescript/highlight-updates/utils/screenElements.d.ts.map +1 -1
- package/lib/typescript/highlight-updates/utils/tapMatch.d.ts +11 -0
- package/lib/typescript/highlight-updates/utils/tapMatch.d.ts.map +1 -1
- package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts +1 -1
- package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts.map +1 -1
- package/package.json +7 -7
|
@@ -46,6 +46,69 @@ function getReactDevToolsHook() {
|
|
|
46
46
|
return global.__REACT_DEVTOOLS_GLOBAL_HOOK__ ?? null;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* React commits observed since this counter was installed.
|
|
51
|
+
*
|
|
52
|
+
* `tapElement` reads it either side of firing a handler to say whether
|
|
53
|
+
* ANYTHING re-rendered afterwards. Without that a tap that matched a button
|
|
54
|
+
* on the screen BENEATH the current one (a stack keeps it mounted) came back
|
|
55
|
+
* `tapped:true` — the handler really did run — and an agent then built its
|
|
56
|
+
* next three turns on a sign-in that never happened. Zero commits inside the
|
|
57
|
+
* settle window is the honest signal: the handler ran and the app did nothing
|
|
58
|
+
* visible with it.
|
|
59
|
+
*
|
|
60
|
+
* Installed once and chained, the same way CommitProfiler wraps the hook;
|
|
61
|
+
* counting is the only work it does on the commit path.
|
|
62
|
+
*/
|
|
63
|
+
let commitCount = 0;
|
|
64
|
+
let commitCounterInstalled = false;
|
|
65
|
+
function ensureCommitCounter() {
|
|
66
|
+
if (commitCounterInstalled) return;
|
|
67
|
+
const hook = getReactDevToolsHook();
|
|
68
|
+
if (!hook) return;
|
|
69
|
+
const original = hook.onCommitFiberRoot;
|
|
70
|
+
hook.onCommitFiberRoot = function (...args) {
|
|
71
|
+
commitCount++;
|
|
72
|
+
return typeof original === "function" ? original.apply(hook, args) : undefined;
|
|
73
|
+
};
|
|
74
|
+
commitCounterInstalled = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** How long a tap waits to see the app react before reporting. */
|
|
78
|
+
const TAP_SETTLE_MS = 250;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Per-walk memo of "is this fiber under Buoy's own overlay". Every candidate
|
|
82
|
+
* asks, and they share ancestors, so the answer is cached along the path
|
|
83
|
+
* walked rather than recomputed 400 levels deep per row.
|
|
84
|
+
*/
|
|
85
|
+
function makeChromeCheck() {
|
|
86
|
+
const memo = new Map();
|
|
87
|
+
return fiber => {
|
|
88
|
+
const path = [];
|
|
89
|
+
let cur = fiber;
|
|
90
|
+
let depth = 0;
|
|
91
|
+
let inChrome = false;
|
|
92
|
+
while (cur && depth < 400) {
|
|
93
|
+
const known = memo.get(cur);
|
|
94
|
+
if (known !== undefined) {
|
|
95
|
+
inChrome = known;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (getProps(cur)?.__buoyDevToolsRoot) {
|
|
99
|
+
inChrome = true;
|
|
100
|
+
memo.set(cur, true);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
path.push(cur);
|
|
104
|
+
cur = cur.return;
|
|
105
|
+
depth++;
|
|
106
|
+
}
|
|
107
|
+
for (const f of path) memo.set(f, inChrome);
|
|
108
|
+
return inChrome;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
49
112
|
/**
|
|
50
113
|
* Collect fiber roots across ALL renderers. A single renderer id is not safe
|
|
51
114
|
* to assume: secondary reconcilers (react-native-skia, react-native-svg, …)
|
|
@@ -264,13 +327,48 @@ function getOwningComponentName(fiber) {
|
|
|
264
327
|
function getProps(fiber) {
|
|
265
328
|
return fiber?.memoizedProps ?? fiber?.pendingProps ?? null;
|
|
266
329
|
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The text of a `children` value, INCLUDING one assembled from several pieces.
|
|
333
|
+
*
|
|
334
|
+
* `<Text>{value} {label}</Text>` compiles to `children: ["1,450", " ", "Points"]`
|
|
335
|
+
* and `Check Back Soon{"\n"}For Available Offers` to three strings. This used
|
|
336
|
+
* to accept a lone string or number and nothing else, so EVERY composed label
|
|
337
|
+
* in an app was invisible to describeScreen, tapElement and waitFor — while
|
|
338
|
+
* native accessibility, which concatenates the same children, could read them.
|
|
339
|
+
* Measured on a live QA run: five waits failed against an empty state that was
|
|
340
|
+
* on screen the whole time, and the agent then reported the app was missing
|
|
341
|
+
* one; a points balance could not be found by its own number.
|
|
342
|
+
*
|
|
343
|
+
* Nested React ELEMENTS are not walked here — `collectDescendantText` owns
|
|
344
|
+
* that, with its stop-at-a-nested-button rule — so this stays a leaf join, and
|
|
345
|
+
* adjacent pieces are concatenated exactly as React renders them.
|
|
346
|
+
*/
|
|
347
|
+
function flattenText(value, depth = 0) {
|
|
348
|
+
if (typeof value === "string") return value;
|
|
349
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
350
|
+
if (Array.isArray(value) && depth < 4) {
|
|
351
|
+
const parts = [];
|
|
352
|
+
for (const child of value) {
|
|
353
|
+
const part = flattenText(child, depth + 1);
|
|
354
|
+
if (part) parts.push(part);
|
|
355
|
+
}
|
|
356
|
+
return parts.length > 0 ? parts.join("") : undefined;
|
|
357
|
+
}
|
|
358
|
+
return undefined;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** What a person reads: newlines and runs of spaces collapse to one space. */
|
|
362
|
+
function normalizeVisibleText(value) {
|
|
363
|
+
return value.replace(/\s+/g, " ").trim();
|
|
364
|
+
}
|
|
267
365
|
function getVisibleText(props) {
|
|
268
366
|
if (!props) return undefined;
|
|
269
367
|
const pick = v => {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
return undefined;
|
|
368
|
+
const flat = flattenText(v);
|
|
369
|
+
if (flat === undefined) return undefined;
|
|
370
|
+
const text = normalizeVisibleText(flat);
|
|
371
|
+
return text ? text.slice(0, 80) : undefined;
|
|
274
372
|
};
|
|
275
373
|
return pick(props.children) ?? pick(props.title) ?? pick(props.label) ?? pick(props.placeholder) ?? undefined;
|
|
276
374
|
}
|
|
@@ -384,7 +482,19 @@ function collectCandidates() {
|
|
|
384
482
|
const roots = getAllFiberRoots();
|
|
385
483
|
if (roots.length === 0) return [];
|
|
386
484
|
const out = [];
|
|
387
|
-
|
|
485
|
+
/**
|
|
486
|
+
* De-dup indexes. Two fibers resolving to the same host node collapse to one
|
|
487
|
+
* row — a Pressable and its inner View, but ALSO the same native view reached
|
|
488
|
+
* twice by the walk, which produces two different `instance` objects for one
|
|
489
|
+
* `nativeTag`. Measured on a live QA run: a 141-row screen dump held 70
|
|
490
|
+
* distinct elements, every one listed twice, and the agent told the user
|
|
491
|
+
* their layout had duplicate elements. Keyed both ways so either collision
|
|
492
|
+
* collapses, and as Maps because the old `out.find(…)` was a linear scan per
|
|
493
|
+
* candidate on a list that can run to hundreds.
|
|
494
|
+
*/
|
|
495
|
+
const byInstance = new Map();
|
|
496
|
+
const byTag = new Map();
|
|
497
|
+
const inBuoyChrome = makeChromeCheck();
|
|
388
498
|
for (const root of roots) {
|
|
389
499
|
traverseFiberTree(root.current, fiber => {
|
|
390
500
|
const props = getProps(fiber);
|
|
@@ -442,25 +552,25 @@ function collectCandidates() {
|
|
|
442
552
|
text = collectDescendantText(fiber, 6);
|
|
443
553
|
}
|
|
444
554
|
const instance = host?.instance ?? null;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (
|
|
448
|
-
|
|
449
|
-
if (existing) {
|
|
450
|
-
|
|
451
|
-
existing.press.longPress = press.longPress;
|
|
452
|
-
}
|
|
453
|
-
if (!existing.valueControl && valueControl) existing.valueControl = valueControl;
|
|
454
|
-
if (!existing.textControl && textControl) existing.textControl = textControl;
|
|
455
|
-
if (!existing.testID && testID) existing.testID = testID;
|
|
456
|
-
if (!existing.label && label) existing.label = label;
|
|
457
|
-
if (!existing.text && text) existing.text = text;
|
|
458
|
-
return;
|
|
555
|
+
const nativeTag = host?.nativeTag ?? null;
|
|
556
|
+
const existing = (instance ? byInstance.get(instance) : undefined) ?? (nativeTag != null ? byTag.get(nativeTag) : undefined);
|
|
557
|
+
if (existing) {
|
|
558
|
+
// Keep the more interactive / better-labelled of the two.
|
|
559
|
+
if (!existing.press && press) existing.press = press;else if (existing.press && press?.longPress && !existing.press.longPress) {
|
|
560
|
+
existing.press.longPress = press.longPress;
|
|
459
561
|
}
|
|
562
|
+
if (!existing.valueControl && valueControl) existing.valueControl = valueControl;
|
|
563
|
+
if (!existing.textControl && textControl) existing.textControl = textControl;
|
|
564
|
+
if (!existing.testID && testID) existing.testID = testID;
|
|
565
|
+
if (!existing.label && label) existing.label = label;
|
|
566
|
+
if (!existing.text && text) existing.text = text;
|
|
567
|
+
// Index the alias too, so a third sighting through either key finds it.
|
|
568
|
+
if (instance && !byInstance.has(instance)) byInstance.set(instance, existing);
|
|
569
|
+
if (nativeTag != null && !byTag.has(nativeTag)) byTag.set(nativeTag, existing);
|
|
570
|
+
return;
|
|
460
571
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
nativeTag: host?.nativeTag ?? null,
|
|
572
|
+
const candidate = {
|
|
573
|
+
nativeTag,
|
|
464
574
|
name,
|
|
465
575
|
role,
|
|
466
576
|
testID,
|
|
@@ -472,12 +582,48 @@ function collectCandidates() {
|
|
|
472
582
|
press,
|
|
473
583
|
valueControl,
|
|
474
584
|
textControl,
|
|
475
|
-
rect: null
|
|
476
|
-
|
|
585
|
+
rect: null,
|
|
586
|
+
buoy: inBuoyChrome(fiber)
|
|
587
|
+
};
|
|
588
|
+
out.push(candidate);
|
|
589
|
+
if (instance) byInstance.set(instance, candidate);
|
|
590
|
+
if (nativeTag != null) byTag.set(nativeTag, candidate);
|
|
477
591
|
});
|
|
478
592
|
}
|
|
479
593
|
return out;
|
|
480
594
|
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Whether the caller named THIS element, rather than described one like it.
|
|
598
|
+
* An exact nativeTag or testID is a deliberate choice, and is honoured even
|
|
599
|
+
* inside Buoy's own chrome — that is how a test or an editor agent drives a
|
|
600
|
+
* Buoy tool on purpose. A fuzzy `query` is not.
|
|
601
|
+
*/
|
|
602
|
+
function explicitlyTargets(c, params) {
|
|
603
|
+
if (params.nativeTag != null) return c.nativeTag === params.nativeTag;
|
|
604
|
+
if (params.testID && c.testID) return c.testID.toLowerCase() === params.testID.toLowerCase();
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The app under test, not the tool looking at it.
|
|
610
|
+
*
|
|
611
|
+
* Buoy runs INSIDE the app, so its own sheet is in the same fiber tree as the
|
|
612
|
+
* screens it inspects — and Ask Buoy's activity log echoes whatever the model
|
|
613
|
+
* is searching for back as visible text. Left in, that echo was the best
|
|
614
|
+
* match for the next fuzzy tap or wait, so every text-driven action landed on
|
|
615
|
+
* Buoy's own panel and nothing in the app moved. The agent diagnosed it
|
|
616
|
+
* itself, four chats in ("every text-based tap and wait I've done this
|
|
617
|
+
* session has been landing on Buoy's panel"), and still could not escape it.
|
|
618
|
+
*
|
|
619
|
+
* `includeBuoy` lists the chrome for callers that want it; an exact id gets
|
|
620
|
+
* through regardless (see `explicitlyTargets`).
|
|
621
|
+
*/
|
|
622
|
+
function appCandidates(params) {
|
|
623
|
+
const all = collectCandidates();
|
|
624
|
+
if (params.includeBuoy) return all;
|
|
625
|
+
return all.filter(c => !c.buoy || explicitlyTargets(c, params));
|
|
626
|
+
}
|
|
481
627
|
async function measureCandidates(candidates) {
|
|
482
628
|
await Promise.all(candidates.map(async c => {
|
|
483
629
|
if (c.instance) c.rect = await measureInstance(c.instance);
|
|
@@ -486,8 +632,10 @@ async function measureCandidates(candidates) {
|
|
|
486
632
|
|
|
487
633
|
// --- describeScreen -------------------------------------------------------
|
|
488
634
|
|
|
489
|
-
async function describeScreen() {
|
|
490
|
-
const
|
|
635
|
+
async function describeScreen(params = {}) {
|
|
636
|
+
const all = collectCandidates();
|
|
637
|
+
const candidates = params.includeBuoy ? all : all.filter(c => !c.buoy);
|
|
638
|
+
const hiddenBuoy = all.length - candidates.length;
|
|
491
639
|
await measureCandidates(candidates);
|
|
492
640
|
const win = _reactNative.Dimensions.get("window");
|
|
493
641
|
const screenW = Math.round(win.width) || 1;
|
|
@@ -549,7 +697,10 @@ async function describeScreen() {
|
|
|
549
697
|
height: screenH
|
|
550
698
|
},
|
|
551
699
|
count: elements.length,
|
|
552
|
-
elements
|
|
700
|
+
elements,
|
|
701
|
+
...(hiddenBuoy > 0 ? {
|
|
702
|
+
hiddenBuoy
|
|
703
|
+
} : {})
|
|
553
704
|
};
|
|
554
705
|
}
|
|
555
706
|
function round2(n) {
|
|
@@ -605,7 +756,7 @@ function makeSyntheticPressEvent(rect) {
|
|
|
605
756
|
};
|
|
606
757
|
}
|
|
607
758
|
async function tapElement(params) {
|
|
608
|
-
const candidates =
|
|
759
|
+
const candidates = appCandidates(params);
|
|
609
760
|
if (candidates.length === 0) {
|
|
610
761
|
return {
|
|
611
762
|
tapped: false,
|
|
@@ -654,6 +805,21 @@ async function tapElement(params) {
|
|
|
654
805
|
// sit below 15 text-score peers.
|
|
655
806
|
const target = top.find(r => isInteractive(r.c)) ?? ranked.find(r => isInteractive(r.c)) ?? top[0];
|
|
656
807
|
const c = target.c;
|
|
808
|
+
|
|
809
|
+
// Everything else that matched just as well. A fuzzy "SIGN IN" on a stack
|
|
810
|
+
// that still has the home screen mounted underneath has two equal answers,
|
|
811
|
+
// and the caller has to know that before trusting whichever one fired.
|
|
812
|
+
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;
|
|
813
|
+
const alsoMatched = top.filter(r => r !== target && isInteractive(r.c) && r.score === target.score)
|
|
814
|
+
// The label Text inside the tapped button matches its own button's text
|
|
815
|
+
// and inherits its onPress: same control, not a rival.
|
|
816
|
+
.filter(r => !sameControl(r.c, c)).filter((r, i, arr) => arr.findIndex(o => sameControl(o.c, r.c)) === i).slice(0, 3).map(r => ({
|
|
817
|
+
nativeTag: r.c.nativeTag,
|
|
818
|
+
name: r.c.name,
|
|
819
|
+
testID: r.c.testID,
|
|
820
|
+
text: r.c.text,
|
|
821
|
+
tap: tapPoint(r.c.rect, screenW, screenH)
|
|
822
|
+
}));
|
|
657
823
|
if (!isInteractive(c)) {
|
|
658
824
|
return {
|
|
659
825
|
tapped: false,
|
|
@@ -691,6 +857,28 @@ async function tapElement(params) {
|
|
|
691
857
|
label: c.label,
|
|
692
858
|
text: c.text
|
|
693
859
|
};
|
|
860
|
+
|
|
861
|
+
// Fire, then watch. The handler is the app's own; what it did — or did
|
|
862
|
+
// not do — shows up as commits (or none) in the settle window.
|
|
863
|
+
ensureCommitCounter();
|
|
864
|
+
// Read BEFORE the handler runs: a synchronous setState commits inside it.
|
|
865
|
+
const c0 = commitCount;
|
|
866
|
+
const t0 = Date.now();
|
|
867
|
+
const fired = async matched => {
|
|
868
|
+
await new Promise(resolve => setTimeout(resolve, TAP_SETTLE_MS));
|
|
869
|
+
return {
|
|
870
|
+
tapped: true,
|
|
871
|
+
scrolled,
|
|
872
|
+
matched,
|
|
873
|
+
effect: {
|
|
874
|
+
commits: commitCount - c0,
|
|
875
|
+
observedMs: Date.now() - t0
|
|
876
|
+
},
|
|
877
|
+
...(alsoMatched.length ? {
|
|
878
|
+
alsoMatched
|
|
879
|
+
} : {})
|
|
880
|
+
};
|
|
881
|
+
};
|
|
694
882
|
try {
|
|
695
883
|
if (c.press) {
|
|
696
884
|
if (params.longPress) {
|
|
@@ -707,24 +895,16 @@ async function tapElement(params) {
|
|
|
707
895
|
};
|
|
708
896
|
}
|
|
709
897
|
lp(makeSyntheticPressEvent(c.rect));
|
|
710
|
-
return {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
...base,
|
|
715
|
-
via: "onLongPress"
|
|
716
|
-
}
|
|
717
|
-
};
|
|
898
|
+
return await fired({
|
|
899
|
+
...base,
|
|
900
|
+
via: "onLongPress"
|
|
901
|
+
});
|
|
718
902
|
}
|
|
719
903
|
c.press.handler(makeSyntheticPressEvent(c.rect));
|
|
720
|
-
return {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
...base,
|
|
725
|
-
via: c.press.kind
|
|
726
|
-
}
|
|
727
|
-
};
|
|
904
|
+
return await fired({
|
|
905
|
+
...base,
|
|
906
|
+
via: c.press.kind
|
|
907
|
+
});
|
|
728
908
|
}
|
|
729
909
|
if (c.valueControl) {
|
|
730
910
|
const vc = c.valueControl;
|
|
@@ -742,28 +922,20 @@ async function tapElement(params) {
|
|
|
742
922
|
}
|
|
743
923
|
}
|
|
744
924
|
vc.handler(next);
|
|
745
|
-
return {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
via: "onValueChange",
|
|
751
|
-
value: next
|
|
752
|
-
}
|
|
753
|
-
};
|
|
925
|
+
return await fired({
|
|
926
|
+
...base,
|
|
927
|
+
via: "onValueChange",
|
|
928
|
+
value: next
|
|
929
|
+
});
|
|
754
930
|
}
|
|
755
931
|
// textControl
|
|
756
932
|
const next = params.text ?? (typeof params.value === "string" ? params.value : "");
|
|
757
933
|
c.textControl.handler(next);
|
|
758
|
-
return {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
via: "onChangeText",
|
|
764
|
-
value: next
|
|
765
|
-
}
|
|
766
|
-
};
|
|
934
|
+
return await fired({
|
|
935
|
+
...base,
|
|
936
|
+
via: "onChangeText",
|
|
937
|
+
value: next
|
|
938
|
+
});
|
|
767
939
|
} catch (e) {
|
|
768
940
|
return {
|
|
769
941
|
tapped: false,
|
|
@@ -812,7 +984,9 @@ const WAIT_DEFAULT_POLL_MS = 150;
|
|
|
812
984
|
* waiting for.
|
|
813
985
|
*/
|
|
814
986
|
async function waitFor(params) {
|
|
815
|
-
|
|
987
|
+
// Coerced, not optional-chained: a model sends `query: 1450` often enough,
|
|
988
|
+
// and `.trim()` on a number throws before the wait ever starts.
|
|
989
|
+
const hasTarget = params.nativeTag != null || !!String(params.testID ?? "").trim() || !!String(params.query ?? "").trim();
|
|
816
990
|
if (!hasTarget) {
|
|
817
991
|
return {
|
|
818
992
|
ok: false,
|
|
@@ -828,7 +1002,10 @@ async function waitFor(params) {
|
|
|
828
1002
|
let polls = 0;
|
|
829
1003
|
for (;;) {
|
|
830
1004
|
polls += 1;
|
|
831
|
-
const
|
|
1005
|
+
const {
|
|
1006
|
+
hit,
|
|
1007
|
+
closest
|
|
1008
|
+
} = await findOnScreen(params);
|
|
832
1009
|
if (wantGone ? !hit : !!hit) {
|
|
833
1010
|
return {
|
|
834
1011
|
ok: true,
|
|
@@ -838,12 +1015,18 @@ async function waitFor(params) {
|
|
|
838
1015
|
};
|
|
839
1016
|
}
|
|
840
1017
|
if (Date.now() - started >= timeoutMs) {
|
|
1018
|
+
const nearMisses = closest?.length ? closest : undefined;
|
|
1019
|
+
// Say which of the two failures this is. "Nothing is called that" and
|
|
1020
|
+
// "it exists but is not visible" need opposite next moves, and a caller
|
|
1021
|
+
// that cannot tell them apart tries the same wait with new words.
|
|
1022
|
+
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.`;
|
|
841
1023
|
return {
|
|
842
1024
|
ok: false,
|
|
843
1025
|
waitedMs: Date.now() - started,
|
|
844
1026
|
polls,
|
|
845
|
-
reason:
|
|
846
|
-
matched: hit ?? undefined
|
|
1027
|
+
reason: why,
|
|
1028
|
+
matched: hit ?? undefined,
|
|
1029
|
+
closest: nearMisses
|
|
847
1030
|
};
|
|
848
1031
|
}
|
|
849
1032
|
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
@@ -852,22 +1035,26 @@ async function waitFor(params) {
|
|
|
852
1035
|
|
|
853
1036
|
/** One scan: rank by identity, then confirm the best match is really visible. */
|
|
854
1037
|
async function findOnScreen(params) {
|
|
855
|
-
const ranked =
|
|
1038
|
+
const ranked = appCandidates(params).map(c => ({
|
|
856
1039
|
c,
|
|
857
1040
|
score: matchScore(c, params)
|
|
858
1041
|
})).filter(r => r.score > 0).sort((a, b) => b.score - a.score)
|
|
859
1042
|
// Only the plausible few get measured; a broad query can match dozens.
|
|
860
1043
|
.slice(0, 8);
|
|
861
|
-
if (ranked.length === 0) return
|
|
1044
|
+
if (ranked.length === 0) return {
|
|
1045
|
+
hit: null,
|
|
1046
|
+
closest: undefined
|
|
1047
|
+
};
|
|
862
1048
|
await measureCandidates(ranked.map(r => r.c));
|
|
863
1049
|
const win = _reactNative.Dimensions.get("window");
|
|
864
1050
|
const screenW = Math.round(win.width) || 1;
|
|
865
1051
|
const screenH = Math.round(win.height) || 1;
|
|
1052
|
+
let hit = null;
|
|
866
1053
|
for (const {
|
|
867
1054
|
c
|
|
868
1055
|
} of ranked) {
|
|
869
1056
|
if (!(0, _tapMatch.isRectCenterOnScreen)(c.rect, screenW, screenH)) continue;
|
|
870
|
-
|
|
1057
|
+
hit = {
|
|
871
1058
|
nativeTag: c.nativeTag,
|
|
872
1059
|
name: c.name,
|
|
873
1060
|
testID: c.testID,
|
|
@@ -875,8 +1062,23 @@ async function findOnScreen(params) {
|
|
|
875
1062
|
text: c.text,
|
|
876
1063
|
interactive: !!(c.press || c.valueControl || c.textControl)
|
|
877
1064
|
};
|
|
1065
|
+
break;
|
|
878
1066
|
}
|
|
879
|
-
|
|
1067
|
+
|
|
1068
|
+
// Built from the same measured ranking, so a failed wait can say what it
|
|
1069
|
+
// nearly matched and whether that thing was visible.
|
|
1070
|
+
const closest = ranked.slice(0, 3).map(({
|
|
1071
|
+
c
|
|
1072
|
+
}) => ({
|
|
1073
|
+
name: c.name,
|
|
1074
|
+
testID: c.testID,
|
|
1075
|
+
text: c.text,
|
|
1076
|
+
onScreen: (0, _tapMatch.isRectCenterOnScreen)(c.rect, screenW, screenH)
|
|
1077
|
+
}));
|
|
1078
|
+
return {
|
|
1079
|
+
hit,
|
|
1080
|
+
closest
|
|
1081
|
+
};
|
|
880
1082
|
}
|
|
881
1083
|
|
|
882
1084
|
// --- resolveTouchTarget ---------------------------------------------------
|
|
@@ -5,6 +5,7 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.isOnScreenTap = isOnScreenTap;
|
|
7
7
|
exports.isRectCenterOnScreen = isRectCenterOnScreen;
|
|
8
|
+
exports.looseText = looseText;
|
|
8
9
|
exports.scoreTapMatch = scoreTapMatch;
|
|
9
10
|
/**
|
|
10
11
|
* Shared tap-target scoring for describe_screen / tap_element.
|
|
@@ -33,15 +34,39 @@ function isRectCenterOnScreen(rect, screenW, screenH) {
|
|
|
33
34
|
const slackY = screenH * 0.1;
|
|
34
35
|
return cx >= -slackX && cx <= screenW + slackX && cy >= -slackY && cy <= screenH + slackY;
|
|
35
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The comparable form of a piece of text.
|
|
40
|
+
*
|
|
41
|
+
* "1,450", `1450` and "1,450 Points" are one request as far as a person is
|
|
42
|
+
* concerned. Measured on a live QA run: waits for BOTH "1,450" and 1450 failed
|
|
43
|
+
* against a balance that was on screen, because the thousands separator made
|
|
44
|
+
* every comparison miss. Lowercased, whitespace collapsed, digit-group
|
|
45
|
+
* separators dropped. A number arrives here as a number often enough that the
|
|
46
|
+
* coercion is not optional — `.trim()` on one is a crash.
|
|
47
|
+
*/
|
|
48
|
+
function looseText(value) {
|
|
49
|
+
return String(value ?? "").toLowerCase().replace(/\s+/g, " ").replace(/(\d)[,\u00a0\u202f](?=\d{3}\b)/g, "$1").trim();
|
|
50
|
+
}
|
|
36
51
|
function scoreTapMatch(c, params) {
|
|
37
52
|
if (params.nativeTag != null && c.nativeTag === params.nativeTag) return 1000;
|
|
38
|
-
const q = (params.query ?? params.testID ?? "").trim().toLowerCase();
|
|
53
|
+
const q = String(params.query ?? params.testID ?? "").trim().toLowerCase();
|
|
39
54
|
if (!q) return 0;
|
|
40
55
|
const fields = [c.testID, c.label, c.text, c.name].filter(Boolean).map(s => s.toLowerCase());
|
|
41
56
|
let best = 0;
|
|
42
57
|
for (const f of fields) {
|
|
43
58
|
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);
|
|
44
59
|
}
|
|
60
|
+
// Same tiers on the loose form, one notch lower so an exact hit still wins
|
|
61
|
+
// when both match. Skipped when the loose query is empty, or `includes("")`
|
|
62
|
+
// would score every candidate on the screen.
|
|
63
|
+
const looseQuery = looseText(q);
|
|
64
|
+
if (looseQuery) {
|
|
65
|
+
for (const f of fields) {
|
|
66
|
+
const loose = looseText(f);
|
|
67
|
+
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);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
45
70
|
if (params.testID && c.testID && c.testID.toLowerCase() === params.testID.toLowerCase()) {
|
|
46
71
|
best = Math.max(best, 200);
|
|
47
72
|
}
|
|
@@ -205,7 +205,7 @@ const highlightUpdatesSyncAdapter = exports.highlightUpdatesSyncAdapter = {
|
|
|
205
205
|
// Walk the live fiber tree and return the current screen's meaningful /
|
|
206
206
|
// interactive elements, each with normalized tap coordinates. Powers the
|
|
207
207
|
// MCP `describe_screen` reader — no screenshot, no silent-tracking needed.
|
|
208
|
-
describeScreen:
|
|
208
|
+
describeScreen: params => (0, _screenElements.describeScreen)(params ?? {}),
|
|
209
209
|
// Tap an element purely in JS: resolve it (by nativeTag / testID / query),
|
|
210
210
|
// find the nearest onPress up its fiber chain, and invoke it with a
|
|
211
211
|
// synthetic press event. Powers the MCP `tap_element` action.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from "react";
|
|
12
12
|
import { View, Text, StyleSheet } from "react-native";
|
|
13
|
-
import { night } from "@buoy-gg/shared-ui";
|
|
13
|
+
import { night, monoFont } from "@buoy-gg/shared-ui";
|
|
14
14
|
import HighlightUpdatesController from "./utils/HighlightUpdatesController";
|
|
15
15
|
import { PerformanceLogger, markOverlayRendered } from "./utils/PerformanceLogger";
|
|
16
16
|
import { RenderTracker } from "./utils/RenderTracker";
|
|
@@ -268,7 +268,7 @@ const styles = StyleSheet.create({
|
|
|
268
268
|
color: night.color.accent,
|
|
269
269
|
fontSize: 11,
|
|
270
270
|
fontWeight: "600",
|
|
271
|
-
fontFamily:
|
|
271
|
+
fontFamily: monoFont
|
|
272
272
|
}
|
|
273
273
|
});
|
|
274
274
|
export default HighlightUpdatesOverlay;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import React, { useState, useCallback } from "react";
|
|
12
12
|
import { View, Text, TouchableOpacity, TextInput, StyleSheet, ScrollView, Modal, Pressable, Switch } from "react-native";
|
|
13
|
-
import { Eye, Filter, Plus, X, Box, Settings, Hash, SectionHeader, night } from "@buoy-gg/shared-ui";
|
|
13
|
+
import { Eye, Filter, Plus, X, Box, Settings, Hash, SectionHeader, night, monoFont } from "@buoy-gg/shared-ui";
|
|
14
14
|
import { IdentifierBadge, IDENTIFIER_CONFIG } from "./IdentifierBadge";
|
|
15
15
|
import { detectedItemTestID, patternChipTestID, settingTestID } from "../utils/testIDs";
|
|
16
16
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
@@ -1095,7 +1095,7 @@ const styles = StyleSheet.create({
|
|
|
1095
1095
|
patternChipValue: {
|
|
1096
1096
|
fontSize: 11,
|
|
1097
1097
|
color: night.color.text,
|
|
1098
|
-
fontFamily:
|
|
1098
|
+
fontFamily: monoFont,
|
|
1099
1099
|
flexShrink: 1
|
|
1100
1100
|
},
|
|
1101
1101
|
patternChipX: {
|
|
@@ -1238,7 +1238,7 @@ const styles = StyleSheet.create({
|
|
|
1238
1238
|
detectedItemText: {
|
|
1239
1239
|
fontSize: 11,
|
|
1240
1240
|
color: night.color.text,
|
|
1241
|
-
fontFamily:
|
|
1241
|
+
fontFamily: monoFont
|
|
1242
1242
|
},
|
|
1243
1243
|
emptyText: {
|
|
1244
1244
|
fontSize: 11,
|
|
@@ -1256,7 +1256,7 @@ const styles = StyleSheet.create({
|
|
|
1256
1256
|
fontSize: 11,
|
|
1257
1257
|
color: night.color.textTertiary,
|
|
1258
1258
|
lineHeight: 18,
|
|
1259
|
-
fontFamily:
|
|
1259
|
+
fontFamily: monoFont
|
|
1260
1260
|
},
|
|
1261
1261
|
howItWorksBold: {
|
|
1262
1262
|
fontWeight: "700",
|
|
@@ -1356,7 +1356,7 @@ const styles = StyleSheet.create({
|
|
|
1356
1356
|
fontSize: 12,
|
|
1357
1357
|
fontWeight: "700",
|
|
1358
1358
|
color: night.color.textTertiary,
|
|
1359
|
-
fontFamily:
|
|
1359
|
+
fontFamily: monoFont
|
|
1360
1360
|
},
|
|
1361
1361
|
settingDescription: {
|
|
1362
1362
|
fontSize: 11,
|
|
@@ -1399,7 +1399,7 @@ const styles = StyleSheet.create({
|
|
|
1399
1399
|
fontSize: 12,
|
|
1400
1400
|
fontWeight: "500",
|
|
1401
1401
|
color: night.color.textSecondary,
|
|
1402
|
-
fontFamily:
|
|
1402
|
+
fontFamily: monoFont
|
|
1403
1403
|
},
|
|
1404
1404
|
batchSizePresetTextActive: {
|
|
1405
1405
|
color: night.color.textTertiary,
|
|
@@ -1438,7 +1438,7 @@ const styles = StyleSheet.create({
|
|
|
1438
1438
|
fontSize: 14,
|
|
1439
1439
|
fontWeight: "600",
|
|
1440
1440
|
color: night.color.text,
|
|
1441
|
-
fontFamily:
|
|
1441
|
+
fontFamily: monoFont,
|
|
1442
1442
|
textAlign: "center"
|
|
1443
1443
|
},
|
|
1444
1444
|
renderCountSeparator: {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import React from "react";
|
|
11
11
|
import { View, Text, StyleSheet } from "react-native";
|
|
12
|
-
import { Box, Hash, Layers, Eye, Search, FileCode, buoyColors, night } from "@buoy-gg/shared-ui";
|
|
12
|
+
import { Box, Hash, Layers, Eye, Search, FileCode, buoyColors, night, monoFont } from "@buoy-gg/shared-ui";
|
|
13
13
|
|
|
14
14
|
// All possible identifier types
|
|
15
15
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
@@ -198,7 +198,7 @@ const styles = StyleSheet.create({
|
|
|
198
198
|
value: {
|
|
199
199
|
fontSize: 12,
|
|
200
200
|
color: night.color.text,
|
|
201
|
-
fontFamily:
|
|
201
|
+
fontFamily: monoFont,
|
|
202
202
|
flex: 1
|
|
203
203
|
},
|
|
204
204
|
valueCompact: {
|
|
@@ -231,7 +231,7 @@ const styles = StyleSheet.create({
|
|
|
231
231
|
// Value only
|
|
232
232
|
valueOnly: {
|
|
233
233
|
fontSize: 12,
|
|
234
|
-
fontFamily:
|
|
234
|
+
fontFamily: monoFont
|
|
235
235
|
},
|
|
236
236
|
valueOnlyCompact: {
|
|
237
237
|
fontSize: 11
|