diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml index 9dc5a26..a8e8518 100644 --- a/.github/workflows/e2e-android.yml +++ b/.github/workflows/e2e-android.yml @@ -191,6 +191,20 @@ jobs: echo "=== Available AVDs ===" emulator -list-avds 2>/dev/null || [ -z "${ANDROID_HOME:-}" ] || "$ANDROID_HOME/emulator/emulator" -list-avds 2>/dev/null || true + echo "=== Emulator diagnostics ===" + echo "--- wm size ---" + adb shell wm size || true + echo "--- wm density ---" + adb shell wm density || true + echo "--- display info (dumpsys display | grep -E 'mBaseDisplayInfo|density|DisplayDeviceInfo') ---" + adb shell dumpsys display | grep -E 'mBaseDisplayInfo|density|DisplayDeviceInfo' || true + echo "--- top inset (statusBars / captionBar) ---" + adb shell dumpsys window displays | grep -E 'type=(statusBars|captionBar|navigationBars)' || true + echo "--- window policy (freeform / desktop) ---" + adb shell settings get global force_desktop_mode_on_external_displays 2>/dev/null || true + adb shell settings get global enable_freeform_support 2>/dev/null || true + echo "=== End diagnostics ===" + # APK 설치 (runner가 줄마다 별도 sh -c로 실행해 변수 미공유 → 경로 리터럴 사용) ls -la examples/demo-app/android/app/build/outputs/apk/release/app-release.apk adb install -r examples/demo-app/android/app/build/outputs/apk/release/app-release.apk diff --git a/docs/e2e-ci-reliability.md b/docs/e2e-ci-reliability.md index 895c866..67cc15a 100644 --- a/docs/e2e-ci-reliability.md +++ b/docs/e2e-ci-reliability.md @@ -4,6 +4,18 @@ GitHub Actions에서 E2E 테스트가 가끔 실패하는 경우(flakiness)와 --- +## 간헐 실패의 공통·플랫폼별 원인 + +| 구분 | 원인 요약 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **공통** | CI 러너는 로컬보다 **시뮬/에뮬·앱·MCP·idb/adb** 전반이 느리다. 같은 스텝이라도 타이밍에 따라 query_selector·measure·tap 중 하나가 지연되거나 실패한다. | +| **iOS** | **idb `ui tap`** 이 시뮬레이터에 전달되기 전/후로 시뮬레이터가 무거우면 25초 타임아웃까지 응답이 안 나온다. `query_selector`는 성공해도 그 다음 tap 단계에서 "Command timed out"이 난다. | +| **Android** | **measure 보충**이 실패한다. 요소는 셀렉터로 찾지만, Fabric/Bridge의 `measureInWindow`·`UIManager.measure` 콜백이 CI에서 늦게 오거나 오지 않아 "has no measure data"가 난다. 화면 전환·리플로우 직후에 measure를 요청할 때 특히 불안정하다. | + +즉, **iOS는 “tap 명령 타임아웃”**, **Android는 “요소는 찾았지만 좌표(measure) 수집 실패”**가 주된 간헐 원인이다. 둘 다 **CI 환경의 지연·타이밍**에서 오는 flakiness다. + +--- + ## CI에서 하는 검사 - **Doctor**: E2E 워크플로에서 의존성 설치 후 `doctor`를 실행한다. Node ≥ 24, react-native ≥ 0.74 @@ -27,4 +39,19 @@ GitHub Actions에서 E2E 테스트가 가끔 실패하는 경우(flakiness)와 ## 재실행 권장 일시적 지연이나 측정 타이밍 이슈로 실패한 경우, GitHub Actions에서 **Re-run failed jobs** 또는 **Re-run all jobs**로 -한 번 더 돌리면 통과하는 경우가 많다. 스위트 전체 재시도는 아직 CI에 넣지 않았으며, 필요 시 수동 재실행을 권장한다. +한 번 더 돌리면 통과하는 경우가 많다. + +--- + +## 항상 성공에 가깝게 하려면 + +간헐 원인이 타이밍/지연이므로, **한 번 실패해도 한 번 더 시도**하면 통과할 가능성이 높다. 아래는 효과 순으로 정리한 선택지다. + +| 우선순위 | 방법 | 설명 | 부작용 | +| -------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | +| **1** | **CI에서 E2E 단계 재시도** | E2E YAML 테스트 실행 스텝을 실패 시 1회 재시도(예: 최대 2회 실행). 한 번의 flake를 흡수한다. | 실패 시 런이 2배 길어질 수 있음(보통 첫 시도에서 성공). | +| **2** | **Runner에서 스텝 재시도** | `runner.ts`에서 `executeStep` 실패 시 짧은 대기 후 같은 스텝을 1회 더 실행. tap/measure 타임아웃을 스텝 단위로 완화. | 코드 변경 필요, 모든 스텝에 재시도 적용됨. | +| **3** | **tap 전 measure 재조회** | `has no measure data` 시 클라이언트에서 300ms 대기 후 `query_selector` 한 번 더 호출해 measure 보충 후 tap. Android measure flake 완화. | 서버/클라이언트 코드 변경. | +| **4** | **YAML 보수적 작성** | tap 직전 `wait`·`waitForVisible`·`waitForText`로 상태 안정화. testID 있으면 선택자로 testID 우선 사용. | 스텝 수·실행 시간 증가. (이미 일부 적용됨) | + +**권장**: **1번(CI E2E 단계 재시도)**를 먼저 적용하는 것이 부담이 적고 효과가 크다. 워크플로에서 "E2E YAML 테스트 실행" 스텝을 `run`으로 감싸서 실패 시 한 번 더 실행하거나, [nick-fields/retry](https://github.com/nick-fields/retry) 같은 액션으로 감싸면 된다. 2·3번은 필요 시 추가로 검토하면 된다. diff --git a/examples/demo-app/e2e/all-steps.yaml b/examples/demo-app/e2e/all-steps.yaml index 6ff500d..99e6530 100644 --- a/examples/demo-app/e2e/all-steps.yaml +++ b/examples/demo-app/e2e/all-steps.yaml @@ -13,10 +13,11 @@ setup: - waitForVisible: selector: 'Pressable:text("Count:")' timeout: 25000 + - wait: 500 steps: - # ─── Step 1: Press Counter (텍스트로 찾기) ────────────────────────── - - assertText: { text: 'Count: 0', selector: 'Pressable:text("Count:")' } + # ─── Step 1: Press Counter (CI 초기 렌더 안정화: waitForText로 초기 상태 대기) ─ + - waitForText: { text: 'Count: 0', selector: 'Pressable:text("Count:")', timeout: 5000 } - tap: { selector: 'Pressable:text("Count:")' } - wait: 500 - waitForText: { text: 'Count: 1', selector: 'Pressable:text("Count:")', timeout: 6000 } diff --git a/packages/react-native-mcp-server/runtime.js b/packages/react-native-mcp-server/runtime.js index 5239afd..478b522 100644 --- a/packages/react-native-mcp-server/runtime.js +++ b/packages/react-native-mcp-server/runtime.js @@ -94,6 +94,7 @@ } function setOverlayTopInsetDp(dp) { overlayTopInsetDp = dp; + overlayTopInsetConfirmed = true; } function setOverlayActive(active) { overlayActive = active; @@ -125,7 +126,7 @@ overlayMaxHighlights = 100; overlayRenderCounts = {}; } - var pressHandlers, consoleLogs, consoleLogId, CONSOLE_BUFFER_SIZE, networkRequests, networkRequestId, NETWORK_BUFFER_SIZE, NETWORK_BODY_LIMIT, networkMockRules, stateChanges, stateChangeId, STATE_CHANGE_BUFFER, renderProfileActive, renderProfileStartTime, renderCommitCount, renderEntries, renderComponentFilter, renderIgnoreFilter, RENDER_BUFFER_SIZE, renderHighlight, renderHighlightStyle, overlayTopInsetDp, overlayActive, overlayComponentFilter, overlayIgnoreFilter, overlayShowLabels, overlayFadeTimeout, overlayMaxHighlights, overlaySetHighlights, overlayRenderCounts; + var pressHandlers, consoleLogs, consoleLogId, CONSOLE_BUFFER_SIZE, networkRequests, networkRequestId, NETWORK_BUFFER_SIZE, NETWORK_BODY_LIMIT, networkMockRules, stateChanges, stateChangeId, STATE_CHANGE_BUFFER, renderProfileActive, renderProfileStartTime, renderCommitCount, renderEntries, renderComponentFilter, renderIgnoreFilter, RENDER_BUFFER_SIZE, renderHighlight, renderHighlightStyle, overlayTopInsetDp, overlayTopInsetConfirmed, overlayActive, overlayComponentFilter, overlayIgnoreFilter, overlayShowLabels, overlayFadeTimeout, overlayMaxHighlights, overlaySetHighlights, overlayRenderCounts; var init_shared = __esmMin(() => { pressHandlers = {}; consoleLogs = []; @@ -149,6 +150,7 @@ renderHighlight = typeof global !== "undefined" && global.__REACT_NATIVE_MCP_RENDER_HIGHLIGHT__ === true; renderHighlightStyle = typeof global !== "undefined" && global.__REACT_NATIVE_MCP_RENDER_HIGHLIGHT_STYLE__ === "react-scan" ? "react-scan" : "react-mcp"; overlayTopInsetDp = 0; + overlayTopInsetConfirmed = false; overlayActive = false; overlayComponentFilter = null; overlayIgnoreFilter = null; @@ -173,7 +175,7 @@ var first = roots.values().next().value; if (first) return toRootFiber(first); } - } catch (_e) {} + } catch (_unused) {} var renderer = hook.renderers && hook.renderers.get(rendererID); if (renderer && typeof renderer.getCurrentFiber === "function") { var fiber = renderer.getCurrentFiber(); @@ -421,7 +423,7 @@ return { selectors }; } /** compound 셀렉터가 단일 fiber 노드에 매칭되는지 검사 */ - function matchesCompound(fiber, compound, TextComp, ImgComp) { + function matchesCompound(fiber, compound, TextComp, _ImgComp) { if (!fiber) return false; var props = fiber.memoizedProps || {}; if (compound.type !== null) { @@ -447,21 +449,21 @@ return true; } /** 계층 셀렉터(A > B, A B) 매칭 — fiber.return을 상향 탐색 */ - function matchesComplexSelector(fiber, complex, TextComp, ImgComp) { + function matchesComplexSelector(fiber, complex, TextComp, _ImgComp) { var segs = complex.segments; var last = segs.length - 1; - if (!matchesCompound(fiber, segs[last].selector, TextComp, ImgComp)) return false; + if (!matchesCompound(fiber, segs[last].selector, TextComp, _ImgComp)) return false; var current = fiber; for (var i = last - 1; i >= 0; i--) { var combinator = segs[i + 1].combinator; var targetSel = segs[i].selector; if (combinator === ">") { current = current.return; - if (!current || !matchesCompound(current, targetSel, TextComp, ImgComp)) return false; + if (!current || !matchesCompound(current, targetSel, TextComp, _ImgComp)) return false; } else { current = current.return; while (current) { - if (matchesCompound(current, targetSel, TextComp, ImgComp)) break; + if (matchesCompound(current, targetSel, TextComp, _ImgComp)) break; current = current.return; } if (!current) return false; @@ -579,7 +581,7 @@ var debugStack = fiber && fiber._debugStack; if (!debugStack || typeof debugStack.stack !== "string") return []; return getSourceRefFromStack(debugStack.stack); - } catch (e) { + } catch (_unused) { return []; } } @@ -632,7 +634,7 @@ } visit(root); return out; - } catch (e) { + } catch (_unused2) { return []; } } @@ -662,7 +664,7 @@ } visit(root); return out; - } catch (e) { + } catch (_unused3) { return []; } } @@ -736,7 +738,7 @@ type: "Root", children: result } : result; - } catch (e) { + } catch (_unused4) { return null; } } @@ -1376,7 +1378,7 @@ if (!overlayActive || highlights.length === 0) return null; var topInsetDp = 0; if (RN.Platform.OS === "android") { - if (overlayTopInsetDp > 0) topInsetDp = overlayTopInsetDp; + if (overlayTopInsetConfirmed) topInsetDp = overlayTopInsetDp; else if (RN.StatusBar && typeof RN.StatusBar.currentHeight === "number") { var ratio = RN.PixelRatio && RN.PixelRatio.get ? RN.PixelRatio.get() : 1; topInsetDp = RN.StatusBar.currentHeight / ratio; @@ -1509,19 +1511,19 @@ if (typeof orig === "function") orig.call(hook, rendererID, root); try { if (root && root.current) collectStateChanges(root.current); - } catch (_e) {} + } catch (_unused) {} try { if (renderProfileActive && root && root.current) { incrementRenderCommitCount(); collectRenderEntries(root.current); } - } catch (_e) {} + } catch (_unused2) {} try { if (overlayActive && root && root.current) { collectOverlayHighlights(root.current); flushOverlayMeasurements(); } - } catch (_e) {} + } catch (_unused3) {} }; })(); }); @@ -1697,7 +1699,7 @@ } } return null; - } catch (e) { + } catch (_unused) { return null; } } @@ -1715,7 +1717,7 @@ _webViews[id] = ref; if (_webViewRefToId) try { _webViewRefToId.set(ref, id); - } catch (e) {} + } catch (_unused) {} } } function unregisterWebView(id) { @@ -1800,7 +1802,7 @@ value: payload.value }); return true; - } catch (_) { + } catch (_unused2) { return false; } } @@ -1830,7 +1832,7 @@ //#endregion //#region src/runtime/fiber-serialization.ts /** fiber 노드를 결과 객체로 직렬화 */ - function fiberToResult(fiber, TextComp, ImgComp) { + function fiberToResult(fiber, TextComp, _ImgComp) { var props = fiber.memoizedProps || {}; var typeName = getFiberTypeName(fiber); var testID = typeof props.testID === "string" && props.testID.trim() ? props.testID.trim() : void 0; @@ -1857,7 +1859,7 @@ var measure = null; try { measure = measureViewSync(uid); - } catch (e) {} + } catch (_unused) {} if (!measure && typeof fiber.type !== "string") { var hostChild = (function findHost(f) { if (!f) return null; @@ -1874,7 +1876,7 @@ var hostUid = getPathUid(hostChild); try { measure = measureViewSync(hostUid); - } catch (e) {} + } catch (_unused2) {} if (!measure) result._measureUid = hostUid; } } @@ -1985,11 +1987,11 @@ if (typeof fn === "function") { try { fn(); - } catch (e) {} + } catch (_unused) {} return true; } return false; - } catch (e) { + } catch (_unused2) { return false; } } @@ -2021,11 +2023,11 @@ if (typeof fn === "function") { try { fn(); - } catch (e) {} + } catch (_unused3) {} return true; } return false; - } catch (e) { + } catch (_unused4) { return false; } } @@ -2227,7 +2229,7 @@ var parsed; try { parsed = parseSelector(selector.trim()); - } catch (_parseErr) { + } catch (_unused) { return null; } var foundFiber = null; @@ -2267,7 +2269,7 @@ }; }) }; - } catch (e) { + } catch (_unused2) { return null; } } @@ -2412,7 +2414,7 @@ var parsed; try { parsed = parseSelector(selector.trim()); - } catch (parseErr) { + } catch (_unused) { return []; } var results = []; @@ -2451,7 +2453,7 @@ } } return deduped; - } catch (e) { + } catch (_unused2) { return []; } } @@ -2463,7 +2465,7 @@ try { var all = querySelectorAll(selector); return all.length > 0 ? all[0] : null; - } catch (e) { + } catch (_unused3) { return null; } } @@ -2604,7 +2606,7 @@ var measure = null; try { measure = globalThis.__REACT_NATIVE_MCP__.measureViewSync(uid); - } catch (e) {} + } catch (_unused) {} if (!measure && typeof item.fiber.type !== "string") { var hostChild = (function findHost(f) { if (!f) return null; @@ -2621,7 +2623,7 @@ var hostUid = hostChild.memoizedProps && hostChild.memoizedProps.testID || getPathUid(hostChild); try { measure = measureViewSync(hostUid); - } catch (e) {} + } catch (_unused2) {} } } if (measure && (measure.width < minTouchTarget || measure.height < minTouchTarget)) violations.push({ @@ -2632,7 +2634,7 @@ }); } return violations; - } catch (e) { + } catch (_unused3) { return []; } } @@ -2649,7 +2651,7 @@ if (rule.method && rule.method !== method) return false; if (rule.isRegex) try { return new RegExp(rule.urlPattern).test(url); - } catch (_e) { + } catch (_unused) { return false; } return url.indexOf(rule.urlPattern) !== -1; @@ -2911,7 +2913,7 @@ xhr.__didReceiveResponse(fakeId, mockResp.status, mockResp.headers || {}, entry.url); if (mockResp.body) xhr.__didReceiveData(fakeId, mockResp.body); xhr.__didCompleteResponse(fakeId, "", false); - } catch (_e) {} + } catch (_unused) {} }; setTimeout(deliverMock, mockResp.delay > 0 ? mockResp.delay : 0); return; @@ -2922,12 +2924,12 @@ entry.statusText = xhr.statusText || null; try { entry.responseHeaders = xhr.getAllResponseHeaders() || null; - } catch (_e) { + } catch (_unused2) { entry.responseHeaders = null; } try { entry.responseBody = truncateBody(xhr.responseText); - } catch (_e) { + } catch (_unused3) { entry.responseBody = null; } entry.duration = Date.now() - entry.startTime; @@ -2983,7 +2985,7 @@ requestHeaders[key] = input.headers[key]; } } - } catch (_e) {} + } catch (_unused) {} if (input.body != null) requestBody = input.body; } if (init && typeof init === "object") { @@ -3000,7 +3002,7 @@ requestHeaders[key] = init.headers[key]; } } - } catch (_e) {} + } catch (_unused2) {} if (init.body != null) requestBody = init.body; } var bodyStr = null; @@ -3044,7 +3046,7 @@ statusText: mockResp.statusText || "", headers: mockResp.headers }); - } catch (_e) { + } catch (_unused3) { var _body = mockResp.body; fakeResponse = { ok: mockResp.status >= 200 && mockResp.status < 300, @@ -3099,7 +3101,7 @@ headerObj[k] = v; }); entry.responseHeaders = JSON.stringify(headerObj); - } catch (_e) { + } catch (_unused4) { entry.responseHeaders = null; } entry.duration = Date.now() - entry.startTime; @@ -3111,7 +3113,7 @@ }).catch(function() { pushNetworkEntry(entry); }); - } catch (_e) { + } catch (_unused5) { pushNetworkEntry(entry); } return response; @@ -3153,13 +3155,13 @@ } try { ws.send(JSON.stringify({ type: "ping" })); - } catch (_e) { + } catch (_unused) { return; } _pongTimer = setTimeout(function() { if (ws) try { ws.close(); - } catch (_e) {} + } catch (_unused2) {} }, PONG_TIMEOUT_MS); }, HEARTBEAT_INTERVAL_MS); } @@ -3168,7 +3170,7 @@ if (ws && (ws.readyState === 0 || ws.readyState === 1)) return; if (ws) try { ws.close(); - } catch (_e) {} + } catch (_unused3) {} ws = new WebSocket(wsUrl); ws.onopen = function() { if (typeof console !== "undefined" && console.warn) console.warn("[MCP] Connected to server", wsUrl); @@ -3179,11 +3181,19 @@ var deviceName = null; var origin = null; var pixelRatio = null; + var screenHeight = null; + var windowHeight = null; try { var rn = require("react-native"); platform = rn.Platform && rn.Platform.OS; deviceName = rn.Platform && rn.Platform.constants && rn.Platform.constants.Model || null; if (rn.PixelRatio) pixelRatio = rn.PixelRatio.get(); + if (rn.Dimensions) { + var screenDim = rn.Dimensions.get("screen"); + var windowDim = rn.Dimensions.get("window"); + if (screenDim && typeof screenDim.height === "number") screenHeight = screenDim.height; + if (windowDim && typeof windowDim.height === "number") windowHeight = windowDim.height; + } } catch (_e) { if (typeof console !== "undefined" && console.warn) console.warn("[MCP] Failed to read platform info:", _e instanceof Error ? _e.message : String(_e)); } @@ -3192,7 +3202,7 @@ var scriptURL = _rn.NativeModules && _rn.NativeModules.SourceCode && _rn.NativeModules.SourceCode.scriptURL; if (scriptURL && typeof scriptURL === "string") try { origin = new URL(scriptURL).origin; - } catch (_ue) { + } catch (_unused4) { var _match$; var match = scriptURL.match(/^(https?:\/\/[^/?#]+)/); if (match) origin = (_match$ = match[1]) !== null && _match$ !== void 0 ? _match$ : null; @@ -3207,7 +3217,9 @@ deviceId: platform ? platform + "-1" : void 0, deviceName, metroBaseUrl: origin, - pixelRatio + pixelRatio, + screenHeight, + windowHeight })); } catch (_e3) { if (typeof console !== "undefined" && console.warn) console.warn("[MCP] Failed to send init:", _e3 instanceof Error ? _e3.message : String(_e3)); @@ -3253,7 +3265,7 @@ }); else sendEvalResponse(result, null); } - } catch (_unused) {} + } catch (_unused5) {} }; ws.onclose = function() { _stopHeartbeat(); @@ -3304,7 +3316,7 @@ if (ws && ws.readyState === 1) _startHeartbeat(); } else _stopHeartbeat(); }); - } catch (_e) {} + } catch (_unused6) {} })(); PERIODIC_INTERVAL_MS = 5e3; setInterval(function() { diff --git a/packages/react-native-mcp-server/src/__tests__/tools-swipe-idb-coordinates.test.ts b/packages/react-native-mcp-server/src/__tests__/tools-swipe-idb-coordinates.test.ts index df6de5f..64dace6 100644 --- a/packages/react-native-mcp-server/src/__tests__/tools-swipe-idb-coordinates.test.ts +++ b/packages/react-native-mcp-server/src/__tests__/tools-swipe-idb-coordinates.test.ts @@ -27,7 +27,7 @@ mock.module('../tools/adb-utils.js', () => ({ runAdbCommand: async () => '', adbNotInstalledError: () => ({ content: [{ type: 'text' as const, text: 'adb not installed' }] }), getAndroidScale: async () => 3, - getAndroidTopInset: async () => 0, + getAndroidInsets: async () => ({ statusBarPx: 0, navBarPx: 0, captionBarPx: 0 }), })); // portrait (변환 없음) 으로 mock — 이 테스트는 좌표 정수 반올림 검증이 목적 diff --git a/packages/react-native-mcp-server/src/__tests__/websocket-server.test.ts b/packages/react-native-mcp-server/src/__tests__/websocket-server.test.ts index 20ff149..02c37a1 100644 --- a/packages/react-native-mcp-server/src/__tests__/websocket-server.test.ts +++ b/packages/react-native-mcp-server/src/__tests__/websocket-server.test.ts @@ -23,6 +23,8 @@ function makeDevice( metroBaseUrl: null, pixelRatio: null, topInsetPx: 0, + screenHeightDp: 0, + windowHeightDp: 0, lastMessageTime: Date.now(), }; } diff --git a/packages/react-native-mcp-server/src/client/app-client.ts b/packages/react-native-mcp-server/src/client/app-client.ts index 641c57c..01c4999 100644 --- a/packages/react-native-mcp-server/src/client/app-client.ts +++ b/packages/react-native-mcp-server/src/client/app-client.ts @@ -455,7 +455,16 @@ export class AppClient { const screen = await this.getScreenBounds(); try { const { cx: x, cy: y } = clampToViewport(rawX, rawY, el.measure, screen); - return this.tapXY(x, y, opts); + const m = el.measure; + console.error( + `[tap-debug] selector="${selector}" measure={pageX:${m.pageX},pageY:${m.pageY},w:${m.width},h:${m.height}}` + + ` center=(${rawX.toFixed(1)},${rawY.toFixed(1)}) clamped=(${x.toFixed(1)},${y.toFixed(1)}) screen=${JSON.stringify(screen)}` + ); + const result = await this.tapXY(x, y, opts); + console.error( + `[tap-debug] tapXY response: ${typeof result === 'string' ? result : JSON.stringify(result)}` + ); + return result; } catch (e) { if (e instanceof OffScreenError) { throw new McpToolError('tap', `Element "${selector}" is off-screen: ${e.message}`); diff --git a/packages/react-native-mcp-server/src/runtime/connection.ts b/packages/react-native-mcp-server/src/runtime/connection.ts index 2f41dc1..d6a875b 100644 --- a/packages/react-native-mcp-server/src/runtime/connection.ts +++ b/packages/react-native-mcp-server/src/runtime/connection.ts @@ -86,11 +86,19 @@ function connect(): void { var deviceName: string | null = null; var origin: string | null = null; var pixelRatio: number | null = null; + var screenHeight: number | null = null; + var windowHeight: number | null = null; try { var rn = require('react-native'); platform = rn.Platform && rn.Platform.OS; deviceName = (rn.Platform && rn.Platform.constants && rn.Platform.constants.Model) || null; if (rn.PixelRatio) pixelRatio = rn.PixelRatio.get(); + if (rn.Dimensions) { + var screenDim = rn.Dimensions.get('screen'); + var windowDim = rn.Dimensions.get('window'); + if (screenDim && typeof screenDim.height === 'number') screenHeight = screenDim.height; + if (windowDim && typeof windowDim.height === 'number') windowHeight = windowDim.height; + } } catch (_e) { if (typeof console !== 'undefined' && console.warn) { console.warn( @@ -129,6 +137,8 @@ function connect(): void { deviceName: deviceName, metroBaseUrl: origin, pixelRatio: pixelRatio, + screenHeight: screenHeight, + windowHeight: windowHeight, }) ); } catch (_e3) { diff --git a/packages/react-native-mcp-server/src/runtime/render-overlay.ts b/packages/react-native-mcp-server/src/runtime/render-overlay.ts index c847c20..ae46aca 100644 --- a/packages/react-native-mcp-server/src/runtime/render-overlay.ts +++ b/packages/react-native-mcp-server/src/runtime/render-overlay.ts @@ -36,6 +36,7 @@ import { setOverlaySetHighlights as setOverlaySetHighlightsFn, resetOverlay, overlayTopInsetDp, + overlayTopInsetConfirmed, } from './shared'; import { resolveScreenOffset, screenOffsetX, screenOffsetY } from './screen-offset'; @@ -503,9 +504,10 @@ export function getOverlayComponent(): any { // Android: measureInWindow는 콘텐츠(윈도우) 기준 좌표를 반환하는데, 오버레이 루트는 화면 전체(0,0=상태바 위)라 // y에 상태바 높이(dp)를 더해 정렬. RN 이슈 #19497. 서버가 setTopInsetDp로 보낸 값 우선(tap/swipe와 동일). + // overlayTopInsetConfirmed가 true면 서버 값만 사용 (0이어도 — window가 statusBar 포함인 경우). var topInsetDp = 0; if (RN.Platform.OS === 'android') { - if (overlayTopInsetDp > 0) { + if (overlayTopInsetConfirmed) { topInsetDp = overlayTopInsetDp; } else if (RN.StatusBar && typeof (RN.StatusBar as any).currentHeight === 'number') { var ratio = RN.PixelRatio && RN.PixelRatio.get ? RN.PixelRatio.get() : 1; diff --git a/packages/react-native-mcp-server/src/runtime/shared.ts b/packages/react-native-mcp-server/src/runtime/shared.ts index 59f639d..4edda92 100644 --- a/packages/react-native-mcp-server/src/runtime/shared.ts +++ b/packages/react-native-mcp-server/src/runtime/shared.ts @@ -119,8 +119,11 @@ export var renderHighlightStyle: 'react-scan' | 'react-mcp' = // ─── Android overlay top inset (dp). 서버가 setTopInsetDp 메시지로 설정. tap/swipe와 동일 값 사용. export var overlayTopInsetDp = 0; +/** 서버가 setTopInsetDp를 보냈는지 여부 (값이 0이라도 확정). false면 StatusBar fallback 사용. */ +export var overlayTopInsetConfirmed = false; export function setOverlayTopInsetDp(dp: number) { overlayTopInsetDp = dp; + overlayTopInsetConfirmed = true; } // ─── Render overlay (render-overlay ↔ state-change-tracking) ─── diff --git a/packages/react-native-mcp-server/src/tools/adb-utils.ts b/packages/react-native-mcp-server/src/tools/adb-utils.ts index 143e403..dd6c549 100644 --- a/packages/react-native-mcp-server/src/tools/adb-utils.ts +++ b/packages/react-native-mcp-server/src/tools/adb-utils.ts @@ -158,55 +158,62 @@ export function _resetScaleCache(): void { _scaleBySerial.clear(); } -/* ─── Android top inset (상태바/캡션바 높이, px) ─── */ +/* ─── Android insets (상태바/내비바/캡션바 높이, px) ─── */ -const _topInsetBySerial = new Map(); +export interface AndroidInsets { + statusBarPx: number; + navBarPx: number; + captionBarPx: number; +} + +const _insetsBySerial = new Map(); /** - * Android 디바이스의 실제 top inset(px)을 `dumpsys window displays`에서 파싱. - * captionBar(태블릿 등)가 있으면 우선, 없으면 statusBars 사용. - * 파싱 실패 시 0 반환 (호출자가 fallback 처리). + * Android 디바이스의 시스템 insets(px)을 `dumpsys window displays`에서 파싱. + * statusBars, navigationBars, captionBar 세 종류 모두 반환. + * 파싱 실패 시 모두 0. */ -export async function getAndroidTopInset(serial?: string): Promise { +export async function getAndroidInsets(serial?: string): Promise { const key = serial ?? '_default'; - const cached = _topInsetBySerial.get(key); + const cached = _insetsBySerial.get(key); if (cached != null) return cached; try { const text = await runAdbCommand(['shell', 'dumpsys', 'window', 'displays'], serial, { timeoutMs: 5000, }); - // captionBar가 있으면 우선 (Pixel Tablet 등) - const captionMatch = text.match( - /InsetsSource[^\n]*type=captionBar[^\n]*frame=\[\d+,\d+\]\[\d+,(\d+)\]/ - ); - if (captionMatch) { - const px = parseInt(captionMatch[1]!, 10); - _topInsetBySerial.set(key, px); - return px; - } + let statusBarPx = 0; + let navBarPx = 0; + let captionBarPx = 0; - // 없으면 statusBars const statusMatch = text.match( /InsetsSource[^\n]*type=statusBars[^\n]*frame=\[\d+,\d+\]\[\d+,(\d+)\]/ ); - if (statusMatch) { - const px = parseInt(statusMatch[1]!, 10); - _topInsetBySerial.set(key, px); - return px; - } - - _topInsetBySerial.set(key, 0); - return 0; + if (statusMatch) statusBarPx = parseInt(statusMatch[1]!, 10); + + // navigationBars: frame=[0,H-navH][W,H] → height = H - (H-navH) = navH + // 또는 간단히 frame=[x1,y1][x2,y2] → height = y2 - y1 + const navMatch = text.match( + /InsetsSource[^\n]*type=navigationBars[^\n]*frame=\[\d+,(\d+)\]\[\d+,(\d+)\]/ + ); + if (navMatch) navBarPx = parseInt(navMatch[2]!, 10) - parseInt(navMatch[1]!, 10); + + const captionMatch = text.match( + /InsetsSource[^\n]*type=captionBar[^\n]*frame=\[\d+,\d+\]\[\d+,(\d+)\]/ + ); + if (captionMatch) captionBarPx = parseInt(captionMatch[1]!, 10); + + const result: AndroidInsets = { statusBarPx, navBarPx, captionBarPx }; + _insetsBySerial.set(key, result); + return result; } catch { - // 파싱 실패 시 캐싱하지 않음 → 다음 호출에서 재시도 - return 0; + return { statusBarPx: 0, navBarPx: 0, captionBarPx: 0 }; } } -/** 테스트용 top inset 캐시 초기화 */ -export function _resetTopInsetCache(): void { - _topInsetBySerial.clear(); +/** 테스트용 insets 캐시 초기화 */ +export function _resetInsetsCache(): void { + _insetsBySerial.clear(); } /* ─── 에러 헬퍼 ─── */ diff --git a/packages/react-native-mcp-server/src/tools/tap.ts b/packages/react-native-mcp-server/src/tools/tap.ts index 554d83d..6ac48de 100644 --- a/packages/react-native-mcp-server/src/tools/tap.ts +++ b/packages/react-native-mcp-server/src/tools/tap.ts @@ -106,26 +106,33 @@ export function registerTap(server: McpServer, appSession: AppSession): void { const topInsetDp = appSession.getTopInsetDp(deviceId, 'android'); const px = Math.round(x * scale); const py = Math.round((y + topInsetDp) * scale); - if (isLongPress) { - // Long press = swipe from same point to same point with duration - await runAdbCommand( - [ - 'shell', - 'input', - 'swipe', - String(px), - String(py), - String(px), - String(py), - String(duration), - ], - serial, - { timeoutMs: TAP_TIMEOUT_MS } - ); - } else { - await runAdbCommand(['shell', 'input', 'tap', String(px), String(py)], serial, { - timeoutMs: TAP_TIMEOUT_MS, - }); + const runTap = async (): Promise => { + if (isLongPress) { + await runAdbCommand( + [ + 'shell', + 'input', + 'swipe', + String(px), + String(py), + String(px), + String(py), + String(duration), + ], + serial, + { timeoutMs: TAP_TIMEOUT_MS } + ); + } else { + await runAdbCommand(['shell', 'input', 'tap', String(px), String(py)], serial, { + timeoutMs: TAP_TIMEOUT_MS, + }); + } + }; + try { + await runTap(); + } catch { + await new Promise((r) => setTimeout(r, 1500)); + await runTap(); } // Allow UI to update before returning so callers (e.g. assert_text) see the result. await new Promise((r) => setTimeout(r, 300)); diff --git a/packages/react-native-mcp-server/src/websocket-server.ts b/packages/react-native-mcp-server/src/websocket-server.ts index 3d8baa4..38b3d65 100644 --- a/packages/react-native-mcp-server/src/websocket-server.ts +++ b/packages/react-native-mcp-server/src/websocket-server.ts @@ -6,7 +6,7 @@ import { WebSocketServer, WebSocket } from 'ws'; import { setMetroBaseUrlFromApp } from './tools/metro-cdp.js'; -import { getAndroidTopInset } from './tools/adb-utils.js'; +import { getAndroidInsets } from './tools/adb-utils.js'; import { stopAllRecordings } from './tools/video-recording.js'; const DEFAULT_PORT = 12300; @@ -44,6 +44,12 @@ export interface DeviceConnection { topInsetPx: number; /** Android에서 ensureAndroidTopInset으로 dumpsys 시도한 적 있음. 0이어도 재호출 방지. */ topInsetAttempted?: boolean; + /** window가 statusBar를 포함하는지 여부. true면 measureInWindow이 screen-absolute → topInset 안 더함. */ + windowIncludesStatusBar?: boolean; + /** 런타임에서 보낸 screen height (dp). */ + screenHeightDp: number; + /** 런타임에서 보낸 window height (dp). */ + windowHeightDp: number; /** Date.now() of last message received from this device (for stale detection). */ lastMessageTime: number; } @@ -120,12 +126,16 @@ export class AppSession { .filter((c) => c.ws.readyState === WebSocket.OPEN) .map((c) => { const ratio = c.pixelRatio ?? 1; + let topInsetDp = 0; + if (c.platform === 'android' && !c.windowIncludesStatusBar && c.topInsetPx > 0) { + topInsetDp = c.topInsetPx / ratio; + } return { deviceId: c.deviceId, platform: c.platform, deviceName: c.deviceName, connected: true as const, - topInsetDp: c.platform === 'android' && c.topInsetPx > 0 ? c.topInsetPx / ratio : 0, + topInsetDp, }; }); } @@ -142,12 +152,15 @@ export class AppSession { /** * Android top inset을 dp 단위로 반환. - * topInsetPx / pixelRatio. iOS는 항상 0. + * windowIncludesStatusBar가 true면 0 (measureInWindow이 이미 screen-absolute). + * 그 외: topInsetPx / pixelRatio. iOS는 항상 0. */ getTopInsetDp(deviceId?: string, platform?: string): number { try { const conn = this.resolveDevice(deviceId, platform); - if (conn.platform !== 'android' || conn.topInsetPx === 0) return 0; + if (conn.platform !== 'android') return 0; + if (conn.windowIncludesStatusBar) return 0; + if (conn.topInsetPx === 0) return 0; const ratio = conn.pixelRatio ?? 1; return conn.topInsetPx / ratio; } catch { @@ -158,11 +171,13 @@ export class AppSession { /** * 수동으로 Android top inset(dp) 설정. * ADB 자동 감지 결과를 덮어쓴다. 앱 오버레이에도 전달한다. + * 수동 오버라이드 시 windowIncludesStatusBar 판별 리셋. */ setTopInsetDp(dp: number, deviceId?: string, platform?: string): void { const conn = this.resolveDevice(deviceId, platform); const ratio = conn.pixelRatio ?? 1; conn.topInsetPx = Math.round(dp * ratio); + conn.windowIncludesStatusBar = false; if (conn.platform === 'android' && conn.ws.readyState === 1) { conn.ws.send(JSON.stringify({ type: 'setTopInsetDp', topInsetDp: dp })); } @@ -171,6 +186,7 @@ export class AppSession { /** * Android tap 전에 top inset이 0이면 한 번만 dumpsys로 보충. * 연결당 한 번만 시도(topInsetAttempted). 0이어도 재호출하지 않아 adb 낭비 방지. + * windowIncludesStatusBar가 true면 skip (이미 판별 완료, inset 불필요). */ async ensureAndroidTopInset(deviceId: string | undefined, serial: string): Promise { let conn: DeviceConnection; @@ -179,15 +195,27 @@ export class AppSession { } catch { return; } + if (conn.windowIncludesStatusBar) return; if (conn.topInsetPx > 0) return; if (conn.topInsetAttempted) return; conn.topInsetAttempted = true; - const px = await getAndroidTopInset(serial); - if (px <= 0) return; + const insets = await getAndroidInsets(serial); + const topPx = insets.captionBarPx > 0 ? insets.captionBarPx : insets.statusBarPx; + if (topPx <= 0) return; const ratio = conn.pixelRatio ?? 1; - conn.topInsetPx = px; + conn.topInsetPx = topPx; + // 판별 로직 + const actualGap = conn.screenHeightDp - conn.windowHeightDp; + const navBarDp = insets.navBarPx / ratio; + if (Math.abs(actualGap - navBarDp) <= 4) { + conn.windowIncludesStatusBar = true; + if (conn.ws.readyState === 1) { + conn.ws.send(JSON.stringify({ type: 'setTopInsetDp', topInsetDp: 0 })); + } + return; + } if (conn.ws.readyState === 1) { - conn.ws.send(JSON.stringify({ type: 'setTopInsetDp', topInsetDp: px / ratio })); + conn.ws.send(JSON.stringify({ type: 'setTopInsetDp', topInsetDp: topPx / ratio })); } } @@ -339,6 +367,8 @@ export class AppSession { const deviceName = typeof msg.deviceName === 'string' ? msg.deviceName : null; const metroBaseUrl = typeof msg.metroBaseUrl === 'string' ? msg.metroBaseUrl : null; const pixelRatio = typeof msg.pixelRatio === 'number' ? msg.pixelRatio : null; + const screenHeightDp = typeof msg.screenHeight === 'number' ? msg.screenHeight : 0; + const windowHeightDp = typeof msg.windowHeight === 'number' ? msg.windowHeight : 0; const conn: DeviceConnection = { deviceId, @@ -349,6 +379,8 @@ export class AppSession { metroBaseUrl, pixelRatio, topInsetPx: 0, + screenHeightDp, + windowHeightDp, lastMessageTime: Date.now(), }; this.devices.set(deviceId, conn); @@ -361,25 +393,48 @@ export class AppSession { devices: this.getConnectedDevices(), }); - // Android: top inset 감지 (비동기, 연결 차단하지 않음). 앱 오버레이 좌표 보정용으로 앱에 전달. + // Android: top inset 감지 + windowIncludesStatusBar 판별 if (platform === 'android') { const ratio = pixelRatio ?? 1; - const sendTopInsetToApp = (px: number) => { - if (conn.ws.readyState === 1) { - const topInsetDp = px / ratio; - conn.ws.send(JSON.stringify({ type: 'setTopInsetDp', topInsetDp })); - } - }; // init 메시지에 topInsetDp가 있으면 수동 오버라이드 const userTopInset = typeof msg.topInsetDp === 'number' ? msg.topInsetDp : null; if (userTopInset != null) { conn.topInsetPx = Math.round(userTopInset * ratio); - sendTopInsetToApp(conn.topInsetPx); + if (conn.ws.readyState === 1) { + conn.ws.send(JSON.stringify({ type: 'setTopInsetDp', topInsetDp: userTopInset })); + } } else { - getAndroidTopInset() - .then((px) => { - conn.topInsetPx = px; - sendTopInsetToApp(px); + getAndroidInsets() + .then((insets) => { + const topPx = + insets.captionBarPx > 0 ? insets.captionBarPx : insets.statusBarPx; + conn.topInsetPx = topPx; + + // 판별: screenHeight - windowHeight ≈ navBarDp → window가 statusBar 포함 + const actualGap = screenHeightDp - windowHeightDp; + const navBarDp = insets.navBarPx / ratio; + console.error( + `[react-native-mcp-server] topInset detection: screenH=${screenHeightDp} windowH=${windowHeightDp} gap=${actualGap.toFixed(1)} navBarDp=${navBarDp.toFixed(1)} statusBarPx=${insets.statusBarPx} navBarPx=${insets.navBarPx}` + ); + if (Math.abs(actualGap - navBarDp) <= 4) { + conn.windowIncludesStatusBar = true; + console.error( + `[react-native-mcp-server] windowIncludesStatusBar=true → topInset=0dp` + ); + if (conn.ws.readyState === 1) { + conn.ws.send(JSON.stringify({ type: 'setTopInsetDp', topInsetDp: 0 })); + } + return; + } + + // window가 statusBar 미포함 → topInset 더함 + if (topPx > 0 && conn.ws.readyState === 1) { + const topInsetDp = topPx / ratio; + console.error( + `[react-native-mcp-server] windowIncludesStatusBar=false → topInset=${topInsetDp}dp` + ); + conn.ws.send(JSON.stringify({ type: 'setTopInsetDp', topInsetDp })); + } }) .catch(() => { /* keep 0 */