@buoy-gg/highlight-updates 7.0.30 → 7.0.32
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/components/HighlightUpdatesModal.js +1 -0
- package/lib/commonjs/highlight-updates/utils/rawEventEmitter.js +50 -0
- package/lib/commonjs/highlight-updates/utils/screenElements.js +270 -0
- package/lib/commonjs/highlight-updates/utils/touchCapture.js +277 -0
- package/lib/commonjs/sync/highlightUpdatesSyncAdapter.js +23 -1
- package/lib/module/highlight-updates/components/HighlightUpdatesModal.js +2 -1
- package/lib/module/highlight-updates/utils/rawEventEmitter.js +46 -0
- package/lib/module/highlight-updates/utils/screenElements.js +268 -0
- package/lib/module/highlight-updates/utils/touchCapture.js +270 -0
- package/lib/module/sync/highlightUpdatesSyncAdapter.js +24 -2
- package/lib/typescript/highlight-updates/components/HighlightUpdatesModal.d.ts.map +1 -1
- package/lib/typescript/highlight-updates/utils/rawEventEmitter.d.ts +37 -0
- package/lib/typescript/highlight-updates/utils/rawEventEmitter.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/screenElements.d.ts +76 -0
- package/lib/typescript/highlight-updates/utils/screenElements.d.ts.map +1 -1
- package/lib/typescript/highlight-updates/utils/touchCapture.d.ts +66 -0
- package/lib/typescript/highlight-updates/utils/touchCapture.d.ts.map +1 -0
- package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts +18 -0
- package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts.map +1 -1
- package/package.json +7 -7
|
@@ -557,6 +557,7 @@ function HighlightUpdatesModal({
|
|
|
557
557
|
style: styles.inlineRoot,
|
|
558
558
|
children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
|
|
559
559
|
style: styles.inlineHeader,
|
|
560
|
+
..._sharedUi.windowDragProps,
|
|
560
561
|
children: renderHeaderContent()
|
|
561
562
|
}), body, footerNode]
|
|
562
563
|
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.resolveRawEventEmitter = resolveRawEventEmitter;
|
|
7
|
+
/**
|
|
8
|
+
* The one guarded require for React Native's RawEventEmitter.
|
|
9
|
+
*
|
|
10
|
+
* `RawEventEmitter` mirrors every event React Native dispatches BEFORE React's
|
|
11
|
+
* plugins or components see it, which makes it the only way to observe what a
|
|
12
|
+
* HUMAN is touching without instrumenting the app: Buoy mounts as a sibling of
|
|
13
|
+
* the app tree, not a parent, so there is no root View to put a capture
|
|
14
|
+
* responder on.
|
|
15
|
+
*
|
|
16
|
+
* Two things this file is careful about, both learned the hard way in
|
|
17
|
+
* focus-inspector's copy:
|
|
18
|
+
*
|
|
19
|
+
* 1. It is a DEEP import that RN's export map warns about and could move in any
|
|
20
|
+
* release. The failure has to degrade to "capture unavailable" rather than
|
|
21
|
+
* taking the host app's bundle down — and Metro only treats a require as
|
|
22
|
+
* optional when it sits LEXICALLY inside a try statement, so wrapping it in
|
|
23
|
+
* a helper or an arrow makes Metro hard-fail the bundle instead.
|
|
24
|
+
* 2. Keeping it in its own module gives the unit suite a seam: Node's `require`
|
|
25
|
+
* ignores Vitest's module mocks, so the emitter can only be faked by mocking
|
|
26
|
+
* a module reached with a real `import`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
function resolveRawEventEmitter() {
|
|
30
|
+
let mod;
|
|
31
|
+
try {
|
|
32
|
+
mod = require("react-native/Libraries/Core/RawEventEmitter");
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return {
|
|
35
|
+
emitter: null,
|
|
36
|
+
reason: e?.message ?? "RawEventEmitter is unavailable"
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const candidate = mod?.default ?? mod;
|
|
40
|
+
if (typeof candidate?.addListener !== "function") {
|
|
41
|
+
return {
|
|
42
|
+
emitter: null,
|
|
43
|
+
reason: "RawEventEmitter has no addListener"
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
emitter: candidate,
|
|
48
|
+
reason: null
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -4,7 +4,9 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
4
4
|
value: true
|
|
5
5
|
});
|
|
6
6
|
exports.describeScreen = describeScreen;
|
|
7
|
+
exports.resolveTouchTarget = resolveTouchTarget;
|
|
7
8
|
exports.tapElement = tapElement;
|
|
9
|
+
exports.waitFor = waitFor;
|
|
8
10
|
var _reactNative = require("react-native");
|
|
9
11
|
var _tapMatch = require("./tapMatch");
|
|
10
12
|
/**
|
|
@@ -773,4 +775,272 @@ async function tapElement(params) {
|
|
|
773
775
|
}
|
|
774
776
|
};
|
|
775
777
|
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// --- waitFor --------------------------------------------------------------
|
|
781
|
+
|
|
782
|
+
const WAIT_DEFAULT_MS = 5_000;
|
|
783
|
+
const WAIT_MAX_MS = 30_000;
|
|
784
|
+
const WAIT_MIN_POLL_MS = 50;
|
|
785
|
+
const WAIT_DEFAULT_POLL_MS = 150;
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Block until an element appears (or disappears), then report how long it took.
|
|
789
|
+
*
|
|
790
|
+
* WHY THIS EXISTS. `navigate` returns the moment the route is pushed, not when
|
|
791
|
+
* the new screen has data on it, so "go to /shop then tap Fire Grill" reliably
|
|
792
|
+
* tapped a loading skeleton. A remote driver solves that with a wait step
|
|
793
|
+
* (MCP's `run_flow` has `waitFor`/`waitMs`); anything driving the tools from
|
|
794
|
+
* INSIDE the app had nothing, and its only option was to re-run
|
|
795
|
+
* `describeScreen` in a loop — which for an agent means burning turns against a
|
|
796
|
+
* step budget, and for a Scenario means a fixed sleep that is either flaky or
|
|
797
|
+
* slow.
|
|
798
|
+
*
|
|
799
|
+
* MATCHING IS `tapElement`'S, EXACTLY. Same `scoreTapMatch`, same fields, so
|
|
800
|
+
* "wait for X" and "tap X" can never disagree about what X is. A wait that
|
|
801
|
+
* resolves on an element the tap then cannot find is worse than no wait.
|
|
802
|
+
*
|
|
803
|
+
* PRESENCE MEANS ON SCREEN. An identity scan alone would resolve against a
|
|
804
|
+
* previous Expo Router screen still mounted behind the current one — the exact
|
|
805
|
+
* false positive `describeScreen` drops by rect. So a candidate that matches by
|
|
806
|
+
* name is measured before it counts, and only the matches are measured: the
|
|
807
|
+
* per-poll cost stays one fiber walk plus a handful of `measureInWindow`
|
|
808
|
+
* calls, not a full `describeScreen`.
|
|
809
|
+
*
|
|
810
|
+
* The poll floor is deliberate. This runs on the JS thread the app is drawing
|
|
811
|
+
* on, and a caller asking for `pollMs: 0` would starve the very render it is
|
|
812
|
+
* waiting for.
|
|
813
|
+
*/
|
|
814
|
+
async function waitFor(params) {
|
|
815
|
+
const hasTarget = params.nativeTag != null || !!params.testID?.trim() || !!params.query?.trim();
|
|
816
|
+
if (!hasTarget) {
|
|
817
|
+
return {
|
|
818
|
+
ok: false,
|
|
819
|
+
waitedMs: 0,
|
|
820
|
+
polls: 0,
|
|
821
|
+
reason: "Nothing to wait for — pass nativeTag, testID or query."
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
const timeoutMs = Math.min(Math.max(params.timeoutMs ?? WAIT_DEFAULT_MS, 0), WAIT_MAX_MS);
|
|
825
|
+
const pollMs = Math.max(params.pollMs ?? WAIT_DEFAULT_POLL_MS, WAIT_MIN_POLL_MS);
|
|
826
|
+
const wantGone = params.gone === true;
|
|
827
|
+
const started = Date.now();
|
|
828
|
+
let polls = 0;
|
|
829
|
+
for (;;) {
|
|
830
|
+
polls += 1;
|
|
831
|
+
const hit = await findOnScreen(params);
|
|
832
|
+
if (wantGone ? !hit : !!hit) {
|
|
833
|
+
return {
|
|
834
|
+
ok: true,
|
|
835
|
+
waitedMs: Date.now() - started,
|
|
836
|
+
polls,
|
|
837
|
+
matched: hit ?? undefined
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
if (Date.now() - started >= timeoutMs) {
|
|
841
|
+
return {
|
|
842
|
+
ok: false,
|
|
843
|
+
waitedMs: Date.now() - started,
|
|
844
|
+
polls,
|
|
845
|
+
reason: wantGone ? `Still on screen after ${timeoutMs}ms.` : `Not on screen after ${timeoutMs}ms. Call describeScreen to see what is.`,
|
|
846
|
+
matched: hit ?? undefined
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** One scan: rank by identity, then confirm the best match is really visible. */
|
|
854
|
+
async function findOnScreen(params) {
|
|
855
|
+
const ranked = collectCandidates().map(c => ({
|
|
856
|
+
c,
|
|
857
|
+
score: matchScore(c, params)
|
|
858
|
+
})).filter(r => r.score > 0).sort((a, b) => b.score - a.score)
|
|
859
|
+
// Only the plausible few get measured; a broad query can match dozens.
|
|
860
|
+
.slice(0, 8);
|
|
861
|
+
if (ranked.length === 0) return null;
|
|
862
|
+
await measureCandidates(ranked.map(r => r.c));
|
|
863
|
+
const win = _reactNative.Dimensions.get("window");
|
|
864
|
+
const screenW = Math.round(win.width) || 1;
|
|
865
|
+
const screenH = Math.round(win.height) || 1;
|
|
866
|
+
for (const {
|
|
867
|
+
c
|
|
868
|
+
} of ranked) {
|
|
869
|
+
if (!(0, _tapMatch.isRectCenterOnScreen)(c.rect, screenW, screenH)) continue;
|
|
870
|
+
return {
|
|
871
|
+
nativeTag: c.nativeTag,
|
|
872
|
+
name: c.name,
|
|
873
|
+
testID: c.testID,
|
|
874
|
+
label: c.label,
|
|
875
|
+
text: c.text,
|
|
876
|
+
interactive: !!(c.press || c.valueControl || c.textControl)
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// --- resolveTouchTarget ---------------------------------------------------
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* What a recorded touch landed on, in the vocabulary `tapElement` searches by.
|
|
886
|
+
*
|
|
887
|
+
* Deliberately the SAME fields `describeScreen` emits: a macro recorded from a
|
|
888
|
+
* human's finger has to replay through `tapElement`, and the only way to
|
|
889
|
+
* guarantee that is for the recorder to name the element exactly the way the
|
|
890
|
+
* matcher will look for it. Anything richer here is a selector that records
|
|
891
|
+
* fine and then can't be found again.
|
|
892
|
+
*/
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Resolve the native tag from a raw touch event to the element a HUMAN would
|
|
896
|
+
* say they tapped.
|
|
897
|
+
*
|
|
898
|
+
* Synchronous on purpose. The tag arrives on `topTouchStart`, and by the time
|
|
899
|
+
* an async resolve came back the press handler may already have navigated —
|
|
900
|
+
* leaving the recorder to name the element on the screen it moved TO. Everything
|
|
901
|
+
* here is a fiber walk (no measure), which is what makes that affordable.
|
|
902
|
+
*
|
|
903
|
+
* Returns null when the tag belongs to nothing interesting (the background, a
|
|
904
|
+
* scroll surface): the recorder treats that as "not a step", which is exactly
|
|
905
|
+
* right — a tap on empty space is not something to replay.
|
|
906
|
+
*/
|
|
907
|
+
/**
|
|
908
|
+
* Is this fiber inside Buoy's own overlay?
|
|
909
|
+
*
|
|
910
|
+
* `FloatingDevTools` marks its single root View with `__buoyDevToolsRoot`, and
|
|
911
|
+
* everything the tool draws — the bubble, the dial, every modal, every tool
|
|
912
|
+
* overlay — is under it. Walking the whole chain (rather than a fixed depth) is
|
|
913
|
+
* the point: a control deep inside a tool's modal is still the tool.
|
|
914
|
+
*/
|
|
915
|
+
function isBuoyChrome(fiber) {
|
|
916
|
+
let cur = fiber;
|
|
917
|
+
let depth = 0;
|
|
918
|
+
while (cur && depth < 400) {
|
|
919
|
+
if (getProps(cur)?.__buoyDevToolsRoot) return true;
|
|
920
|
+
cur = cur.return;
|
|
921
|
+
depth++;
|
|
922
|
+
}
|
|
923
|
+
return false;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Climb from the node that was touched to the element that OWNS the press.
|
|
928
|
+
*
|
|
929
|
+
* A finger lands on the deepest host view, which for a button is the `<Text>`
|
|
930
|
+
* inside it. That Text is a legitimate candidate — `findPressHandler` walks up
|
|
931
|
+
* and finds the button's `onPress`, so it looks drivable — but it carries the
|
|
932
|
+
* TEXT's identity: no testID, and "visible text" that is whatever string
|
|
933
|
+
* happens to be in that node.
|
|
934
|
+
*
|
|
935
|
+
* Left alone, every recorded step is named after a fragment of copy and
|
|
936
|
+
* replayed by fuzzy-matching that fragment. In the Poké Mart flow, all six
|
|
937
|
+
* steps came back as `Text` with no testID at all, so "tap the Charizard row"
|
|
938
|
+
* was recorded as "The signature. Flame-broiled, wings on, worth the wait." —
|
|
939
|
+
* the brittlest possible selector, chosen when `shop-item-charizard` was one
|
|
940
|
+
* fiber up.
|
|
941
|
+
*
|
|
942
|
+
* So we walk to the fiber that DECLARES `onPress` and take the candidate there.
|
|
943
|
+
* Stopping at the declaring fiber matters: keep climbing and a row inside a
|
|
944
|
+
* pressable list resolves to the list.
|
|
945
|
+
*/
|
|
946
|
+
function preferHandlerOwner(match, byFiber) {
|
|
947
|
+
// Already the owner (or a Switch/TextInput, whose handler is its own).
|
|
948
|
+
const own = getProps(match.fiber);
|
|
949
|
+
if (typeof own?.onPress === "function" || typeof own?.onValueChange === "function" || typeof own?.onChangeText === "function") {
|
|
950
|
+
return match;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// 1. Find the fiber that actually declares the handler.
|
|
954
|
+
let declaring = null;
|
|
955
|
+
let cur = match.fiber?.return;
|
|
956
|
+
let depth = 0;
|
|
957
|
+
while (cur && depth < 25) {
|
|
958
|
+
if (typeof getProps(cur)?.onPress === "function") {
|
|
959
|
+
declaring = cur;
|
|
960
|
+
break;
|
|
961
|
+
}
|
|
962
|
+
cur = cur.return;
|
|
963
|
+
depth++;
|
|
964
|
+
}
|
|
965
|
+
if (!declaring) return match;
|
|
966
|
+
|
|
967
|
+
// 2. Take the candidate AT that fiber, or the first one above it.
|
|
968
|
+
//
|
|
969
|
+
// Stopping exactly at the declaring fiber isn't enough: a `<Pressable>`
|
|
970
|
+
// declares onPress but is de-duped into the component that renders it
|
|
971
|
+
// (`PrimaryButton`), and de-duping keeps the OUTERMOST fiber of everything
|
|
972
|
+
// sharing a host instance — which is where the testID ends up. Climbing a
|
|
973
|
+
// few levels past the handler is what turns "Text: Add to bag" into
|
|
974
|
+
// "PrimaryButton [testID=shop-add-to-bag]". The bound keeps a row inside a
|
|
975
|
+
// pressable list from resolving to the list.
|
|
976
|
+
let probe = declaring;
|
|
977
|
+
for (let i = 0; i < 6 && probe; i++) {
|
|
978
|
+
const candidate = byFiber.get(probe);
|
|
979
|
+
if (candidate) return candidate;
|
|
980
|
+
probe = probe.return;
|
|
981
|
+
}
|
|
982
|
+
return match;
|
|
983
|
+
}
|
|
984
|
+
function resolveTouchTarget(nativeTag) {
|
|
985
|
+
const candidates = collectCandidates();
|
|
986
|
+
if (candidates.length === 0) return null;
|
|
987
|
+
const byFiber = new Map();
|
|
988
|
+
for (const c of candidates) byFiber.set(c.fiber, c);
|
|
989
|
+
|
|
990
|
+
// Fast path: the touched host IS a candidate's host.
|
|
991
|
+
const direct = candidates.find(c => c.nativeTag === nativeTag);
|
|
992
|
+
|
|
993
|
+
// Otherwise find the touched fiber and walk up to the nearest candidate. The
|
|
994
|
+
// touch lands on the deepest host view (the <Text> inside the button), so the
|
|
995
|
+
// interesting element is almost always an ancestor.
|
|
996
|
+
let touched = null;
|
|
997
|
+
if (!direct) {
|
|
998
|
+
for (const root of getAllFiberRoots()) {
|
|
999
|
+
traverseFiberTree(root.current, fiber => {
|
|
1000
|
+
if (touched) return;
|
|
1001
|
+
if (fiber.stateNode && getNativeTag(fiber.stateNode) === nativeTag) {
|
|
1002
|
+
touched = fiber;
|
|
1003
|
+
}
|
|
1004
|
+
});
|
|
1005
|
+
if (touched) break;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
let match = direct ?? null;
|
|
1009
|
+
if (!match && touched) {
|
|
1010
|
+
let cur = touched;
|
|
1011
|
+
let depth = 0;
|
|
1012
|
+
// Prefer an ancestor that is actually drivable; fall back to the nearest
|
|
1013
|
+
// named one so an unlabeled tap still records something a human can read.
|
|
1014
|
+
let named = null;
|
|
1015
|
+
while (cur && depth < 25) {
|
|
1016
|
+
const c = byFiber.get(cur);
|
|
1017
|
+
if (c) {
|
|
1018
|
+
if (c.press || c.valueControl || c.textControl) {
|
|
1019
|
+
match = c;
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
if (!named) named = c;
|
|
1023
|
+
}
|
|
1024
|
+
cur = cur.return;
|
|
1025
|
+
depth++;
|
|
1026
|
+
}
|
|
1027
|
+
if (!match) match = named;
|
|
1028
|
+
}
|
|
1029
|
+
if (!match) return null;
|
|
1030
|
+
match = preferHandlerOwner(match, byFiber);
|
|
1031
|
+
if (!match.press && !match.valueControl && !match.textControl) return null;
|
|
1032
|
+
// Buoy's own chrome is not part of the app under test. Without this, the tap
|
|
1033
|
+
// that stops a recording is recorded as its final step.
|
|
1034
|
+
if (isBuoyChrome(match.fiber)) return null;
|
|
1035
|
+
const control = match.valueControl ? match.valueControl.kind : match.textControl ? "text" : "press";
|
|
1036
|
+
return {
|
|
1037
|
+
nativeTag: match.nativeTag,
|
|
1038
|
+
name: match.name,
|
|
1039
|
+
role: match.role,
|
|
1040
|
+
testID: match.testID,
|
|
1041
|
+
label: match.label,
|
|
1042
|
+
text: match.text,
|
|
1043
|
+
control,
|
|
1044
|
+
value: match.valueControl ? match.valueControl.current : match.textControl ? match.textControl.current : undefined
|
|
1045
|
+
};
|
|
776
1046
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.clearTouchCapture = clearTouchCapture;
|
|
7
|
+
exports.getTouchCaptureStatus = getTouchCaptureStatus;
|
|
8
|
+
exports.readTouchCapture = readTouchCapture;
|
|
9
|
+
exports.startTouchCapture = startTouchCapture;
|
|
10
|
+
exports.stopTouchCapture = stopTouchCapture;
|
|
11
|
+
var _screenElements = require("./screenElements");
|
|
12
|
+
var _rawEventEmitter = require("./rawEventEmitter");
|
|
13
|
+
/**
|
|
14
|
+
* touchCapture — what a HUMAN is doing to the app, as replayable intents.
|
|
15
|
+
*
|
|
16
|
+
* This is the recording half of the pair whose playback half is `tapElement`:
|
|
17
|
+
* it watches real finger input and names each interaction in exactly the
|
|
18
|
+
* vocabulary `tapElement` searches by, so a recorded macro can be replayed
|
|
19
|
+
* without a second guess about what "that button" meant.
|
|
20
|
+
*
|
|
21
|
+
* Why RawEventEmitter and not the obvious alternatives:
|
|
22
|
+
* - A capture-phase responder on a root View would be the textbook answer, but
|
|
23
|
+
* Buoy mounts as a SIBLING of the app tree, not a parent. There is no root to
|
|
24
|
+
* wrap without changing every host app's integration.
|
|
25
|
+
* - Patching `Pressability`, or rewriting `onPress` props on the fly, breaks the
|
|
26
|
+
* moment a component re-renders (props are rebuilt every render) and misses
|
|
27
|
+
* everything driven by react-native-gesture-handler anyway.
|
|
28
|
+
* - RawEventEmitter mirrors every dispatched event before React's plugins see
|
|
29
|
+
* it, needs zero app instrumentation, and is already proven in-tree by
|
|
30
|
+
* focus-inspector's TV focus observer.
|
|
31
|
+
*
|
|
32
|
+
* Three rules the recorder above depends on:
|
|
33
|
+
*
|
|
34
|
+
* THE TARGET IS RESOLVED AT touchStart, NOT touchEnd. A press handler can
|
|
35
|
+
* navigate synchronously, so resolving after the fact names an element on the
|
|
36
|
+
* screen the app moved TO. We resolve while the finger is still down and emit
|
|
37
|
+
* when it lifts.
|
|
38
|
+
*
|
|
39
|
+
* A DRAG IS NOT A TAP. Scrolling a list produces the same touchStart the
|
|
40
|
+
* button above it does. Movement past a threshold, or an explicit
|
|
41
|
+
* touchCancel when a ScrollView steals the responder, drops the record —
|
|
42
|
+
* otherwise every recording is half scroll noise.
|
|
43
|
+
*
|
|
44
|
+
* KEYSTROKES ARE NOT INTENTS. Typing "SAVE10" fires six change events; a
|
|
45
|
+
* macro step wants the finished string. Consecutive edits to the same input
|
|
46
|
+
* collapse into the latest value as they arrive.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/** Points of finger travel past which the gesture stops being a tap. */
|
|
50
|
+
const TAP_SLOP_PT = 14;
|
|
51
|
+
/** Hold time at or past which a tap is recorded as a long-press. */
|
|
52
|
+
const LONG_PRESS_MS = 550;
|
|
53
|
+
/** A tap that outlasts this was a gesture we don't model — drop it. */
|
|
54
|
+
const MAX_TAP_MS = 4_000;
|
|
55
|
+
/** Consecutive edits to the same input within this window are one intent. */
|
|
56
|
+
const TEXT_COALESCE_MS = 8_000;
|
|
57
|
+
/** Ring buffer size. A recording longer than this is not a macro anyway. */
|
|
58
|
+
const MAX_RECORDS = 600;
|
|
59
|
+
let emitter = null;
|
|
60
|
+
let emitterResolved = false;
|
|
61
|
+
let unavailableReason = null;
|
|
62
|
+
let subscriptions = [];
|
|
63
|
+
let status = "idle";
|
|
64
|
+
let records = [];
|
|
65
|
+
let seq = 0;
|
|
66
|
+
let pending = null;
|
|
67
|
+
function getEmitter() {
|
|
68
|
+
if (emitterResolved) return emitter;
|
|
69
|
+
emitterResolved = true;
|
|
70
|
+
const resolution = (0, _rawEventEmitter.resolveRawEventEmitter)();
|
|
71
|
+
emitter = resolution.emitter;
|
|
72
|
+
unavailableReason = resolution.reason;
|
|
73
|
+
return emitter;
|
|
74
|
+
}
|
|
75
|
+
function num(v) {
|
|
76
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Fabric puts the touched view on `target`; `tag` is the older shape. */
|
|
80
|
+
function tagOf(event) {
|
|
81
|
+
const native = event?.nativeEvent;
|
|
82
|
+
if (!native) return null;
|
|
83
|
+
return num(native.target) ?? num(native.tag);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Touch coordinates. A touch event carries them at the top level on the
|
|
88
|
+
* synthesized event and inside `touches`/`changedTouches` on the raw one, and
|
|
89
|
+
* which of those is populated differs by platform and architecture — so read
|
|
90
|
+
* whichever answers rather than betting on one.
|
|
91
|
+
*/
|
|
92
|
+
function pointOf(event) {
|
|
93
|
+
const native = event?.nativeEvent;
|
|
94
|
+
if (!native) return null;
|
|
95
|
+
const direct = {
|
|
96
|
+
x: num(native.pageX),
|
|
97
|
+
y: num(native.pageY)
|
|
98
|
+
};
|
|
99
|
+
if (direct.x !== null && direct.y !== null) return {
|
|
100
|
+
x: direct.x,
|
|
101
|
+
y: direct.y
|
|
102
|
+
};
|
|
103
|
+
for (const key of ["changedTouches", "touches"]) {
|
|
104
|
+
const list = native[key];
|
|
105
|
+
if (Array.isArray(list) && list.length) {
|
|
106
|
+
const first = list[0];
|
|
107
|
+
const x = num(first?.pageX);
|
|
108
|
+
const y = num(first?.pageY);
|
|
109
|
+
if (x !== null && y !== null) return {
|
|
110
|
+
x,
|
|
111
|
+
y
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
function push(record) {
|
|
118
|
+
records.push({
|
|
119
|
+
...record,
|
|
120
|
+
seq: ++seq
|
|
121
|
+
});
|
|
122
|
+
if (records.length > MAX_RECORDS) records = records.slice(-MAX_RECORDS);
|
|
123
|
+
}
|
|
124
|
+
function onTouchStart(event) {
|
|
125
|
+
const tag = tagOf(event);
|
|
126
|
+
if (tag === null) return;
|
|
127
|
+
const point = pointOf(event);
|
|
128
|
+
let target = null;
|
|
129
|
+
try {
|
|
130
|
+
target = (0, _screenElements.resolveTouchTarget)(tag);
|
|
131
|
+
} catch {
|
|
132
|
+
// A fiber walk that throws must never break the app's touch handling.
|
|
133
|
+
}
|
|
134
|
+
pending = {
|
|
135
|
+
tag,
|
|
136
|
+
x: point?.x ?? 0,
|
|
137
|
+
y: point?.y ?? 0,
|
|
138
|
+
t: Date.now(),
|
|
139
|
+
drift: 0,
|
|
140
|
+
target
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function onTouchMove(event) {
|
|
144
|
+
if (!pending) return;
|
|
145
|
+
const point = pointOf(event);
|
|
146
|
+
if (!point) return;
|
|
147
|
+
const dx = point.x - pending.x;
|
|
148
|
+
const dy = point.y - pending.y;
|
|
149
|
+
pending.drift = Math.max(pending.drift, Math.sqrt(dx * dx + dy * dy));
|
|
150
|
+
}
|
|
151
|
+
function onTouchEnd(event) {
|
|
152
|
+
const p = pending;
|
|
153
|
+
pending = null;
|
|
154
|
+
if (!p || !p.target) return;
|
|
155
|
+
const point = pointOf(event);
|
|
156
|
+
if (point) {
|
|
157
|
+
const dx = point.x - p.x;
|
|
158
|
+
const dy = point.y - p.y;
|
|
159
|
+
p.drift = Math.max(p.drift, Math.sqrt(dx * dx + dy * dy));
|
|
160
|
+
}
|
|
161
|
+
if (p.drift > TAP_SLOP_PT) return; // a drag, a scroll, a swipe — not a step
|
|
162
|
+
|
|
163
|
+
const held = Date.now() - p.t;
|
|
164
|
+
if (held > MAX_TAP_MS) return;
|
|
165
|
+
|
|
166
|
+
// A text input's tap is just "focus this field"; the intent is the text it
|
|
167
|
+
// ends up with, which arrives separately as a change event. Recording the
|
|
168
|
+
// focus tap too would put a step in every macro that does nothing on replay.
|
|
169
|
+
if (p.target.control === "text") return;
|
|
170
|
+
// Same for a toggle/slider: the tap and the value change are one intent, and
|
|
171
|
+
// the change event is the half that carries what it became.
|
|
172
|
+
if (p.target.control === "toggle" || p.target.control === "slider") return;
|
|
173
|
+
push({
|
|
174
|
+
t: Date.now(),
|
|
175
|
+
kind: held >= LONG_PRESS_MS ? "longPress" : "press",
|
|
176
|
+
target: p.target
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function onTouchCancel() {
|
|
180
|
+
// A ScrollView (or any ancestor) taking the responder terminates the touch.
|
|
181
|
+
pending = null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* `topChange` is the shared channel for "this control's value became X" —
|
|
186
|
+
* TextInput sends `text`, Switch sends `value`. Sliders arrive on their own
|
|
187
|
+
* channel. All three are the same idea: a control the user set.
|
|
188
|
+
*/
|
|
189
|
+
function onChange(event) {
|
|
190
|
+
const tag = tagOf(event);
|
|
191
|
+
if (tag === null) return;
|
|
192
|
+
const native = event.nativeEvent;
|
|
193
|
+
let kind;
|
|
194
|
+
let value;
|
|
195
|
+
if (typeof native.text === "string") {
|
|
196
|
+
kind = "text";
|
|
197
|
+
value = native.text;
|
|
198
|
+
} else if (typeof native.value === "boolean") {
|
|
199
|
+
kind = "toggle";
|
|
200
|
+
value = native.value;
|
|
201
|
+
} else if (typeof native.value === "number") {
|
|
202
|
+
kind = "slider";
|
|
203
|
+
value = native.value;
|
|
204
|
+
} else {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const last = records[records.length - 1];
|
|
208
|
+
if (last && last.kind === kind && last.target.nativeTag === tag && Date.now() - last.t < TEXT_COALESCE_MS) {
|
|
209
|
+
// Same control, still typing/dragging: this IS the previous intent, further
|
|
210
|
+
// along. Replace rather than append, or "SAVE10" becomes six macro steps.
|
|
211
|
+
last.value = value;
|
|
212
|
+
last.t = Date.now();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
let target = null;
|
|
216
|
+
try {
|
|
217
|
+
target = (0, _screenElements.resolveTouchTarget)(tag);
|
|
218
|
+
} catch {
|
|
219
|
+
/* see onTouchStart */
|
|
220
|
+
}
|
|
221
|
+
if (!target) return;
|
|
222
|
+
push({
|
|
223
|
+
t: Date.now(),
|
|
224
|
+
kind,
|
|
225
|
+
target,
|
|
226
|
+
value
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function startTouchCapture() {
|
|
230
|
+
if (status === "capturing") return getTouchCaptureStatus();
|
|
231
|
+
const raw = getEmitter();
|
|
232
|
+
if (!raw) {
|
|
233
|
+
status = "unavailable";
|
|
234
|
+
return getTouchCaptureStatus();
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
subscriptions = [raw.addListener("topTouchStart", onTouchStart), raw.addListener("topTouchMove", onTouchMove), raw.addListener("topTouchEnd", onTouchEnd), raw.addListener("topTouchCancel", onTouchCancel), raw.addListener("topChange", onChange), raw.addListener("topValueChange", onChange), raw.addListener("topSlidingComplete", onChange)];
|
|
238
|
+
pending = null;
|
|
239
|
+
status = "capturing";
|
|
240
|
+
} catch (e) {
|
|
241
|
+
subscriptions = [];
|
|
242
|
+
status = "unavailable";
|
|
243
|
+
unavailableReason = e?.message ?? "addListener failed";
|
|
244
|
+
}
|
|
245
|
+
return getTouchCaptureStatus();
|
|
246
|
+
}
|
|
247
|
+
function stopTouchCapture() {
|
|
248
|
+
subscriptions.forEach(s => {
|
|
249
|
+
try {
|
|
250
|
+
s.remove();
|
|
251
|
+
} catch {
|
|
252
|
+
/* already gone is fine */
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
subscriptions = [];
|
|
256
|
+
pending = null;
|
|
257
|
+
if (status === "capturing") status = "idle";
|
|
258
|
+
}
|
|
259
|
+
function clearTouchCapture() {
|
|
260
|
+
records = [];
|
|
261
|
+
seq = 0;
|
|
262
|
+
}
|
|
263
|
+
function getTouchCaptureStatus() {
|
|
264
|
+
return {
|
|
265
|
+
status,
|
|
266
|
+
reason: status === "unavailable" ? unavailableReason : null
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Everything recorded after `sinceSeq`, plus where the caller now is. */
|
|
271
|
+
function readTouchCapture(sinceSeq = 0) {
|
|
272
|
+
return {
|
|
273
|
+
...getTouchCaptureStatus(),
|
|
274
|
+
records: records.filter(r => r.seq > sinceSeq),
|
|
275
|
+
seq
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -7,6 +7,7 @@ exports.highlightUpdatesSyncAdapter = void 0;
|
|
|
7
7
|
var _HighlightUpdatesController = _interopRequireDefault(require("../highlight-updates/utils/HighlightUpdatesController"));
|
|
8
8
|
var _RenderTracker = require("../highlight-updates/utils/RenderTracker");
|
|
9
9
|
var _screenElements = require("../highlight-updates/utils/screenElements");
|
|
10
|
+
var _touchCapture = require("../highlight-updates/utils/touchCapture");
|
|
10
11
|
var _CommitProfiler = require("../highlight-updates/utils/CommitProfiler");
|
|
11
12
|
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
|
|
12
13
|
function ensureInitialized() {
|
|
@@ -208,6 +209,27 @@ const highlightUpdatesSyncAdapter = exports.highlightUpdatesSyncAdapter = {
|
|
|
208
209
|
// Tap an element purely in JS: resolve it (by nativeTag / testID / query),
|
|
209
210
|
// find the nearest onPress up its fiber chain, and invoke it with a
|
|
210
211
|
// synthetic press event. Powers the MCP `tap_element` action.
|
|
211
|
-
tapElement: params => (0, _screenElements.tapElement)(params ?? {})
|
|
212
|
+
tapElement: params => (0, _screenElements.tapElement)(params ?? {}),
|
|
213
|
+
// Block until an element is on screen (or gone), so a driver can act on a
|
|
214
|
+
// screen that has finished loading instead of the skeleton that was there
|
|
215
|
+
// when `navigate` returned. Same matcher `tapElement` ranks by.
|
|
216
|
+
waitFor: params => (0, _screenElements.waitFor)(params ?? {}),
|
|
217
|
+
// ── Touch capture: the recording half of tapElement ──────────────────
|
|
218
|
+
// Observe what a HUMAN taps, named in the same vocabulary tapElement
|
|
219
|
+
// searches by, so the Scenarios recorder can turn a real session into a
|
|
220
|
+
// replayable macro. Lives here because this tool already owns the fiber
|
|
221
|
+
// walk both halves need.
|
|
222
|
+
startTouchCapture: () => (0, _touchCapture.startTouchCapture)(),
|
|
223
|
+
stopTouchCapture: () => {
|
|
224
|
+
(0, _touchCapture.stopTouchCapture)();
|
|
225
|
+
return (0, _touchCapture.getTouchCaptureStatus)();
|
|
226
|
+
},
|
|
227
|
+
clearTouchCapture: () => {
|
|
228
|
+
(0, _touchCapture.clearTouchCapture)();
|
|
229
|
+
return {
|
|
230
|
+
ok: true
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
readTouchCapture: params => (0, _touchCapture.readTouchCapture)(params?.sinceSeq ?? 0)
|
|
212
234
|
}
|
|
213
235
|
};
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
19
19
|
import { View, Text, StyleSheet } from "react-native";
|
|
20
|
-
import { Power, JsModal, devToolsStorageKeys, persistentStorage, night, TickProvider } from "@buoy-gg/shared-ui";
|
|
20
|
+
import { Power, JsModal, devToolsStorageKeys, persistentStorage, night, TickProvider, windowDragProps } from "@buoy-gg/shared-ui";
|
|
21
21
|
import { useIsPro } from "@buoy-gg/license";
|
|
22
22
|
import HighlightUpdatesController from "../utils/HighlightUpdatesController";
|
|
23
23
|
import { RenderTracker } from "../utils/RenderTracker";
|
|
@@ -550,6 +550,7 @@ export function HighlightUpdatesModal({
|
|
|
550
550
|
style: styles.inlineRoot,
|
|
551
551
|
children: [/*#__PURE__*/_jsx(View, {
|
|
552
552
|
style: styles.inlineHeader,
|
|
553
|
+
...windowDragProps,
|
|
553
554
|
children: renderHeaderContent()
|
|
554
555
|
}), body, footerNode]
|
|
555
556
|
})
|