@buoy-gg/highlight-updates 7.0.38 → 7.0.39
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 +281 -1
- package/lib/commonjs/highlight-updates/components/HighlightFilterView.js +1474 -1
- package/lib/commonjs/highlight-updates/components/HighlightUpdatesModal.js +641 -1
- package/lib/commonjs/highlight-updates/components/IdentifierBadge.js +273 -1
- package/lib/commonjs/highlight-updates/components/IsolatedRenderList.js +188 -1
- package/lib/commonjs/highlight-updates/components/ModalHeaderContent.js +293 -1
- package/lib/commonjs/highlight-updates/components/RenderCauseBadge.js +508 -1
- package/lib/commonjs/highlight-updates/components/RenderDetailView.js +846 -1
- package/lib/commonjs/highlight-updates/components/RenderHistoryViewer.js +915 -1
- package/lib/commonjs/highlight-updates/components/RenderListItem.js +235 -1
- package/lib/commonjs/highlight-updates/components/RendersCopySettingsView.js +469 -1
- package/lib/commonjs/highlight-updates/components/StatsDisplay.js +73 -1
- package/lib/commonjs/highlight-updates/types/copySettings.js +107 -1
- package/lib/commonjs/highlight-updates/utils/CommitProfiler.js +446 -1
- package/lib/commonjs/highlight-updates/utils/HighlightUpdatesController.js +1920 -1
- package/lib/commonjs/highlight-updates/utils/PerformanceLogger.js +359 -1
- package/lib/commonjs/highlight-updates/utils/ProfilerInterceptor.js +371 -1
- package/lib/commonjs/highlight-updates/utils/RenderCauseDetector.js +1840 -1
- package/lib/commonjs/highlight-updates/utils/RenderTracker.js +952 -1
- package/lib/commonjs/highlight-updates/utils/ViewTypeMapper.js +264 -1
- package/lib/commonjs/highlight-updates/utils/copySettingsStorage.js +32 -1
- package/lib/commonjs/highlight-updates/utils/devtoolsComponentNames.js +188 -1
- package/lib/commonjs/highlight-updates/utils/namedComposite.js +41 -1
- package/lib/commonjs/highlight-updates/utils/ownStateChanged.js +103 -1
- package/lib/commonjs/highlight-updates/utils/rawEventEmitter.js +50 -1
- package/lib/commonjs/highlight-updates/utils/renderExportFormatter.js +58 -1
- package/lib/commonjs/highlight-updates/utils/rendersExportFormatter.js +485 -1
- package/lib/commonjs/highlight-updates/utils/screenElements.js +1248 -1
- package/lib/commonjs/highlight-updates/utils/tapMatch.js +83 -1
- package/lib/commonjs/highlight-updates/utils/testIDs.js +68 -1
- package/lib/commonjs/highlight-updates/utils/touchCapture.js +277 -1
- package/lib/commonjs/index.js +429 -1
- package/lib/commonjs/preset.js +278 -1
- package/lib/commonjs/sync/highlightUpdatesSyncAdapter.js +235 -1
- package/lib/module/highlight-updates/HighlightUpdatesOverlay.js +274 -1
- package/lib/module/highlight-updates/components/HighlightFilterView.js +1468 -1
- package/lib/module/highlight-updates/components/HighlightUpdatesModal.js +634 -1
- package/lib/module/highlight-updates/components/IdentifierBadge.js +265 -1
- package/lib/module/highlight-updates/components/IsolatedRenderList.js +183 -1
- package/lib/module/highlight-updates/components/ModalHeaderContent.js +288 -1
- package/lib/module/highlight-updates/components/RenderCauseBadge.js +499 -1
- package/lib/module/highlight-updates/components/RenderDetailView.js +840 -1
- package/lib/module/highlight-updates/components/RenderHistoryViewer.js +908 -1
- package/lib/module/highlight-updates/components/RenderListItem.js +230 -1
- package/lib/module/highlight-updates/components/RendersCopySettingsView.js +465 -1
- package/lib/module/highlight-updates/components/StatsDisplay.js +70 -1
- package/lib/module/highlight-updates/types/copySettings.js +102 -1
- package/lib/module/highlight-updates/utils/CommitProfiler.js +442 -1
- package/lib/module/highlight-updates/utils/HighlightUpdatesController.js +1916 -1
- package/lib/module/highlight-updates/utils/PerformanceLogger.js +353 -1
- package/lib/module/highlight-updates/utils/ProfilerInterceptor.js +358 -1
- package/lib/module/highlight-updates/utils/RenderCauseDetector.js +1830 -1
- package/lib/module/highlight-updates/utils/RenderTracker.js +949 -1
- package/lib/module/highlight-updates/utils/ViewTypeMapper.js +255 -1
- package/lib/module/highlight-updates/utils/copySettingsStorage.js +26 -1
- package/lib/module/highlight-updates/utils/devtoolsComponentNames.js +178 -1
- package/lib/module/highlight-updates/utils/namedComposite.js +36 -1
- package/lib/module/highlight-updates/utils/ownStateChanged.js +98 -1
- package/lib/module/highlight-updates/utils/rawEventEmitter.js +46 -1
- package/lib/module/highlight-updates/utils/renderExportFormatter.js +54 -1
- package/lib/module/highlight-updates/utils/rendersExportFormatter.js +478 -1
- package/lib/module/highlight-updates/utils/screenElements.js +1242 -1
- package/lib/module/highlight-updates/utils/tapMatch.js +76 -1
- package/lib/module/highlight-updates/utils/testIDs.js +60 -1
- package/lib/module/highlight-updates/utils/touchCapture.js +270 -1
- package/lib/module/index.js +130 -1
- package/lib/module/preset.js +272 -1
- package/lib/module/sync/highlightUpdatesSyncAdapter.js +230 -1
- package/lib/typescript/highlight-updates/HighlightUpdatesOverlay.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/HighlightFilterView.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/HighlightUpdatesModal.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/IdentifierBadge.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/IsolatedRenderList.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/ModalHeaderContent.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/RenderCauseBadge.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/RenderDetailView.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/RenderHistoryViewer.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/RenderListItem.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/RendersCopySettingsView.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/components/StatsDisplay.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/types/copySettings.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/CommitProfiler.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/HighlightUpdatesController.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/PerformanceLogger.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/ProfilerInterceptor.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/RenderCauseDetector.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/RenderTracker.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/ViewTypeMapper.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/copySettingsStorage.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/devtoolsComponentNames.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/namedComposite.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/ownStateChanged.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/rawEventEmitter.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/renderExportFormatter.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/rendersExportFormatter.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/screenElements.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/tapMatch.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/testIDs.d.ts.map +1 -0
- package/lib/typescript/highlight-updates/utils/touchCapture.d.ts.map +1 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/preset.d.ts.map +1 -0
- package/lib/typescript/sync/highlightUpdatesSyncAdapter.d.ts.map +1 -0
- package/package.json +7 -7
|
@@ -1 +1,1248 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,"__esModule",{value:!0}),exports.describeScreen=describeScreen,exports.resolveTouchTarget=resolveTouchTarget,exports.tapElement=tapElement,exports.waitFor=waitFor;var _reactNative=require("react-native"),_tapMatch=require("./tapMatch");const HostComponent=5,OffscreenComponent=22,INTERNAL_NAMES=new Set(["View","RCTView","ScrollView","RCTScrollView","RCTScrollContentView","SafeAreaView","RCTSafeAreaView","AndroidHorizontalScrollContentView","KeyboardAvoidingView","Pressable","PlatformPressable","TouchableOpacity","TouchableHighlight","TouchableWithoutFeedback","TouchableNativeFeedback","AnimatedComponent","Wrapper"]);function getReactDevToolsHook(){return"undefined"==typeof global?null:global.__REACT_DEVTOOLS_GLOBAL_HOOK__??null}let commitCount=0,commitCounterInstalled=!1;function ensureCommitCounter(){if(commitCounterInstalled)return;const e=getReactDevToolsHook();if(!e)return;const t=e.onCommitFiberRoot;e.onCommitFiberRoot=function(...n){return commitCount++,"function"==typeof t?t.apply(e,n):void 0},commitCounterInstalled=!0}const TAP_SETTLE_MS=250;function makeChromeCheck(){const e=new Map;return t=>{const n=[];let o=t,r=0,a=!1;for(;o&&r<400;){const t=e.get(o);if(void 0!==t){a=t;break}if(getProps(o)?.__buoyDevToolsRoot){a=!0,e.set(o,!0);break}n.push(o),o=o.return,r++}for(const t of n)e.set(t,a);return a}}function getAllFiberRoots(){const e=getReactDevToolsHook();if(!e)return[];const t=[];try{if(e.renderers&&"function"==typeof e.renderers.forEach&&e.renderers.forEach((n,o)=>{try{const n=e.getFiberRoots?.(o);n&&n.forEach(e=>t.push(e))}catch{}}),0===t.length&&e.getFiberRoots){const n=e.getFiberRoots(1);n&&n.forEach(e=>t.push(e))}}catch{}return t}function isHiddenOffscreen(e){return 22===e.tag&&null!==e.memoizedState}function isInactiveScreen(e){const t=e.memoizedProps?.activityState;return 0===t||1===t||!1===e.memoizedProps?.isPageFocused}function traverseFiberTree(e,t,n=0,o=new Set){!e||o.has(e)||n>500||(o.add(e),isHiddenOffscreen(e)||isInactiveScreen(e)||(t(e),e.child&&traverseFiberTree(e.child,t,n+1,o)),e.sibling&&traverseFiberTree(e.sibling,t,n,o))}function getPublicInstance(e){if(!e)return null;const t=e;return t.canonical?.publicInstance?t.canonical.publicInstance:t.canonical&&"function"==typeof t.canonical.measure?t.canonical:"function"==typeof t.measure||"function"==typeof t.measureInWindow?t:null}function getNativeTag(e){if(null==e)return null;const t=e;if("number"==typeof t.__nativeTag)return t.__nativeTag;if("number"==typeof t._nativeTag)return t._nativeTag;if("number"==typeof t.nativeTag)return t.nativeTag;if(t.canonical){if("number"==typeof t.canonical.__nativeTag)return t.canonical.__nativeTag;if("number"==typeof t.canonical._nativeTag)return t.canonical._nativeTag;if("number"==typeof t.canonical.nativeTag)return t.canonical.nativeTag}return null}function findHostInstance(e){const t=getPublicInstance(e.stateNode);if(t)return{instance:t,nativeTag:getNativeTag(e.stateNode),stateNode:e.stateNode};const n=[];e.child&&n.push(e.child);let o=0;for(;n.length>0&&o<4e3;){o++;const e=n.pop();if(!e)continue;const t=getPublicInstance(e.stateNode);if(t)return{instance:t,nativeTag:getNativeTag(e.stateNode),stateNode:e.stateNode};e.child&&n.push(e.child),e.sibling&&n.push(e.sibling)}return null}function findScrollAncestor(e){let t=e,n=0;for(;t&&n++<200;){const e=t.stateNode;if(e&&"function"==typeof e.scrollTo){const t="function"==typeof e.getNativeScrollRef&&e.getNativeScrollRef()||null,n="function"==typeof e.getInnerViewNode&&e.getInnerViewNode()||"function"==typeof e.getScrollableNode&&e.getScrollableNode()||e;return{instance:e,nativeRef:t,innerNode:n}}t=t.return}return null}async function scrollIntoView(e,t,n,o){try{const n=findScrollAncestor(e);if(!n)return!1;const r=t;let a=null;const i=e=>new Promise(t=>{let n=!1;const o=e=>{n||(n=!0,t(e))},r=setTimeout(()=>o(null),600);try{e(e=>{clearTimeout(r),o(e)})}catch{clearTimeout(r),o(null)}});if(n.nativeRef&&"function"==typeof r.measureLayout&&(a=await i(e=>r.measureLayout(n.nativeRef,(t,n)=>e(n),()=>e(null)))),null==a){const e=(0,_reactNative.findNodeHandle)(t),o="number"==typeof n.innerNode?n.innerNode:(0,_reactNative.findNodeHandle)(n.innerNode);null!=e&&null!=o&&(a=await i(t=>_reactNative.UIManager.measureLayout(e,o,()=>t(null),(e,n)=>t(n))))}return null!=a&&(n.instance.scrollTo({y:Math.max(0,a-o),animated:!1}),await new Promise(e=>setTimeout(e,120)),!0)}catch{return!1}}function getComponentName(e){const t=e?.type;return t?"string"==typeof t?t:t.displayName||t.name||null:null}function getOwningComponentName(e){let t=e?._debugOwner||e?.return,n=0;for(;t&&n<30;){n++;const e=getComponentName(t);if(e&&!INTERNAL_NAMES.has(e)&&"string"!=typeof t.type)return e;t=t.return}return null}function getProps(e){return e?.memoizedProps??e?.pendingProps??null}function flattenText(e,t=0){if("string"==typeof e)return e;if("number"==typeof e&&Number.isFinite(e))return String(e);if(Array.isArray(e)&&t<4){const n=[];for(const o of e){const e=flattenText(o,t+1);e&&n.push(e)}return n.length>0?n.join(""):void 0}}function normalizeVisibleText(e){return e.replace(/\s+/g," ").trim()}function getVisibleText(e){if(!e)return;const t=e=>{const t=flattenText(e);if(void 0===t)return;const n=normalizeVisibleText(t);return n?n.slice(0,80):void 0};return t(e.children)??t(e.title)??t(e.label)??t(e.placeholder)??void 0}function collectDescendantText(e,t){const n=[],o=(r,a)=>{if(!r||a>t||n.length>=4)return;if(r!==e){const e=getProps(r);if(e&&("function"==typeof e.onPress||"function"==typeof e.onLongPress))return}const i=getVisibleText(getProps(r));i&&!n.includes(i)&&n.push(i),r.child&&o(r.child,a+1),r!==e&&r.sibling&&o(r.sibling,a)};e.child&&o(e.child,1);const r=n.join(" ").trim();return r?r.slice(0,80):void 0}function findPressHandler(e){let t=e,n=0,o=null,r=null;for(;t&&n<25&&(!o||!r);){const e=getProps(t);e&&(o||"function"!=typeof e.onPress||(o=e.onPress),r||"function"!=typeof e.onLongPress||(r=e.onLongPress)),t=t.return,n++}return o?{handler:o,kind:"onPress",longPress:r??void 0}:r?{handler:r,kind:"onLongPress"}:null}function measureInstance(e){return new Promise(t=>{let n=!1;const o=e=>{n||(n=!0,t(e))};setTimeout(()=>o(null),1e3);try{"function"==typeof e.measureInWindow?e.measureInWindow((e,t,n,r)=>{o(n>0&&r>0?{x:e,y:t,width:n,height:r}:null)}):"function"==typeof e.measure?e.measure((e,t,n,r,a,i)=>{o(n>0&&r>0&&null!=a&&null!=i?{x:a,y:i,width:n,height:r}:null)}):o(null)}catch{o(null)}})}function collectCandidates(){const e=getAllFiberRoots();if(0===e.length)return[];const t=[],n=new Map,o=new Map,r=makeChromeCheck();for(const a of e)traverseFiberTree(a.current,e=>{const a=getProps(e);if(!a)return;const i="function"==typeof a.onPress||"function"==typeof a.onLongPress,s="function"==typeof a.onValueChange,l="function"==typeof a.onChangeText,c=a.testID||void 0,u=a.accessibilityLabel||void 0,f=a.accessibilityRole||a.role||void 0;let d=getVisibleText(a);if(!(i||s||l||c||u||d||"button"===f||"link"===f||"menuitem"===f||"tab"===f||"switch"===f||"checkbox"===f))return;let m=null,g=null,p=null;if(s){const e=a.value,t="number"==typeof e||"number"==typeof a.minimumValue;g={handler:a.onValueChange,kind:t?"slider":"toggle",current:e,min:"number"==typeof a.minimumValue?a.minimumValue:void 0,max:"number"==typeof a.maximumValue?a.maximumValue:void 0}}else l?p={handler:a.onChangeText,current:"string"==typeof a.value?a.value:void 0}:m=findPressHandler(e);const h=getComponentName(e);let v=h??"Unknown";h&&!INTERNAL_NAMES.has(h)&&"string"!=typeof e.type||(v=getOwningComponentName(e)??h??"Unknown");const T=findHostInstance(e);if(!T&&!c)return;d||u||!i||(d=collectDescendantText(e,6));const b=T?.instance??null,y=T?.nativeTag??null,C=(b?n.get(b):void 0)??(null!=y?o.get(y):void 0);if(C)return!C.press&&m?C.press=m:C.press&&m?.longPress&&!C.press.longPress&&(C.press.longPress=m.longPress),!C.valueControl&&g&&(C.valueControl=g),!C.textControl&&p&&(C.textControl=p),!C.testID&&c&&(C.testID=c),!C.label&&u&&(C.label=u),!C.text&&d&&(C.text=d),b&&!n.has(b)&&n.set(b,C),void(null==y||o.has(y)||o.set(y,C));const w={nativeTag:y,name:v,role:f,testID:c,label:u,text:d,instance:b,stateNode:T?.stateNode??null,fiber:e,press:m,valueControl:g,textControl:p,rect:null,buoy:r(e)};t.push(w),b&&n.set(b,w),null!=y&&o.set(y,w)});return t}function explicitlyTargets(e,t){return null!=t.nativeTag?e.nativeTag===t.nativeTag:!(!t.testID||!e.testID)&&e.testID.toLowerCase()===t.testID.toLowerCase()}function appCandidates(e){const t=collectCandidates();return e.includeBuoy?t:t.filter(t=>!t.buoy||explicitlyTargets(t,e))}async function measureCandidates(e){await Promise.all(e.map(async e=>{e.instance&&(e.rect=await measureInstance(e.instance))}))}async function describeScreen(e={}){const t=collectCandidates(),n=e.includeBuoy?t:t.filter(e=>!e.buoy),o=t.length-n.length;await measureCandidates(n);const r=_reactNative.Dimensions.get("window"),a=Math.round(r.width)||1,i=Math.round(r.height)||1,s=[];for(const e of n){const t=e.rect;if(!t&&!e.testID)continue;let n,o;if(t){const e=t.x+t.width/2,r=t.y+t.height/2;if(r<.1*-i||r>1.1*i||e<.1*-a||e>1.1*a)continue;n={x:round2(e/a),y:round2(r/i)},o={x:round2(t.x/a),y:round2(t.y/i),width:round2(t.width/a),height:round2(t.height/i)}}const r=e.valueControl?e.valueControl.kind:e.textControl?"text":void 0,l=e.valueControl?e.valueControl.current:e.textControl?.current;s.push({nativeTag:e.nativeTag,name:e.name,role:e.role,testID:e.testID,label:e.label,text:e.text,interactive:!!(e.press||e.valueControl||e.textControl),longPressable:!(!e.press||"onLongPress"!==e.press.kind&&!e.press.longPress)||void 0,control:r,value:l,tap:n,frame:o})}return s.sort((e,t)=>{const n=e.tap?.y??2,o=t.tap?.y??2;return Math.abs(n-o)>.02?n-o:(e.tap?.x??2)-(t.tap?.x??2)}),{screen:{width:a,height:i},count:s.length,elements:s,...o>0?{hiddenBuoy:o}:{}}}function round2(e){return Math.round(100*e)/100}function fieldsOf(e){return{nativeTag:e.nativeTag,testID:e.testID,label:e.label,text:e.text,name:e.name}}function matchScore(e,t){return(0,_tapMatch.scoreTapMatch)(fieldsOf(e),t)}function tapPoint(e,t,n){if(e&&t&&n)return{x:(e.x+e.width/2)/t,y:(e.y+e.height/2)/n}}function makeSyntheticPressEvent(e){const t=e?e.x+e.width/2:0,n=e?e.y+e.height/2:0;return{nativeEvent:{locationX:e?e.width/2:0,locationY:e?e.height/2:0,pageX:t,pageY:n,timestamp:0,target:null,identifier:1,touches:[],changedTouches:[]},persist:()=>{},preventDefault:()=>{},stopPropagation:()=>{},isDefaultPrevented:()=>!1,isPropagationStopped:()=>!1,target:null,currentTarget:null,type:"press",timeStamp:0}}async function tapElement(e){const t=appCandidates(e);if(0===t.length)return{tapped:!1,reason:"No React fiber roots / elements found on screen."};const n=t.map(t=>({c:t,score:matchScore(t,e)})).filter(e=>e.score>0).sort((e,t)=>t.score-e.score),o=e=>!!(e.press||e.valueControl||e.textControl);if(0===n.length)return{tapped:!1,reason:"No element matched. Call describe_screen to see available elements (match by testID, text, or name).",candidates:t.filter(o).slice(0,12).map(e=>({nativeTag:e.nativeTag,name:e.name,testID:e.testID,text:e.text}))};const r=_reactNative.Dimensions.get("window"),a=r.width||1,i=r.height||1,s=n.slice(0,15);await Promise.all(s.map(async e=>{e.c.instance&&!e.c.rect&&(e.c.rect=await measureInstance(e.c.instance))}));for(const t of s)t.score=(0,_tapMatch.scoreTapMatch)({...fieldsOf(t.c),tap:tapPoint(t.c.rect,a,i)},e);s.sort((e,t)=>t.score-e.score);const l=s.find(e=>o(e.c))??n.find(e=>o(e.c))??s[0],c=l.c,u=(e,t)=>e.press&&e.press.handler===t.press?.handler||e.valueControl&&e.valueControl.handler===t.valueControl?.handler||e.textControl&&e.textControl.handler===t.textControl?.handler,f=s.filter(e=>e!==l&&o(e.c)&&e.score===l.score).filter(e=>!u(e.c,c)).filter((e,t,n)=>n.findIndex(t=>u(t.c,e.c))===t).slice(0,3).map(e=>({nativeTag:e.c.nativeTag,name:e.c.name,testID:e.c.testID,text:e.c.text,tap:tapPoint(e.c.rect,a,i)}));if(!o(c))return{tapped:!1,reason:`Matched "${c.name}"${c.text?` ("${c.text}")`:""} but it has no onPress / onValueChange / onChangeText handler (e.g. a react-native-gesture-handler GestureDetector). This element can't be driven in JS.`,matched:{nativeTag:c.nativeTag,name:c.name,testID:c.testID,label:c.label,text:c.text,via:"onPress"}};c.instance&&!c.rect&&(c.rect=await measureInstance(c.instance));let d=!1;(null==c.rect||!(0,_tapMatch.isRectCenterOnScreen)(c.rect,a,i))&&!1!==e.scrollIntoView&&c.instance&&(d=await scrollIntoView(c.fiber,c.instance,c.stateNode,80),d&&(c.rect=await measureInstance(c.instance)));const m={nativeTag:c.nativeTag,name:c.name,testID:c.testID,label:c.label,text:c.text};ensureCommitCounter();const g=commitCount,p=Date.now(),h=async e=>(await new Promise(e=>setTimeout(e,TAP_SETTLE_MS)),{tapped:!0,scrolled:d,matched:e,effect:{commits:commitCount-g,observedMs:Date.now()-p},...f.length?{alsoMatched:f}:{}});try{if(c.press){if(e.longPress){const e="onLongPress"===c.press.kind?c.press.handler:c.press.longPress;return e?(e(makeSyntheticPressEvent(c.rect)),await h({...m,via:"onLongPress"})):{tapped:!1,scrolled:d,reason:`Matched "${c.name}"${c.text?` ("${c.text}")`:""} but it has no onLongPress handler — call tap_element without longPress to tap it normally.`,matched:{...m,via:"onPress"}}}return c.press.handler(makeSyntheticPressEvent(c.rect)),await h({...m,via:c.press.kind})}if(c.valueControl){const t=c.valueControl;let n;if("toggle"===t.kind)n="boolean"==typeof e.value?e.value:!t.current;else{const o=t.min??0,r=t.max??1;n="number"==typeof e.value?Math.min(r,Math.max(o,e.value)):t.current===r?o:r}return t.handler(n),await h({...m,via:"onValueChange",value:n})}const t=e.text??("string"==typeof e.value?e.value:"");return c.textControl.handler(t),await h({...m,via:"onChangeText",value:t})}catch(e){return{tapped:!1,scrolled:d,reason:`handler threw: ${e instanceof Error?e.message:String(e)}`,matched:{...m,via:c.press?c.press.kind:c.valueControl?"onValueChange":"onChangeText"}}}}const WAIT_DEFAULT_MS=5e3,WAIT_MAX_MS=3e4,WAIT_MIN_POLL_MS=50,WAIT_DEFAULT_POLL_MS=150;async function waitFor(e){if(null==e.nativeTag&&!String(e.testID??"").trim()&&!String(e.query??"").trim())return{ok:!1,waitedMs:0,polls:0,reason:"Nothing to wait for — pass nativeTag, testID or query."};const t=Math.min(Math.max(e.timeoutMs??WAIT_DEFAULT_MS,0),WAIT_MAX_MS),n=Math.max(e.pollMs??WAIT_DEFAULT_POLL_MS,WAIT_MIN_POLL_MS),o=!0===e.gone,r=Date.now();let a=0;for(;;){a+=1;const{hit:i,closest:s}=await findOnScreen(e);if(o?!i:i)return{ok:!0,waitedMs:Date.now()-r,polls:a,matched:i??void 0};if(Date.now()-r>=t){const e=s?.length?s:void 0,n=o?`Still on screen after ${t}ms.`:e?`Not on screen after ${t}ms — ${1===e.length?"one element":`${e.length} elements`} matched but ${e.some(e=>e.onScreen)?"not well enough":"none is visible"}. See \`closest\`; do not retry with different words, call describeScreen.`:`Not on screen after ${t}ms, and nothing matched those words at all. Call describeScreen to see what is there.`;return{ok:!1,waitedMs:Date.now()-r,polls:a,reason:n,matched:i??void 0,closest:e}}await new Promise(e=>setTimeout(e,n))}}async function findOnScreen(e){const t=appCandidates(e).map(t=>({c:t,score:matchScore(t,e)})).filter(e=>e.score>0).sort((e,t)=>t.score-e.score).slice(0,8);if(0===t.length)return{hit:null,closest:void 0};await measureCandidates(t.map(e=>e.c));const n=_reactNative.Dimensions.get("window"),o=Math.round(n.width)||1,r=Math.round(n.height)||1;let a=null;for(const{c:e}of t)if((0,_tapMatch.isRectCenterOnScreen)(e.rect,o,r)){a={nativeTag:e.nativeTag,name:e.name,testID:e.testID,label:e.label,text:e.text,interactive:!!(e.press||e.valueControl||e.textControl)};break}return{hit:a,closest:t.slice(0,3).map(({c:e})=>({name:e.name,testID:e.testID,text:e.text,onScreen:(0,_tapMatch.isRectCenterOnScreen)(e.rect,o,r)}))}}function isBuoyChrome(e){let t=e,n=0;for(;t&&n<400;){if(getProps(t)?.__buoyDevToolsRoot)return!0;t=t.return,n++}return!1}function preferHandlerOwner(e,t){const n=getProps(e.fiber);if("function"==typeof n?.onPress||"function"==typeof n?.onValueChange||"function"==typeof n?.onChangeText)return e;let o=null,r=e.fiber?.return,a=0;for(;r&&a<25;){if("function"==typeof getProps(r)?.onPress){o=r;break}r=r.return,a++}if(!o)return e;let i=o;for(let e=0;e<6&&i;e++){const e=t.get(i);if(e)return e;i=i.return}return e}function resolveTouchTarget(e){const t=collectCandidates();if(0===t.length)return null;const n=new Map;for(const e of t)n.set(e.fiber,e);const o=t.find(t=>t.nativeTag===e);let r=null;if(!o)for(const t of getAllFiberRoots())if(traverseFiberTree(t.current,t=>{r||t.stateNode&&getNativeTag(t.stateNode)===e&&(r=t)}),r)break;let a=o??null;if(!a&&r){let e=r,t=0,o=null;for(;e&&t<25;){const r=n.get(e);if(r){if(r.press||r.valueControl||r.textControl){a=r;break}o||(o=r)}e=e.return,t++}a||(a=o)}if(!a)return null;if(a=preferHandlerOwner(a,n),!a.press&&!a.valueControl&&!a.textControl)return null;if(isBuoyChrome(a.fiber))return null;const i=a.valueControl?a.valueControl.kind:a.textControl?"text":"press";return{nativeTag:a.nativeTag,name:a.name,role:a.role,testID:a.testID,label:a.label,text:a.text,control:i,value:a.valueControl?a.valueControl.current:a.textControl?a.textControl.current:void 0}}
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.describeScreen = describeScreen;
|
|
7
|
+
exports.resolveTouchTarget = resolveTouchTarget;
|
|
8
|
+
exports.tapElement = tapElement;
|
|
9
|
+
exports.waitFor = waitFor;
|
|
10
|
+
var _reactNative = require("react-native");
|
|
11
|
+
var _tapMatch = require("./tapMatch");
|
|
12
|
+
/**
|
|
13
|
+
* On-device screen element discovery + in-JS tap.
|
|
14
|
+
*
|
|
15
|
+
* This powers the MCP "describe_screen" / "tap_element" tools. It walks the
|
|
16
|
+
* live React fiber tree (all renderers), collects the meaningful / interactive
|
|
17
|
+
* elements on the current screen, and measures each one's on-screen rect — the
|
|
18
|
+
* same idea as the desktop screenshot tool's component-locate, but producing a
|
|
19
|
+
* whole-screen list with normalized tap coordinates instead of a single crop.
|
|
20
|
+
*
|
|
21
|
+
* Tapping is done PURELY in JS: we resolve the target element, find the nearest
|
|
22
|
+
* `onPress` handler up its fiber chain, and invoke it with a synthetic press
|
|
23
|
+
* event. No screenshots, no OS-level touch injection, and it works on physical
|
|
24
|
+
* devices — because Buoy runs INSIDE the app (unlike external simulator drivers
|
|
25
|
+
* that have to inject an OS touch at a pixel coordinate).
|
|
26
|
+
*
|
|
27
|
+
* Fiber traversal is adapted from image-overlay/utils/fiberScanner.ts and
|
|
28
|
+
* debug-borders/utils/fiberTreeTraversal.js; the fiber-access triad matches the
|
|
29
|
+
* rest of highlight-updates (Fabric / Paper / RN-CLI-dev).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
33
|
+
|
|
34
|
+
// --- React fiber tag / renderer plumbing ---------------------------------
|
|
35
|
+
|
|
36
|
+
const HostComponent = 5;
|
|
37
|
+
const OffscreenComponent = 22;
|
|
38
|
+
|
|
39
|
+
// Component names that are pure infrastructure and never worth listing on
|
|
40
|
+
// their own. Kept small on purpose — a candidate still needs a real signal
|
|
41
|
+
// (onPress / testID / label / text / role) to be emitted, so this only
|
|
42
|
+
// suppresses the *name* of wrappers that happen to also carry one.
|
|
43
|
+
const INTERNAL_NAMES = new Set(["View", "RCTView", "ScrollView", "RCTScrollView", "RCTScrollContentView", "SafeAreaView", "RCTSafeAreaView", "AndroidHorizontalScrollContentView", "KeyboardAvoidingView", "Pressable", "PlatformPressable", "TouchableOpacity", "TouchableHighlight", "TouchableWithoutFeedback", "TouchableNativeFeedback", "AnimatedComponent", "Wrapper"]);
|
|
44
|
+
function getReactDevToolsHook() {
|
|
45
|
+
if (typeof global === "undefined") return null;
|
|
46
|
+
return global.__REACT_DEVTOOLS_GLOBAL_HOOK__ ?? null;
|
|
47
|
+
}
|
|
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
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Collect fiber roots across ALL renderers. A single renderer id is not safe
|
|
114
|
+
* to assume: secondary reconcilers (react-native-skia, react-native-svg, …)
|
|
115
|
+
* register their own renderer, sometimes first as id 1, whose roots contain
|
|
116
|
+
* none of the tappable app UI.
|
|
117
|
+
*/
|
|
118
|
+
function getAllFiberRoots() {
|
|
119
|
+
const hook = getReactDevToolsHook();
|
|
120
|
+
if (!hook) return [];
|
|
121
|
+
const roots = [];
|
|
122
|
+
try {
|
|
123
|
+
if (hook.renderers && typeof hook.renderers.forEach === "function") {
|
|
124
|
+
hook.renderers.forEach((_r, rendererId) => {
|
|
125
|
+
try {
|
|
126
|
+
const set = hook.getFiberRoots?.(rendererId);
|
|
127
|
+
if (set) set.forEach(root => roots.push(root));
|
|
128
|
+
} catch {
|
|
129
|
+
/* ignore a single renderer failing */
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (roots.length === 0 && hook.getFiberRoots) {
|
|
134
|
+
const legacy = hook.getFiberRoots(1);
|
|
135
|
+
if (legacy) legacy.forEach(root => roots.push(root));
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
/* ignore */
|
|
139
|
+
}
|
|
140
|
+
return roots;
|
|
141
|
+
}
|
|
142
|
+
function isHiddenOffscreen(fiber) {
|
|
143
|
+
return fiber.tag === OffscreenComponent && fiber.memoizedState !== null;
|
|
144
|
+
}
|
|
145
|
+
function isInactiveScreen(fiber) {
|
|
146
|
+
// react-native-screens: activityState 0/1 = inactive/transitioning.
|
|
147
|
+
const as = fiber.memoizedProps?.activityState;
|
|
148
|
+
if (as === 0 || as === 1) return true;
|
|
149
|
+
// pagers
|
|
150
|
+
if (fiber.memoizedProps?.isPageFocused === false) return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
function traverseFiberTree(fiber, callback, depth = 0, visited = new Set()) {
|
|
154
|
+
if (!fiber || visited.has(fiber) || depth > 500) return;
|
|
155
|
+
visited.add(fiber);
|
|
156
|
+
if (isHiddenOffscreen(fiber) || isInactiveScreen(fiber)) {
|
|
157
|
+
// Prune the whole subtree, but keep walking siblings.
|
|
158
|
+
if (fiber.sibling) traverseFiberTree(fiber.sibling, callback, depth, visited);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
callback(fiber);
|
|
162
|
+
if (fiber.child) traverseFiberTree(fiber.child, callback, depth + 1, visited);
|
|
163
|
+
if (fiber.sibling) traverseFiberTree(fiber.sibling, callback, depth, visited);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- Fiber → host instance / name / props --------------------------------
|
|
167
|
+
|
|
168
|
+
/** Get a measurable public instance from a host fiber's stateNode. */
|
|
169
|
+
function getPublicInstance(stateNode) {
|
|
170
|
+
if (!stateNode) return null;
|
|
171
|
+
const node = stateNode;
|
|
172
|
+
if (node.canonical?.publicInstance) return node.canonical.publicInstance;
|
|
173
|
+
if (node.canonical && typeof node.canonical.measure === "function") {
|
|
174
|
+
return node.canonical;
|
|
175
|
+
}
|
|
176
|
+
if (typeof node.measure === "function" || typeof node.measureInWindow === "function") {
|
|
177
|
+
return node;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
function getNativeTag(stateNode) {
|
|
182
|
+
if (stateNode == null) return null;
|
|
183
|
+
const n = stateNode;
|
|
184
|
+
if (typeof n.__nativeTag === "number") return n.__nativeTag;
|
|
185
|
+
if (typeof n._nativeTag === "number") return n._nativeTag;
|
|
186
|
+
if (typeof n.nativeTag === "number") return n.nativeTag;
|
|
187
|
+
if (n.canonical) {
|
|
188
|
+
if (typeof n.canonical.__nativeTag === "number") return n.canonical.__nativeTag;
|
|
189
|
+
if (typeof n.canonical._nativeTag === "number") return n.canonical._nativeTag;
|
|
190
|
+
if (typeof n.canonical.nativeTag === "number") return n.canonical.nativeTag;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The first host node in a fiber's own subtree (self first, then descendants). */
|
|
196
|
+
function findHostInstance(fiber) {
|
|
197
|
+
const selfInst = getPublicInstance(fiber.stateNode);
|
|
198
|
+
if (selfInst) {
|
|
199
|
+
return {
|
|
200
|
+
instance: selfInst,
|
|
201
|
+
nativeTag: getNativeTag(fiber.stateNode),
|
|
202
|
+
stateNode: fiber.stateNode
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// DFS restricted to this fiber's subtree (child + siblings-of-children).
|
|
207
|
+
const stack = [];
|
|
208
|
+
if (fiber.child) stack.push(fiber.child);
|
|
209
|
+
let guard = 0;
|
|
210
|
+
while (stack.length > 0 && guard < 4000) {
|
|
211
|
+
guard++;
|
|
212
|
+
const f = stack.pop();
|
|
213
|
+
if (!f) continue;
|
|
214
|
+
const inst = getPublicInstance(f.stateNode);
|
|
215
|
+
if (inst) return {
|
|
216
|
+
instance: inst,
|
|
217
|
+
nativeTag: getNativeTag(f.stateNode),
|
|
218
|
+
stateNode: f.stateNode
|
|
219
|
+
};
|
|
220
|
+
if (f.child) stack.push(f.child);
|
|
221
|
+
if (f.sibling) stack.push(f.sibling);
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Walk up from a fiber to the nearest ScrollView-like instance (exposes
|
|
228
|
+
* scrollTo). Mirrors HighlightUpdatesController.findScrollAncestor.
|
|
229
|
+
*/
|
|
230
|
+
function findScrollAncestor(fiber) {
|
|
231
|
+
let cur = fiber;
|
|
232
|
+
let guard = 0;
|
|
233
|
+
while (cur && guard++ < 200) {
|
|
234
|
+
const inst = cur.stateNode;
|
|
235
|
+
if (inst && typeof inst.scrollTo === "function") {
|
|
236
|
+
const nativeRef = typeof inst.getNativeScrollRef === "function" && inst.getNativeScrollRef() || null;
|
|
237
|
+
const innerNode = typeof inst.getInnerViewNode === "function" && inst.getInnerViewNode() || typeof inst.getScrollableNode === "function" && inst.getScrollableNode() || inst;
|
|
238
|
+
return {
|
|
239
|
+
instance: inst,
|
|
240
|
+
nativeRef,
|
|
241
|
+
innerNode
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
cur = cur.return;
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Best-effort scroll of an ancestor ScrollView so the target sits near the top
|
|
251
|
+
* of its viewport. Returns true only if a scroll was issued. Never throws.
|
|
252
|
+
* Ported from HighlightUpdatesController.scrollDescendantIntoView.
|
|
253
|
+
*/
|
|
254
|
+
async function scrollIntoView(targetFiber, publicInstance, stateNode, margin) {
|
|
255
|
+
try {
|
|
256
|
+
const ancestor = findScrollAncestor(targetFiber);
|
|
257
|
+
if (!ancestor) return false;
|
|
258
|
+
const inst = publicInstance;
|
|
259
|
+
let top = null;
|
|
260
|
+
|
|
261
|
+
// measureLayout can invoke NEITHER callback on some architectures — race
|
|
262
|
+
// every call against a short timeout so the await can't hang.
|
|
263
|
+
const withTimeout = run => new Promise(resolve => {
|
|
264
|
+
let done = false;
|
|
265
|
+
const settle = v => {
|
|
266
|
+
if (done) return;
|
|
267
|
+
done = true;
|
|
268
|
+
resolve(v);
|
|
269
|
+
};
|
|
270
|
+
const timer = setTimeout(() => settle(null), 600);
|
|
271
|
+
try {
|
|
272
|
+
run(v => {
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
settle(v);
|
|
275
|
+
});
|
|
276
|
+
} catch {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
settle(null);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Fabric: ref.measureLayout against the inner view ref.
|
|
283
|
+
if (ancestor.nativeRef && typeof inst.measureLayout === "function") {
|
|
284
|
+
top = await withTimeout(resolve => inst.measureLayout(ancestor.nativeRef, (_left, t) => resolve(t), () => resolve(null)));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Legacy: UIManager.measureLayout with numeric node handles.
|
|
288
|
+
if (top == null) {
|
|
289
|
+
const targetHandle = (0, _reactNative.findNodeHandle)(publicInstance);
|
|
290
|
+
const innerHandle = typeof ancestor.innerNode === "number" ? ancestor.innerNode : (0, _reactNative.findNodeHandle)(ancestor.innerNode);
|
|
291
|
+
if (targetHandle != null && innerHandle != null) {
|
|
292
|
+
top = await withTimeout(resolve => _reactNative.UIManager.measureLayout(targetHandle, innerHandle, () => resolve(null), (_left, t) => resolve(t)));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (top == null) return false;
|
|
296
|
+
ancestor.instance.scrollTo({
|
|
297
|
+
y: Math.max(0, top - margin),
|
|
298
|
+
animated: false
|
|
299
|
+
});
|
|
300
|
+
await new Promise(r => setTimeout(r, 120)); // let scroll + layout settle
|
|
301
|
+
return true;
|
|
302
|
+
} catch {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function getComponentName(fiber) {
|
|
307
|
+
const t = fiber?.type;
|
|
308
|
+
if (!t) return null;
|
|
309
|
+
if (typeof t === "string") return t;
|
|
310
|
+
return t.displayName || t.name || null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Walk up to the nearest user-defined component name (skips host/internal). */
|
|
314
|
+
function getOwningComponentName(fiber) {
|
|
315
|
+
let cur = fiber?._debugOwner || fiber?.return;
|
|
316
|
+
let depth = 0;
|
|
317
|
+
while (cur && depth < 30) {
|
|
318
|
+
depth++;
|
|
319
|
+
const name = getComponentName(cur);
|
|
320
|
+
if (name && !INTERNAL_NAMES.has(name) && typeof cur.type !== "string") {
|
|
321
|
+
return name;
|
|
322
|
+
}
|
|
323
|
+
cur = cur.return;
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
function getProps(fiber) {
|
|
328
|
+
return fiber?.memoizedProps ?? fiber?.pendingProps ?? null;
|
|
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
|
+
}
|
|
365
|
+
function getVisibleText(props) {
|
|
366
|
+
if (!props) return undefined;
|
|
367
|
+
const pick = v => {
|
|
368
|
+
const flat = flattenText(v);
|
|
369
|
+
if (flat === undefined) return undefined;
|
|
370
|
+
const text = normalizeVisibleText(flat);
|
|
371
|
+
return text ? text.slice(0, 80) : undefined;
|
|
372
|
+
};
|
|
373
|
+
return pick(props.children) ?? pick(props.title) ?? pick(props.label) ?? pick(props.placeholder) ?? undefined;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Gather visible text from a fiber's descendants (its own label), stopping at
|
|
378
|
+
* nested interactive subtrees so a button doesn't absorb another button's text.
|
|
379
|
+
* Lets an icon-button whose label is a child <Text> (e.g. a dial icon) still be
|
|
380
|
+
* matched/shown by that label — the button host measures even when the inner
|
|
381
|
+
* Text host doesn't.
|
|
382
|
+
*/
|
|
383
|
+
function collectDescendantText(root, maxDepth) {
|
|
384
|
+
const parts = [];
|
|
385
|
+
const visit = (f, depth) => {
|
|
386
|
+
if (!f || depth > maxDepth || parts.length >= 4) return;
|
|
387
|
+
// Don't cross into a nested interactive element (another button's label).
|
|
388
|
+
if (f !== root) {
|
|
389
|
+
const p = getProps(f);
|
|
390
|
+
if (p && (typeof p.onPress === "function" || typeof p.onLongPress === "function")) return;
|
|
391
|
+
}
|
|
392
|
+
const t = getVisibleText(getProps(f));
|
|
393
|
+
if (t && !parts.includes(t)) parts.push(t);
|
|
394
|
+
if (f.child) visit(f.child, depth + 1);
|
|
395
|
+
if (f !== root && f.sibling) visit(f.sibling, depth); // siblings stay in subtree
|
|
396
|
+
};
|
|
397
|
+
if (root.child) visit(root.child, 1);
|
|
398
|
+
const joined = parts.join(" ").trim();
|
|
399
|
+
return joined ? joined.slice(0, 80) : undefined;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Nearest onPress / onLongPress handlers at or above a fiber. */
|
|
403
|
+
|
|
404
|
+
function findPressHandler(fiber) {
|
|
405
|
+
let cur = fiber;
|
|
406
|
+
let depth = 0;
|
|
407
|
+
let press = null;
|
|
408
|
+
let longPress = null;
|
|
409
|
+
while (cur && depth < 25 && !(press && longPress)) {
|
|
410
|
+
const p = getProps(cur);
|
|
411
|
+
if (p) {
|
|
412
|
+
if (!press && typeof p.onPress === "function") press = p.onPress;
|
|
413
|
+
if (!longPress && typeof p.onLongPress === "function") longPress = p.onLongPress;
|
|
414
|
+
}
|
|
415
|
+
cur = cur.return;
|
|
416
|
+
depth++;
|
|
417
|
+
}
|
|
418
|
+
if (press) return {
|
|
419
|
+
handler: press,
|
|
420
|
+
kind: "onPress",
|
|
421
|
+
longPress: longPress ?? undefined
|
|
422
|
+
};
|
|
423
|
+
return longPress ? {
|
|
424
|
+
handler: longPress,
|
|
425
|
+
kind: "onLongPress"
|
|
426
|
+
} : null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// --- Measurement ----------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/** Absolute on-screen rect in points (or null), with a hang guard. */
|
|
432
|
+
function measureInstance(instance) {
|
|
433
|
+
return new Promise(resolve => {
|
|
434
|
+
let settled = false;
|
|
435
|
+
const done = r => {
|
|
436
|
+
if (settled) return;
|
|
437
|
+
settled = true;
|
|
438
|
+
resolve(r);
|
|
439
|
+
};
|
|
440
|
+
// Detached / off-screen nodes can fire neither callback.
|
|
441
|
+
setTimeout(() => done(null), 1000);
|
|
442
|
+
try {
|
|
443
|
+
if (typeof instance.measureInWindow === "function") {
|
|
444
|
+
instance.measureInWindow((x, y, width, height) => {
|
|
445
|
+
if (width > 0 && height > 0) done({
|
|
446
|
+
x,
|
|
447
|
+
y,
|
|
448
|
+
width,
|
|
449
|
+
height
|
|
450
|
+
});else done(null);
|
|
451
|
+
});
|
|
452
|
+
} else if (typeof instance.measure === "function") {
|
|
453
|
+
instance.measure((_x, _y, width, height, pageX, pageY) => {
|
|
454
|
+
if (width > 0 && height > 0 && pageX != null && pageY != null) {
|
|
455
|
+
done({
|
|
456
|
+
x: pageX,
|
|
457
|
+
y: pageY,
|
|
458
|
+
width,
|
|
459
|
+
height
|
|
460
|
+
});
|
|
461
|
+
} else {
|
|
462
|
+
done(null);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
} else {
|
|
466
|
+
done(null);
|
|
467
|
+
}
|
|
468
|
+
} catch {
|
|
469
|
+
done(null);
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// --- Public types ---------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
// Internal record carries the live fiber refs that `describeScreen`'s
|
|
477
|
+
// serializable result drops.
|
|
478
|
+
|
|
479
|
+
// --- Collection -----------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
function collectCandidates() {
|
|
482
|
+
const roots = getAllFiberRoots();
|
|
483
|
+
if (roots.length === 0) return [];
|
|
484
|
+
const out = [];
|
|
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();
|
|
498
|
+
for (const root of roots) {
|
|
499
|
+
traverseFiberTree(root.current, fiber => {
|
|
500
|
+
const props = getProps(fiber);
|
|
501
|
+
if (!props) return;
|
|
502
|
+
const hasPress = typeof props.onPress === "function" || typeof props.onLongPress === "function";
|
|
503
|
+
const hasValue = typeof props.onValueChange === "function";
|
|
504
|
+
const hasText = typeof props.onChangeText === "function";
|
|
505
|
+
const testID = props.testID || undefined;
|
|
506
|
+
const label = props.accessibilityLabel || undefined;
|
|
507
|
+
const role = props.accessibilityRole || props.role || undefined;
|
|
508
|
+
let text = getVisibleText(props);
|
|
509
|
+
const roleInteractive = role === "button" || role === "link" || role === "menuitem" || role === "tab" || role === "switch" || role === "checkbox";
|
|
510
|
+
|
|
511
|
+
// A fiber earns a row only if it carries a real signal.
|
|
512
|
+
if (!hasPress && !hasValue && !hasText && !testID && !label && !text && !roleInteractive) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Resolve how this element is driven. A value/text control is a leaf
|
|
517
|
+
// (Switch/Slider/TextInput) whose OWN handler wins over an ancestor
|
|
518
|
+
// onPress; otherwise fall back to the nearest onPress up the tree.
|
|
519
|
+
let press = null;
|
|
520
|
+
let valueControl = null;
|
|
521
|
+
let textControl = null;
|
|
522
|
+
if (hasValue) {
|
|
523
|
+
const current = props.value;
|
|
524
|
+
const numeric = typeof current === "number" || typeof props.minimumValue === "number";
|
|
525
|
+
valueControl = {
|
|
526
|
+
handler: props.onValueChange,
|
|
527
|
+
kind: numeric ? "slider" : "toggle",
|
|
528
|
+
current,
|
|
529
|
+
min: typeof props.minimumValue === "number" ? props.minimumValue : undefined,
|
|
530
|
+
max: typeof props.maximumValue === "number" ? props.maximumValue : undefined
|
|
531
|
+
};
|
|
532
|
+
} else if (hasText) {
|
|
533
|
+
textControl = {
|
|
534
|
+
handler: props.onChangeText,
|
|
535
|
+
current: typeof props.value === "string" ? props.value : undefined
|
|
536
|
+
};
|
|
537
|
+
} else {
|
|
538
|
+
press = findPressHandler(fiber);
|
|
539
|
+
}
|
|
540
|
+
const rawName = getComponentName(fiber);
|
|
541
|
+
// Prefer a user component name over a bare host/internal wrapper name.
|
|
542
|
+
let name = rawName ?? "Unknown";
|
|
543
|
+
if (!rawName || INTERNAL_NAMES.has(rawName) || typeof fiber.type === "string") {
|
|
544
|
+
name = getOwningComponentName(fiber) ?? rawName ?? "Unknown";
|
|
545
|
+
}
|
|
546
|
+
const host = findHostInstance(fiber);
|
|
547
|
+
if (!host && !testID) return; // unmeasurable & unnamed → skip
|
|
548
|
+
|
|
549
|
+
// Interactive element with no own text/label (icon button, dial icon) —
|
|
550
|
+
// adopt a descendant <Text> as its label so it's matchable by that text.
|
|
551
|
+
if (!text && !label && hasPress) {
|
|
552
|
+
text = collectDescendantText(fiber, 6);
|
|
553
|
+
}
|
|
554
|
+
const instance = host?.instance ?? null;
|
|
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;
|
|
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;
|
|
571
|
+
}
|
|
572
|
+
const candidate = {
|
|
573
|
+
nativeTag,
|
|
574
|
+
name,
|
|
575
|
+
role,
|
|
576
|
+
testID,
|
|
577
|
+
label,
|
|
578
|
+
text,
|
|
579
|
+
instance,
|
|
580
|
+
stateNode: host?.stateNode ?? null,
|
|
581
|
+
fiber,
|
|
582
|
+
press,
|
|
583
|
+
valueControl,
|
|
584
|
+
textControl,
|
|
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);
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
return out;
|
|
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
|
+
}
|
|
627
|
+
async function measureCandidates(candidates) {
|
|
628
|
+
await Promise.all(candidates.map(async c => {
|
|
629
|
+
if (c.instance) c.rect = await measureInstance(c.instance);
|
|
630
|
+
}));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// --- describeScreen -------------------------------------------------------
|
|
634
|
+
|
|
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;
|
|
639
|
+
await measureCandidates(candidates);
|
|
640
|
+
const win = _reactNative.Dimensions.get("window");
|
|
641
|
+
const screenW = Math.round(win.width) || 1;
|
|
642
|
+
const screenH = Math.round(win.height) || 1;
|
|
643
|
+
const elements = [];
|
|
644
|
+
for (const c of candidates) {
|
|
645
|
+
const r = c.rect;
|
|
646
|
+
// Keep on-screen elements; drop rect-less rows unless they carry a testID.
|
|
647
|
+
if (!r && !c.testID) continue;
|
|
648
|
+
let tap;
|
|
649
|
+
let frame;
|
|
650
|
+
if (r) {
|
|
651
|
+
const cx = r.x + r.width / 2;
|
|
652
|
+
const cy = r.y + r.height / 2;
|
|
653
|
+
// Drop previous Expo Router screens (centers well outside the window).
|
|
654
|
+
// Keep a 10% slack so the floating menu / home-indicator-clipped rows stay.
|
|
655
|
+
if (cy < -screenH * 0.1 || cy > screenH * 1.1 || cx < -screenW * 0.1 || cx > screenW * 1.1) {
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
tap = {
|
|
659
|
+
x: round2(cx / screenW),
|
|
660
|
+
y: round2(cy / screenH)
|
|
661
|
+
};
|
|
662
|
+
frame = {
|
|
663
|
+
x: round2(r.x / screenW),
|
|
664
|
+
y: round2(r.y / screenH),
|
|
665
|
+
width: round2(r.width / screenW),
|
|
666
|
+
height: round2(r.height / screenH)
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
const control = c.valueControl ? c.valueControl.kind : c.textControl ? "text" : undefined;
|
|
670
|
+
const value = c.valueControl ? c.valueControl.current : c.textControl?.current;
|
|
671
|
+
elements.push({
|
|
672
|
+
nativeTag: c.nativeTag,
|
|
673
|
+
name: c.name,
|
|
674
|
+
role: c.role,
|
|
675
|
+
testID: c.testID,
|
|
676
|
+
label: c.label,
|
|
677
|
+
text: c.text,
|
|
678
|
+
interactive: !!(c.press || c.valueControl || c.textControl),
|
|
679
|
+
longPressable: c.press && (c.press.kind === "onLongPress" || c.press.longPress) ? true : undefined,
|
|
680
|
+
control,
|
|
681
|
+
value,
|
|
682
|
+
tap,
|
|
683
|
+
frame
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Reading order: top-to-bottom, then left-to-right.
|
|
688
|
+
elements.sort((a, b) => {
|
|
689
|
+
const ay = a.tap?.y ?? 2;
|
|
690
|
+
const by = b.tap?.y ?? 2;
|
|
691
|
+
if (Math.abs(ay - by) > 0.02) return ay - by;
|
|
692
|
+
return (a.tap?.x ?? 2) - (b.tap?.x ?? 2);
|
|
693
|
+
});
|
|
694
|
+
return {
|
|
695
|
+
screen: {
|
|
696
|
+
width: screenW,
|
|
697
|
+
height: screenH
|
|
698
|
+
},
|
|
699
|
+
count: elements.length,
|
|
700
|
+
elements,
|
|
701
|
+
...(hiddenBuoy > 0 ? {
|
|
702
|
+
hiddenBuoy
|
|
703
|
+
} : {})
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
function round2(n) {
|
|
707
|
+
return Math.round(n * 100) / 100;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// --- tapElement -----------------------------------------------------------
|
|
711
|
+
|
|
712
|
+
function fieldsOf(c) {
|
|
713
|
+
return {
|
|
714
|
+
nativeTag: c.nativeTag,
|
|
715
|
+
testID: c.testID,
|
|
716
|
+
label: c.label,
|
|
717
|
+
text: c.text,
|
|
718
|
+
name: c.name
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
function matchScore(c, params) {
|
|
722
|
+
return (0, _tapMatch.scoreTapMatch)(fieldsOf(c), params);
|
|
723
|
+
}
|
|
724
|
+
function tapPoint(rect, screenW, screenH) {
|
|
725
|
+
if (!rect || !screenW || !screenH) return undefined;
|
|
726
|
+
return {
|
|
727
|
+
x: (rect.x + rect.width / 2) / screenW,
|
|
728
|
+
y: (rect.y + rect.height / 2) / screenH
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
function makeSyntheticPressEvent(rect) {
|
|
732
|
+
const pageX = rect ? rect.x + rect.width / 2 : 0;
|
|
733
|
+
const pageY = rect ? rect.y + rect.height / 2 : 0;
|
|
734
|
+
const nativeEvent = {
|
|
735
|
+
locationX: rect ? rect.width / 2 : 0,
|
|
736
|
+
locationY: rect ? rect.height / 2 : 0,
|
|
737
|
+
pageX,
|
|
738
|
+
pageY,
|
|
739
|
+
timestamp: 0,
|
|
740
|
+
target: null,
|
|
741
|
+
identifier: 1,
|
|
742
|
+
touches: [],
|
|
743
|
+
changedTouches: []
|
|
744
|
+
};
|
|
745
|
+
return {
|
|
746
|
+
nativeEvent,
|
|
747
|
+
persist: () => {},
|
|
748
|
+
preventDefault: () => {},
|
|
749
|
+
stopPropagation: () => {},
|
|
750
|
+
isDefaultPrevented: () => false,
|
|
751
|
+
isPropagationStopped: () => false,
|
|
752
|
+
target: null,
|
|
753
|
+
currentTarget: null,
|
|
754
|
+
type: "press",
|
|
755
|
+
timeStamp: 0
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
async function tapElement(params) {
|
|
759
|
+
const candidates = appCandidates(params);
|
|
760
|
+
if (candidates.length === 0) {
|
|
761
|
+
return {
|
|
762
|
+
tapped: false,
|
|
763
|
+
reason: "No React fiber roots / elements found on screen."
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Rank; only elements with a reachable press handler are tappable.
|
|
768
|
+
const ranked = candidates.map(c => ({
|
|
769
|
+
c,
|
|
770
|
+
score: matchScore(c, params)
|
|
771
|
+
})).filter(r => r.score > 0).sort((a, b) => b.score - a.score);
|
|
772
|
+
const isInteractive = c => !!(c.press || c.valueControl || c.textControl);
|
|
773
|
+
if (ranked.length === 0) {
|
|
774
|
+
return {
|
|
775
|
+
tapped: false,
|
|
776
|
+
reason: "No element matched. Call describe_screen to see available elements (match by testID, text, or name).",
|
|
777
|
+
candidates: candidates.filter(isInteractive).slice(0, 12).map(c => ({
|
|
778
|
+
nativeTag: c.nativeTag,
|
|
779
|
+
name: c.name,
|
|
780
|
+
testID: c.testID,
|
|
781
|
+
text: c.text
|
|
782
|
+
}))
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Measure the top matches so an on-screen DialIcon beats an off-screen nav
|
|
787
|
+
// chip whose testID happens to contain the same substring.
|
|
788
|
+
const win = _reactNative.Dimensions.get("window");
|
|
789
|
+
const screenW = win.width || 1;
|
|
790
|
+
const screenH = win.height || 1;
|
|
791
|
+
const top = ranked.slice(0, 15);
|
|
792
|
+
await Promise.all(top.map(async r => {
|
|
793
|
+
if (r.c.instance && !r.c.rect) r.c.rect = await measureInstance(r.c.instance);
|
|
794
|
+
}));
|
|
795
|
+
for (const r of top) {
|
|
796
|
+
r.score = (0, _tapMatch.scoreTapMatch)({
|
|
797
|
+
...fieldsOf(r.c),
|
|
798
|
+
tap: tapPoint(r.c.rect, screenW, screenH)
|
|
799
|
+
}, params);
|
|
800
|
+
}
|
|
801
|
+
top.sort((a, b) => b.score - a.score);
|
|
802
|
+
|
|
803
|
+
// Fall back to the FULL ranked list for an interactive match — the top-15
|
|
804
|
+
// re-score is only a tiebreak, and on a busy screen the pressable match can
|
|
805
|
+
// sit below 15 text-score peers.
|
|
806
|
+
const target = top.find(r => isInteractive(r.c)) ?? ranked.find(r => isInteractive(r.c)) ?? top[0];
|
|
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
|
+
}));
|
|
823
|
+
if (!isInteractive(c)) {
|
|
824
|
+
return {
|
|
825
|
+
tapped: false,
|
|
826
|
+
reason: `Matched "${c.name}"${c.text ? ` ("${c.text}")` : ""} but it has no onPress / onValueChange / onChangeText handler (e.g. a react-native-gesture-handler GestureDetector). This element can't be driven in JS.`,
|
|
827
|
+
matched: {
|
|
828
|
+
nativeTag: c.nativeTag,
|
|
829
|
+
name: c.name,
|
|
830
|
+
testID: c.testID,
|
|
831
|
+
label: c.label,
|
|
832
|
+
text: c.text,
|
|
833
|
+
via: "onPress"
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Measure the target so we can decide whether to scroll it on-screen (and,
|
|
839
|
+
// for a press, give the synthetic event real coordinates).
|
|
840
|
+
if (c.instance && !c.rect) c.rect = await measureInstance(c.instance);
|
|
841
|
+
|
|
842
|
+
// Bring it into view if it's off-screen and scrolling is wanted. The action
|
|
843
|
+
// doesn't NEED this (handlers are invoked directly), but it keeps the element
|
|
844
|
+
// visible for the human watching / a follow-up describe.
|
|
845
|
+
let scrolled = false;
|
|
846
|
+
// A failed measure (rect == null) counts as off-screen: the node may be in
|
|
847
|
+
// a virtualized list that hasn't materialized it, and scrolling can help.
|
|
848
|
+
const offScreen = c.rect == null || !(0, _tapMatch.isRectCenterOnScreen)(c.rect, screenW, screenH);
|
|
849
|
+
if (offScreen && params.scrollIntoView !== false && c.instance) {
|
|
850
|
+
scrolled = await scrollIntoView(c.fiber, c.instance, c.stateNode, 80);
|
|
851
|
+
if (scrolled) c.rect = await measureInstance(c.instance); // fresh coords
|
|
852
|
+
}
|
|
853
|
+
const base = {
|
|
854
|
+
nativeTag: c.nativeTag,
|
|
855
|
+
name: c.name,
|
|
856
|
+
testID: c.testID,
|
|
857
|
+
label: c.label,
|
|
858
|
+
text: c.text
|
|
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
|
+
};
|
|
882
|
+
try {
|
|
883
|
+
if (c.press) {
|
|
884
|
+
if (params.longPress) {
|
|
885
|
+
const lp = c.press.kind === "onLongPress" ? c.press.handler : c.press.longPress;
|
|
886
|
+
if (!lp) {
|
|
887
|
+
return {
|
|
888
|
+
tapped: false,
|
|
889
|
+
scrolled,
|
|
890
|
+
reason: `Matched "${c.name}"${c.text ? ` ("${c.text}")` : ""} but it has no onLongPress handler — call tap_element without longPress to tap it normally.`,
|
|
891
|
+
matched: {
|
|
892
|
+
...base,
|
|
893
|
+
via: "onPress"
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
lp(makeSyntheticPressEvent(c.rect));
|
|
898
|
+
return await fired({
|
|
899
|
+
...base,
|
|
900
|
+
via: "onLongPress"
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
c.press.handler(makeSyntheticPressEvent(c.rect));
|
|
904
|
+
return await fired({
|
|
905
|
+
...base,
|
|
906
|
+
via: c.press.kind
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
if (c.valueControl) {
|
|
910
|
+
const vc = c.valueControl;
|
|
911
|
+
let next;
|
|
912
|
+
if (vc.kind === "toggle") {
|
|
913
|
+
next = typeof params.value === "boolean" ? params.value : !vc.current;
|
|
914
|
+
} else {
|
|
915
|
+
const min = vc.min ?? 0;
|
|
916
|
+
const max = vc.max ?? 1;
|
|
917
|
+
if (typeof params.value === "number") {
|
|
918
|
+
next = Math.min(max, Math.max(min, params.value));
|
|
919
|
+
} else {
|
|
920
|
+
// No target given → move to the far end so the change is visible.
|
|
921
|
+
next = vc.current === max ? min : max;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
vc.handler(next);
|
|
925
|
+
return await fired({
|
|
926
|
+
...base,
|
|
927
|
+
via: "onValueChange",
|
|
928
|
+
value: next
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
// textControl
|
|
932
|
+
const next = params.text ?? (typeof params.value === "string" ? params.value : "");
|
|
933
|
+
c.textControl.handler(next);
|
|
934
|
+
return await fired({
|
|
935
|
+
...base,
|
|
936
|
+
via: "onChangeText",
|
|
937
|
+
value: next
|
|
938
|
+
});
|
|
939
|
+
} catch (e) {
|
|
940
|
+
return {
|
|
941
|
+
tapped: false,
|
|
942
|
+
scrolled,
|
|
943
|
+
reason: `handler threw: ${e instanceof Error ? e.message : String(e)}`,
|
|
944
|
+
matched: {
|
|
945
|
+
...base,
|
|
946
|
+
via: c.press ? c.press.kind : c.valueControl ? "onValueChange" : "onChangeText"
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// --- waitFor --------------------------------------------------------------
|
|
953
|
+
|
|
954
|
+
const WAIT_DEFAULT_MS = 5_000;
|
|
955
|
+
const WAIT_MAX_MS = 30_000;
|
|
956
|
+
const WAIT_MIN_POLL_MS = 50;
|
|
957
|
+
const WAIT_DEFAULT_POLL_MS = 150;
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Block until an element appears (or disappears), then report how long it took.
|
|
961
|
+
*
|
|
962
|
+
* WHY THIS EXISTS. `navigate` returns the moment the route is pushed, not when
|
|
963
|
+
* the new screen has data on it, so "go to /shop then tap Fire Grill" reliably
|
|
964
|
+
* tapped a loading skeleton. A remote driver solves that with a wait step
|
|
965
|
+
* (MCP's `run_flow` has `waitFor`/`waitMs`); anything driving the tools from
|
|
966
|
+
* INSIDE the app had nothing, and its only option was to re-run
|
|
967
|
+
* `describeScreen` in a loop — which for an agent means burning turns against a
|
|
968
|
+
* step budget, and for a Scenario means a fixed sleep that is either flaky or
|
|
969
|
+
* slow.
|
|
970
|
+
*
|
|
971
|
+
* MATCHING IS `tapElement`'S, EXACTLY. Same `scoreTapMatch`, same fields, so
|
|
972
|
+
* "wait for X" and "tap X" can never disagree about what X is. A wait that
|
|
973
|
+
* resolves on an element the tap then cannot find is worse than no wait.
|
|
974
|
+
*
|
|
975
|
+
* PRESENCE MEANS ON SCREEN. An identity scan alone would resolve against a
|
|
976
|
+
* previous Expo Router screen still mounted behind the current one — the exact
|
|
977
|
+
* false positive `describeScreen` drops by rect. So a candidate that matches by
|
|
978
|
+
* name is measured before it counts, and only the matches are measured: the
|
|
979
|
+
* per-poll cost stays one fiber walk plus a handful of `measureInWindow`
|
|
980
|
+
* calls, not a full `describeScreen`.
|
|
981
|
+
*
|
|
982
|
+
* The poll floor is deliberate. This runs on the JS thread the app is drawing
|
|
983
|
+
* on, and a caller asking for `pollMs: 0` would starve the very render it is
|
|
984
|
+
* waiting for.
|
|
985
|
+
*/
|
|
986
|
+
async function waitFor(params) {
|
|
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();
|
|
990
|
+
if (!hasTarget) {
|
|
991
|
+
return {
|
|
992
|
+
ok: false,
|
|
993
|
+
waitedMs: 0,
|
|
994
|
+
polls: 0,
|
|
995
|
+
reason: "Nothing to wait for — pass nativeTag, testID or query."
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const timeoutMs = Math.min(Math.max(params.timeoutMs ?? WAIT_DEFAULT_MS, 0), WAIT_MAX_MS);
|
|
999
|
+
const pollMs = Math.max(params.pollMs ?? WAIT_DEFAULT_POLL_MS, WAIT_MIN_POLL_MS);
|
|
1000
|
+
const wantGone = params.gone === true;
|
|
1001
|
+
const started = Date.now();
|
|
1002
|
+
let polls = 0;
|
|
1003
|
+
for (;;) {
|
|
1004
|
+
polls += 1;
|
|
1005
|
+
const {
|
|
1006
|
+
hit,
|
|
1007
|
+
closest
|
|
1008
|
+
} = await findOnScreen(params);
|
|
1009
|
+
if (wantGone ? !hit : !!hit) {
|
|
1010
|
+
return {
|
|
1011
|
+
ok: true,
|
|
1012
|
+
waitedMs: Date.now() - started,
|
|
1013
|
+
polls,
|
|
1014
|
+
matched: hit ?? undefined
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
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.`;
|
|
1023
|
+
return {
|
|
1024
|
+
ok: false,
|
|
1025
|
+
waitedMs: Date.now() - started,
|
|
1026
|
+
polls,
|
|
1027
|
+
reason: why,
|
|
1028
|
+
matched: hit ?? undefined,
|
|
1029
|
+
closest: nearMisses
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** One scan: rank by identity, then confirm the best match is really visible. */
|
|
1037
|
+
async function findOnScreen(params) {
|
|
1038
|
+
const ranked = appCandidates(params).map(c => ({
|
|
1039
|
+
c,
|
|
1040
|
+
score: matchScore(c, params)
|
|
1041
|
+
})).filter(r => r.score > 0).sort((a, b) => b.score - a.score)
|
|
1042
|
+
// Only the plausible few get measured; a broad query can match dozens.
|
|
1043
|
+
.slice(0, 8);
|
|
1044
|
+
if (ranked.length === 0) return {
|
|
1045
|
+
hit: null,
|
|
1046
|
+
closest: undefined
|
|
1047
|
+
};
|
|
1048
|
+
await measureCandidates(ranked.map(r => r.c));
|
|
1049
|
+
const win = _reactNative.Dimensions.get("window");
|
|
1050
|
+
const screenW = Math.round(win.width) || 1;
|
|
1051
|
+
const screenH = Math.round(win.height) || 1;
|
|
1052
|
+
let hit = null;
|
|
1053
|
+
for (const {
|
|
1054
|
+
c
|
|
1055
|
+
} of ranked) {
|
|
1056
|
+
if (!(0, _tapMatch.isRectCenterOnScreen)(c.rect, screenW, screenH)) continue;
|
|
1057
|
+
hit = {
|
|
1058
|
+
nativeTag: c.nativeTag,
|
|
1059
|
+
name: c.name,
|
|
1060
|
+
testID: c.testID,
|
|
1061
|
+
label: c.label,
|
|
1062
|
+
text: c.text,
|
|
1063
|
+
interactive: !!(c.press || c.valueControl || c.textControl)
|
|
1064
|
+
};
|
|
1065
|
+
break;
|
|
1066
|
+
}
|
|
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
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// --- resolveTouchTarget ---------------------------------------------------
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* What a recorded touch landed on, in the vocabulary `tapElement` searches by.
|
|
1088
|
+
*
|
|
1089
|
+
* Deliberately the SAME fields `describeScreen` emits: a macro recorded from a
|
|
1090
|
+
* human's finger has to replay through `tapElement`, and the only way to
|
|
1091
|
+
* guarantee that is for the recorder to name the element exactly the way the
|
|
1092
|
+
* matcher will look for it. Anything richer here is a selector that records
|
|
1093
|
+
* fine and then can't be found again.
|
|
1094
|
+
*/
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Resolve the native tag from a raw touch event to the element a HUMAN would
|
|
1098
|
+
* say they tapped.
|
|
1099
|
+
*
|
|
1100
|
+
* Synchronous on purpose. The tag arrives on `topTouchStart`, and by the time
|
|
1101
|
+
* an async resolve came back the press handler may already have navigated —
|
|
1102
|
+
* leaving the recorder to name the element on the screen it moved TO. Everything
|
|
1103
|
+
* here is a fiber walk (no measure), which is what makes that affordable.
|
|
1104
|
+
*
|
|
1105
|
+
* Returns null when the tag belongs to nothing interesting (the background, a
|
|
1106
|
+
* scroll surface): the recorder treats that as "not a step", which is exactly
|
|
1107
|
+
* right — a tap on empty space is not something to replay.
|
|
1108
|
+
*/
|
|
1109
|
+
/**
|
|
1110
|
+
* Is this fiber inside Buoy's own overlay?
|
|
1111
|
+
*
|
|
1112
|
+
* `FloatingDevTools` marks its single root View with `__buoyDevToolsRoot`, and
|
|
1113
|
+
* everything the tool draws — the bubble, the dial, every modal, every tool
|
|
1114
|
+
* overlay — is under it. Walking the whole chain (rather than a fixed depth) is
|
|
1115
|
+
* the point: a control deep inside a tool's modal is still the tool.
|
|
1116
|
+
*/
|
|
1117
|
+
function isBuoyChrome(fiber) {
|
|
1118
|
+
let cur = fiber;
|
|
1119
|
+
let depth = 0;
|
|
1120
|
+
while (cur && depth < 400) {
|
|
1121
|
+
if (getProps(cur)?.__buoyDevToolsRoot) return true;
|
|
1122
|
+
cur = cur.return;
|
|
1123
|
+
depth++;
|
|
1124
|
+
}
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Climb from the node that was touched to the element that OWNS the press.
|
|
1130
|
+
*
|
|
1131
|
+
* A finger lands on the deepest host view, which for a button is the `<Text>`
|
|
1132
|
+
* inside it. That Text is a legitimate candidate — `findPressHandler` walks up
|
|
1133
|
+
* and finds the button's `onPress`, so it looks drivable — but it carries the
|
|
1134
|
+
* TEXT's identity: no testID, and "visible text" that is whatever string
|
|
1135
|
+
* happens to be in that node.
|
|
1136
|
+
*
|
|
1137
|
+
* Left alone, every recorded step is named after a fragment of copy and
|
|
1138
|
+
* replayed by fuzzy-matching that fragment. In the Poké Mart flow, all six
|
|
1139
|
+
* steps came back as `Text` with no testID at all, so "tap the Charizard row"
|
|
1140
|
+
* was recorded as "The signature. Flame-broiled, wings on, worth the wait." —
|
|
1141
|
+
* the brittlest possible selector, chosen when `shop-item-charizard` was one
|
|
1142
|
+
* fiber up.
|
|
1143
|
+
*
|
|
1144
|
+
* So we walk to the fiber that DECLARES `onPress` and take the candidate there.
|
|
1145
|
+
* Stopping at the declaring fiber matters: keep climbing and a row inside a
|
|
1146
|
+
* pressable list resolves to the list.
|
|
1147
|
+
*/
|
|
1148
|
+
function preferHandlerOwner(match, byFiber) {
|
|
1149
|
+
// Already the owner (or a Switch/TextInput, whose handler is its own).
|
|
1150
|
+
const own = getProps(match.fiber);
|
|
1151
|
+
if (typeof own?.onPress === "function" || typeof own?.onValueChange === "function" || typeof own?.onChangeText === "function") {
|
|
1152
|
+
return match;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// 1. Find the fiber that actually declares the handler.
|
|
1156
|
+
let declaring = null;
|
|
1157
|
+
let cur = match.fiber?.return;
|
|
1158
|
+
let depth = 0;
|
|
1159
|
+
while (cur && depth < 25) {
|
|
1160
|
+
if (typeof getProps(cur)?.onPress === "function") {
|
|
1161
|
+
declaring = cur;
|
|
1162
|
+
break;
|
|
1163
|
+
}
|
|
1164
|
+
cur = cur.return;
|
|
1165
|
+
depth++;
|
|
1166
|
+
}
|
|
1167
|
+
if (!declaring) return match;
|
|
1168
|
+
|
|
1169
|
+
// 2. Take the candidate AT that fiber, or the first one above it.
|
|
1170
|
+
//
|
|
1171
|
+
// Stopping exactly at the declaring fiber isn't enough: a `<Pressable>`
|
|
1172
|
+
// declares onPress but is de-duped into the component that renders it
|
|
1173
|
+
// (`PrimaryButton`), and de-duping keeps the OUTERMOST fiber of everything
|
|
1174
|
+
// sharing a host instance — which is where the testID ends up. Climbing a
|
|
1175
|
+
// few levels past the handler is what turns "Text: Add to bag" into
|
|
1176
|
+
// "PrimaryButton [testID=shop-add-to-bag]". The bound keeps a row inside a
|
|
1177
|
+
// pressable list from resolving to the list.
|
|
1178
|
+
let probe = declaring;
|
|
1179
|
+
for (let i = 0; i < 6 && probe; i++) {
|
|
1180
|
+
const candidate = byFiber.get(probe);
|
|
1181
|
+
if (candidate) return candidate;
|
|
1182
|
+
probe = probe.return;
|
|
1183
|
+
}
|
|
1184
|
+
return match;
|
|
1185
|
+
}
|
|
1186
|
+
function resolveTouchTarget(nativeTag) {
|
|
1187
|
+
const candidates = collectCandidates();
|
|
1188
|
+
if (candidates.length === 0) return null;
|
|
1189
|
+
const byFiber = new Map();
|
|
1190
|
+
for (const c of candidates) byFiber.set(c.fiber, c);
|
|
1191
|
+
|
|
1192
|
+
// Fast path: the touched host IS a candidate's host.
|
|
1193
|
+
const direct = candidates.find(c => c.nativeTag === nativeTag);
|
|
1194
|
+
|
|
1195
|
+
// Otherwise find the touched fiber and walk up to the nearest candidate. The
|
|
1196
|
+
// touch lands on the deepest host view (the <Text> inside the button), so the
|
|
1197
|
+
// interesting element is almost always an ancestor.
|
|
1198
|
+
let touched = null;
|
|
1199
|
+
if (!direct) {
|
|
1200
|
+
for (const root of getAllFiberRoots()) {
|
|
1201
|
+
traverseFiberTree(root.current, fiber => {
|
|
1202
|
+
if (touched) return;
|
|
1203
|
+
if (fiber.stateNode && getNativeTag(fiber.stateNode) === nativeTag) {
|
|
1204
|
+
touched = fiber;
|
|
1205
|
+
}
|
|
1206
|
+
});
|
|
1207
|
+
if (touched) break;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
let match = direct ?? null;
|
|
1211
|
+
if (!match && touched) {
|
|
1212
|
+
let cur = touched;
|
|
1213
|
+
let depth = 0;
|
|
1214
|
+
// Prefer an ancestor that is actually drivable; fall back to the nearest
|
|
1215
|
+
// named one so an unlabeled tap still records something a human can read.
|
|
1216
|
+
let named = null;
|
|
1217
|
+
while (cur && depth < 25) {
|
|
1218
|
+
const c = byFiber.get(cur);
|
|
1219
|
+
if (c) {
|
|
1220
|
+
if (c.press || c.valueControl || c.textControl) {
|
|
1221
|
+
match = c;
|
|
1222
|
+
break;
|
|
1223
|
+
}
|
|
1224
|
+
if (!named) named = c;
|
|
1225
|
+
}
|
|
1226
|
+
cur = cur.return;
|
|
1227
|
+
depth++;
|
|
1228
|
+
}
|
|
1229
|
+
if (!match) match = named;
|
|
1230
|
+
}
|
|
1231
|
+
if (!match) return null;
|
|
1232
|
+
match = preferHandlerOwner(match, byFiber);
|
|
1233
|
+
if (!match.press && !match.valueControl && !match.textControl) return null;
|
|
1234
|
+
// Buoy's own chrome is not part of the app under test. Without this, the tap
|
|
1235
|
+
// that stops a recording is recorded as its final step.
|
|
1236
|
+
if (isBuoyChrome(match.fiber)) return null;
|
|
1237
|
+
const control = match.valueControl ? match.valueControl.kind : match.textControl ? "text" : "press";
|
|
1238
|
+
return {
|
|
1239
|
+
nativeTag: match.nativeTag,
|
|
1240
|
+
name: match.name,
|
|
1241
|
+
role: match.role,
|
|
1242
|
+
testID: match.testID,
|
|
1243
|
+
label: match.label,
|
|
1244
|
+
text: match.text,
|
|
1245
|
+
control,
|
|
1246
|
+
value: match.valueControl ? match.valueControl.current : match.textControl ? match.textControl.current : undefined
|
|
1247
|
+
};
|
|
1248
|
+
}
|