diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml index a97a4cb..54746e4 100644 --- a/.github/workflows/e2e-android.yml +++ b/.github/workflows/e2e-android.yml @@ -63,7 +63,7 @@ jobs: - name: 디스크 공간 확보 run: | - sudo apt-get remove -y '^dotnet-.*' 'php.*' '^mongodb-.*' '^mysql-.*' azure-cli google-chrome-stable firefox powershell mono-devel libgl1-mesa-dri + sudo apt-get remove -y '^dotnet-.*' 'php.*' '^mongodb-.*' '^mysql-.*' azure-cli google-chrome-stable firefox powershell mono-devel sudo apt-get autoremove -y sudo apt-get clean sudo rm -rf /usr/share/dotnet/ /usr/local/graalvm/ /usr/local/.ghcup/ /usr/local/share/powershell /usr/local/share/chromium @@ -152,6 +152,13 @@ jobs: mkdir -p examples/demo-app/android/app/build/outputs/apk/release cp "${{ runner.temp }}/e2e-app-cache/android/app-release.apk" examples/demo-app/android/app/build/outputs/apk/release/ + - name: Android SDK PATH 설정 및 adb 시작 + run: | + echo "$ANDROID_HOME/platform-tools" >> $GITHUB_PATH + echo "$ANDROID_HOME/emulator" >> $GITHUB_PATH + $ANDROID_HOME/platform-tools/adb start-server + $ANDROID_HOME/platform-tools/adb devices + - name: AVD 캐시 uses: actions/cache@v4 id: avd-cache @@ -159,7 +166,7 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-${{ steps.avd.outputs.name }}-1080x1920-ram4096-v3 + key: avd-${{ steps.avd.outputs.name }}-1080x1920-ram4096-v4 - name: AVD 스냅샷 생성 (캐시 미스 시) if: steps.avd-cache.outputs.cache-hit != 'true' @@ -173,6 +180,7 @@ jobs: heap-size: 2048M force-avd-creation: false emulator-options: -no-window -skin 1080x1920 -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -cores 4 + emulator-boot-timeout: 600 disable-animations: false script: echo "Generated AVD snapshot for caching." @@ -188,6 +196,7 @@ jobs: heap-size: 2048M force-avd-creation: false emulator-options: -no-snapshot-save -no-window -skin 1080x1920 -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -cores 4 + emulator-boot-timeout: 600 disable-animations: true script: | echo "=== Connected Android devices (adb devices -l) ===" @@ -232,6 +241,16 @@ jobs: E2E_CMD="node packages/react-native-mcp-server/dist/test/cli.js run examples/demo-app/e2e/all-steps.yaml -p android -o e2e-artifacts/yaml-results --no-auto-launch" $E2E_CMD || { echo "--- E2E 1차 실패, 재시도 ---"; sleep 5; $E2E_CMD; } + # CLI (rn-mcp) E2E smoke test + echo "" + echo "=== CLI (rn-mcp) E2E smoke test ===" + # YAML 테스트 후 앱 재시작 + adb shell am force-stop com.reactnativemcp.demo + sleep 2 + adb shell monkey -p com.reactnativemcp.demo -c android.intent.category.LAUNCHER 1 + sleep 8 + bash examples/demo-app/e2e/cli-smoke.sh --platform android + - name: 실패 시 스크린샷·로그 저장 if: failure() timeout-minutes: 2 diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml index 83ca0ec..b9a36db 100644 --- a/.github/workflows/e2e-ios.yml +++ b/.github/workflows/e2e-ios.yml @@ -248,6 +248,19 @@ jobs: max_attempts: 2 command: node packages/react-native-mcp-server/dist/test/cli.js run examples/demo-app/e2e/all-steps.yaml -p ios -o e2e-artifacts/yaml-results --no-auto-launch + - name: CLI (rn-mcp) E2E smoke test + if: success() + timeout-minutes: 5 + env: + REACT_NATIVE_MCP_TAP_TIMEOUT_MS: 25000 + run: | + # YAML 테스트 후 앱이 마지막 화면에 있으므로 재시작 + xcrun simctl terminate booted org.reactnativemcp.demo 2>/dev/null || true + sleep 2 + xcrun simctl launch booted org.reactnativemcp.demo 2>/dev/null || true + sleep 8 + bash examples/demo-app/e2e/cli-smoke.sh --platform ios + - name: 실패 시 스크린샷·로그 저장 if: failure() timeout-minutes: 2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 534aa95..cf0efe0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,3 +46,21 @@ jobs: - name: 테스트 실행 run: bun run test:unit + + - name: CLI smoke test + run: | + node packages/react-native-mcp-server/dist/cli.js --version + node packages/react-native-mcp-server/dist/cli.js --help | grep -q "WORKFLOW" + echo "✓ CLI --help and --version work" + + - name: CLI init-agent test + run: | + cd "$(mktemp -d)" + node "$GITHUB_WORKSPACE/packages/react-native-mcp-server/dist/cli.js" init-agent --lang en + grep -q "rn-mcp snapshot" AGENTS.md + grep -q "rn-mcp snapshot" CLAUDE.md + # 한국어로 교체 + node "$GITHUB_WORKSPACE/packages/react-native-mcp-server/dist/cli.js" init-agent --lang ko + grep -q "연결 확인" AGENTS.md + grep -q "연결 확인" CLAUDE.md + echo "✓ init-agent creates and updates guide files" diff --git a/AGENTS.MD b/AGENTS.MD index da780ef..05d4daf 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -116,3 +116,58 @@ bun run format # oxfmt - [E2E YAML Reference](document/docs/en/testing/e2e-yaml-reference.md) — YAML scenario syntax and steps - Runner: `packages/react-native-mcp-server/src/e2e/` - Config: `e2e.yaml` at project root + +--- + +## CLI (rn-mcp) — AI 에이전트 권장 인터페이스 + +셸 접근이 가능한 AI 에이전트(Claude Code, Codex 등)는 MCP 도구 대신 `rn-mcp` CLI를 사용하세요. +Snapshot + Refs 패턴으로 토큰 소비를 크게 절감합니다. + +### 필수 워크플로우 + +```bash +# 1. 연결 확인 +rn-mcp status + +# 2. 화면의 interactive 요소를 @ref로 조회 +rn-mcp snapshot -i + +# 3. ref로 요소 조작 +rn-mcp tap @e3 +rn-mcp type @e5 "user@example.com" + +# 4. 화면이 바뀌었으면 snapshot 재호출 +rn-mcp snapshot -i +``` + +### 핵심 규칙 + +1. **snapshot -i를 먼저** — 앱 화면을 모르는 상태에서 tap하지 마세요 +2. **화면 전환 후 snapshot 재호출** — 탭 후 화면이 바뀌면 기존 refs는 무효 +3. **ref "not found" = snapshot 필요** — `✗ @e3 not found` 에러 시 `rn-mcp snapshot -i` +4. **스크린샷보다 assert** — `rn-mcp assert text "Welcome"`이 스크린샷보다 토큰 절약 +5. **iOS orientation 자동 처리** — CLI가 내부적으로 좌표 변환 (별도 조치 불필요) + +### 명령어 빠른 참조 + +| 명령 | 용도 | +|------|------| +| `rn-mcp status` | 연결 상태 확인 | +| `rn-mcp snapshot -i` | interactive 요소 + @ref 할당 | +| `rn-mcp tap @e3` | 탭 | +| `rn-mcp tap @e3 --long 500` | 롱프레스 | +| `rn-mcp type @e5 "텍스트"` | 텍스트 입력 | +| `rn-mcp swipe @e2 down` | 스와이프 | +| `rn-mcp key back` | 하드웨어 키 (back, home, enter) | +| `rn-mcp assert text "Welcome"` | 텍스트 존재 확인 | +| `rn-mcp assert visible @e3` | 가시성 확인 | +| `rn-mcp query "#btn"` | 요소 조회 | +| `rn-mcp screenshot -o s.png` | 스크린샷 | + +### CLI vs MCP 선택 기준 + +- **CLI 가능** (Claude Code, Codex 등) → `rn-mcp` CLI 사용 (토큰 효율) +- **CLI 불가** (Cursor, Windsurf 등 MCP만 지원) → 기존 MCP 도구 사용 + +자세한 사용법: `rn-mcp --help` diff --git a/docs/cli-spec.md b/docs/cli-spec.md new file mode 100644 index 0000000..79c4bc1 --- /dev/null +++ b/docs/cli-spec.md @@ -0,0 +1,260 @@ +# react-native-mcp CLI Spec (Snapshot + Refs) + +## 개요 + +기존 MCP 프로토콜은 유지하면서, AI 에이전트가 셸에서 직접 호출할 수 있는 CLI 인터페이스를 추가한다. +agent-browser의 Snapshot+Refs 패턴을 React Native Fiber 트리에 적용. + +## 아키텍처 + +``` +┌─────────┐ ┌──────────────────────┐ ┌─────────────┐ +│ AI │────▶│ rn-mcp (CLI) │────▶│ RN App │ +│ Agent │ sh │ (경량 클라이언트) │ WS │ (runtime) │ +└─────────┘ └──────────────────────┘ └─────────────┘ + │ + ▼ + ~/.rn-mcp/session.json ← refs 매핑 저장 +``` + +### 통신 구조 + +- CLI → WebSocket 서버(포트 12300)에 직접 연결 (기존 AppSession 재사용) +- MCP 서버를 경유하지 않음 — CLI는 독립 프로세스 +- 세션 파일(`~/.rn-mcp/session.json`)에 refs 매핑 저장 → CLI 호출 간 상태 공유 + +## 명령어 + +### 연결 & 상태 + +```bash +rn-mcp status # 연결 상태, 디바이스 목록 +rn-mcp devices # 연결된 디바이스 목록 (deviceId, platform) +``` + +### Snapshot (핵심) + +```bash +rn-mcp snapshot # 전체 컴포넌트 트리 + refs 할당 +rn-mcp snapshot -i # interactive 요소만 (Pressable, Button, TextInput 등) +rn-mcp snapshot --max-depth 10 # 깊이 제한 +``` + +**출력 예시:** + +``` +@e1 View #main-container +@e2 ScrollView #feed +@e3 Pressable #post-1 "첫 번째 게시글" +@e4 Pressable #post-2 "두 번째 게시글" +@e5 TextInput #search "검색..." +@e6 Pressable #settings-btn "설정" +``` + +- 각 요소에 `@e1`, `@e2`, ... 순번 부여 (depth-first 순회) +- refs는 `~/.rn-mcp/session.json`에 저장 → 이후 명령에서 사용 +- `-i` 플래그: interactive 요소만 필터 → 토큰 대폭 절감 + +### 상호작용 + +```bash +rn-mcp tap @e3 # ref로 탭 (내부: uid → 좌표 조회 → idb/adb tap) +rn-mcp tap @e3 --long 500 # 롱프레스 (500ms) +rn-mcp type @e5 "검색어" # ref의 TextInput에 텍스트 입력 +rn-mcp clear @e5 # ref의 TextInput 텍스트 삭제 +rn-mcp swipe @e2 down # ref 요소 기준 스와이프 +rn-mcp swipe @e2 down --dist 300 # 거리 지정 +rn-mcp scroll @e2 until @e10 # @e2 내에서 @e10이 보일 때까지 스크롤 +rn-mcp key back # 하드웨어 버튼 (back, home, enter) +``` + +### 검증 (assert) + +```bash +rn-mcp assert text "환영합니다" # 화면에 텍스트 존재 확인 +rn-mcp assert visible @e3 # 요소 가시성 확인 +rn-mcp assert not-visible @e3 # 요소 비가시성 확인 +rn-mcp assert count "Pressable" 5 # 셀렉터 매칭 개수 확인 +``` + +- exit code 0 = pass, 1 = fail +- stdout에 pass/fail 메시지 + +### 셀렉터 직접 사용 + +refs 대신 기존 셀렉터도 지원 (snapshot 없이 바로 사용 가능): + +```bash +rn-mcp tap "#login-btn" # testID로 탭 +rn-mcp tap "Pressable:text(\"로그인\")" # 셀렉터로 탭 +rn-mcp query "#login-btn" # 요소 정보 조회 (좌표, uid 등) +rn-mcp query-all "Pressable" # 매칭되는 모든 요소 +``` + +### 스크린샷 & 미디어 + +```bash +rn-mcp screenshot # stdout에 base64 또는 파일 저장 +rn-mcp screenshot -o login.png # 파일로 저장 +rn-mcp record start -o test.mp4 # 녹화 시작 +rn-mcp record stop # 녹화 종료 +``` + +### WebView + +```bash +rn-mcp webview list # 등록된 WebView ID 목록 +rn-mcp webview eval "document.querySelector('button').click()" +rn-mcp webview eval "document.querySelector('input').value" +``` + +### 디바이스 제어 + +```bash +rn-mcp orientation # 현재 방향 +rn-mcp orientation landscape # 방향 변경 +rn-mcp deeplink "myapp://profile/123" +rn-mcp location 37.5665 126.9780 # GPS 설정 (시뮬/에뮬) +``` + +### 디버깅 + +```bash +rn-mcp console # 콘솔 로그 +rn-mcp console --level error # 에러만 +rn-mcp network # 네트워크 요청 목록 +rn-mcp eval "() => global.myVar" # 앱 컨텍스트에서 JS 실행 +``` + +## 전역 옵션 + +```bash +--device, -d # 디바이스 지정 (다중 연결 시) +--platform, -p # ios | android +--json # JSON 출력 (스크립팅용) +--port # WebSocket 포트 (기본 12300) +--timeout # 명령 타임아웃 (기본 10000) +``` + +## Refs 생명주기 + +``` +snapshot → refs 생성 (@e1, @e2, ...) + ↓ +tap @e3 → 성공 (refs 유효) + ↓ +tap @e3 → "✗ @e3 not found — run `rn-mcp snapshot` to refresh" + ↓ +snapshot → 새 refs 생성 (이전 refs 전부 무효화) +``` + +### 규칙 + +1. `snapshot` 호출 시 **이전 refs 전부 무효화**, 새 refs 생성 +2. ref가 가리키는 요소가 화면에 없으면 에러 반환 + exit code 1 +3. AI가 화면 전환을 예상하면 직접 `snapshot` 재호출 +4. refs는 세션 파일에 저장 → CLI 호출 간 공유 + +### 세션 파일 구조 + +```json +{ + "port": 12300, + "deviceId": "ios-1", + "platform": "ios", + "refs": { + "@e1": { "uid": "main-container", "type": "View", "testID": "main-container" }, + "@e2": { "uid": "1.0.1", "type": "ScrollView", "testID": "feed" }, + "@e3": { "uid": "1.0.1.0", "type": "Pressable", "testID": "post-1", "text": "첫 번째 게시글" } + }, + "updatedAt": "2026-03-21T10:30:00Z" +} +``` + +## ref → 좌표 변환 흐름 + +``` +rn-mcp tap @e3 + ↓ +1. session.json에서 @e3 → uid="1.0.1.0" 조회 +2. WebSocket으로 querySelectorWithMeasure(uid) 호출 +3. { pageX, pageY, width, height } 응답 +4. 중심 좌표 계산: (pageX + width/2, pageY + height/2) +5. idb ui tap / adb shell input tap 실행 +6. stdout: "✓ tapped @e3 Pressable #post-1" +``` + +- ref에 좌표를 캐싱하지 않음 — 매번 measure 호출 +- 화면 전환/스크롤 후에도 uid가 유효하면 새 좌표로 탭 가능 + +## 구현 계획 + +### Phase 1: 핵심 (MVP) + +- `rn-mcp snapshot [-i]` — Fiber 트리 + refs 할당 +- `rn-mcp tap @ref` — ref로 탭 +- `rn-mcp type @ref "text"` — ref로 텍스트 입력 +- `rn-mcp assert text "..."` — 텍스트 존재 확인 +- `rn-mcp status` — 연결 상태 +- 세션 파일 관리 + +### Phase 2: 상호작용 확장 + +- `rn-mcp swipe`, `rn-mcp scroll`, `rn-mcp key` +- `rn-mcp screenshot`, `rn-mcp record` +- `rn-mcp query`, `rn-mcp query-all` (셀렉터 직접 사용) +- `--json` 출력 모드 + +### Phase 3: 디버깅 & WebView + +- `rn-mcp console`, `rn-mcp network` +- `rn-mcp webview eval` +- `rn-mcp eval` +- `rn-mcp orientation`, `rn-mcp deeplink`, `rn-mcp location` + +## AI 에이전트 연동 방식 + +### CLAUDE.md / AGENTS.md에 명시 + +```markdown +## React Native App Control + +이 프로젝트의 React Native 앱을 제어하려면 `rn-mcp` CLI를 사용하세요. + +### 기본 워크플로우 + +1. `rn-mcp status` — 앱 연결 확인 +2. `rn-mcp snapshot -i` — 화면의 interactive 요소를 @ref로 조회 +3. `rn-mcp tap @e3` — ref로 요소 탭 +4. 화면이 바뀌었으면 `rn-mcp snapshot -i`로 새 refs 획득 + +### 규칙 + +- 스크린샷보다 `snapshot -i`와 `assert text`를 우선 사용 (토큰 절약) +- tap/type 후 화면 전환이 예상되면 반드시 `snapshot -i` 재호출 +- ref가 "not found"이면 `snapshot -i`로 갱신 +- WebView 내부 요소는 `rn-mcp webview eval`로 조작 +``` + +### MCP와 CLI 공존 + +| 연동 방식 | 사용 도구 | 대상 | +| --------- | ------------------------------- | ---------------------------- | +| MCP 서버 | Cursor, Windsurf 등 에디터 | MCP 프로토콜 지원 클라이언트 | +| CLI | Claude Code, Codex, 셸 스크립트 | 셸 접근 가능한 AI 에이전트 | + +동일한 WebSocket 서버(포트 12300)에 연결하므로 동시 사용 가능. +단, refs는 CLI 세션에서만 관리 — MCP 쪽에서는 기존 query_selector 방식 유지. + +## bin 필드 + +```json +{ + "bin": { + "react-native-mcp-server": "dist/index.js", + "rn-mcp": "dist/cli.js" + } +} +``` + +별도 entry point `src/cli.ts`로 분리. 기존 `src/index.ts`(MCP 서버)는 변경 없음. diff --git a/document/docs/en/mcp/_meta.json b/document/docs/en/mcp/_meta.json index 45771d2..ebf994e 100644 --- a/document/docs/en/mcp/_meta.json +++ b/document/docs/en/mcp/_meta.json @@ -1,6 +1,7 @@ [ { "type": "file", "name": "index", "label": "Install & connect" }, { "type": "file", "name": "cli-init", "label": "CLI Init (Quick Setup)" }, + { "type": "file", "name": "cli", "label": "rn-mcp CLI (Snapshot + Refs)" }, { "type": "file", "name": "getting-started", "label": "Getting Started" }, { "type": "file", "name": "architecture", "label": "Architecture" }, { "type": "file", "name": "mcp-usage", "label": "Cursor / Claude / Copilot" }, diff --git a/document/docs/en/mcp/cli.md b/document/docs/en/mcp/cli.md new file mode 100644 index 0000000..957a8bf --- /dev/null +++ b/document/docs/en/mcp/cli.md @@ -0,0 +1,194 @@ +# rn-mcp CLI (Snapshot + Refs) + +A shell-first interface for AI agents to control React Native apps. Uses the **Snapshot + Refs** pattern inspired by [agent-browser](https://github.com/vercel-labs/agent-browser) for token-efficient interaction. + +## Why CLI? + +| | MCP Tools | rn-mcp CLI | +|---|---|---| +| **Token cost** | High — full JSON responses per tool call | Low — compact refs (`@e1`, `@e2`) | +| **Steps to tap** | query_selector → extract coords → tap (3 calls) | `rn-mcp tap @e3` (1 call) | +| **Setup** | MCP client config required | Shell command, no config | +| **Best for** | Cursor, Windsurf (MCP-only editors) | Claude Code, Codex, shell scripts | + +## Installation + +The CLI is included in the same package: + +```bash +# Global install +npm install -g @ohah/react-native-mcp-server + +# Or use npx +npx rn-mcp --help + +# Or project-local +npm install -D @ohah/react-native-mcp-server +npx rn-mcp --help +``` + +## Prerequisites + +- MCP server must be running (started by your editor or `npx react-native-mcp-server`) +- App must be running on a simulator/emulator and connected via WebSocket (port 12300) +- iOS: [idb](https://fbidb.io/) installed +- Android: [adb](https://developer.android.com/tools/adb) installed + +## Workflow + +```bash +# 1. Check connection +rn-mcp status + +# 2. Get interactive elements as @refs +rn-mcp snapshot -i + +# 3. Interact using @refs +rn-mcp tap @e3 +rn-mcp type @e5 "user@example.com" + +# 4. After screen transition, refresh refs +rn-mcp snapshot -i +``` + +### Snapshot output example + +``` +@e1 View #login-screen +@e2 TextInput #email "Email" +@e3 TextInput #password "Password" +@e4 Pressable #login-btn "Sign In" +@e5 Pressable #signup-link "Create Account" +``` + +Each element gets a short ref (`@e1`, `@e2`, ...) assigned in depth-first order. + +## Commands + +### Connection + +| Command | Description | +|---------|-------------| +| `rn-mcp status` | Show connection status and devices | + +### Snapshot + +| Command | Description | +|---------|-------------| +| `rn-mcp snapshot` | Full component tree with @refs | +| `rn-mcp snapshot -i` | Interactive elements only (recommended) | +| `rn-mcp snapshot --max-depth 10` | Limit tree depth (default: 30) | +| `rn-mcp snapshot -i --json` | JSON output for scripting | + +### Interaction + +| Command | Description | +|---------|-------------| +| `rn-mcp tap @e3` | Tap element by ref | +| `rn-mcp tap "#login-btn"` | Tap by selector | +| `rn-mcp tap @e3 --long 500` | Long press (500ms) | +| `rn-mcp type @e5 "text"` | Type into TextInput | +| `rn-mcp swipe @e2 down` | Swipe element | +| `rn-mcp swipe @e2 down --dist 300` | Swipe with distance (dp) | +| `rn-mcp key back` | Press hardware key | + +Available keys: `back`, `home`, `enter`, `tab`, `delete`, `up`, `down`, `left`, `right` + +### Assertions + +| Command | Description | +|---------|-------------| +| `rn-mcp assert text "Welcome"` | Verify text exists (exit 0/1) | +| `rn-mcp assert visible @e3` | Verify element is visible | +| `rn-mcp assert not-visible @e3` | Verify element is NOT visible | +| `rn-mcp assert count "Pressable" 5` | Verify element count | + +### Query + +| Command | Description | +|---------|-------------| +| `rn-mcp query "#my-btn"` | Query single element info | +| `rn-mcp query --all "Pressable"` | Query all matching elements | + +### Screenshot + +| Command | Description | +|---------|-------------| +| `rn-mcp screenshot` | Save screenshot (default: screenshot.png) | +| `rn-mcp screenshot -o login.png` | Save to specific file | + +### Agent Guide Setup + +| Command | Description | +|---------|-------------| +| `rn-mcp init-agent` | Add CLI guide to AGENTS.md + CLAUDE.md | +| `rn-mcp init-agent --lang ko` | Korean guide | +| `rn-mcp init-agent --target claude` | CLAUDE.md only | + +## Global Options + +``` +-d, --device Target device (when multiple connected) +-p, --platform ios or android +--port WebSocket port (default: 12300) +--json JSON output for scripting +--timeout Command timeout (default: 10000) +-h, --help Show help +-v, --version Show version +``` + +## Refs System + +### How refs work + +1. `rn-mcp snapshot -i` assigns `@e1`, `@e2`, ... to each element (depth-first order) +2. Refs are saved to `~/.rn-mcp/session.json` +3. Subsequent commands use refs: `rn-mcp tap @e3` +4. Running snapshot again **invalidates all previous refs** + +### When to re-snapshot + +- After screen transitions (navigation, modal open/close) +- When `@ref not found` error occurs +- After actions that change the UI structure + +### Selectors (alternative to refs) + +You can also use selectors directly without snapshot: + +```bash +rn-mcp tap "#login-btn" # by testID +rn-mcp tap "Pressable:text(\"Sign In\")" # by type + text +rn-mcp tap "TextInput[placeholder=\"Email\"]" # by attribute +``` + +## Important Notes + +- **iOS orientation** is handled automatically — no manual action needed +- **Android dp→px conversion** is automatic +- **Coordinates are in points (dp)**, not pixels +- Use `--json` flag for programmatic output parsing + +## Example: Login Flow + +```bash +# Check connection +rn-mcp status + +# See what's on screen +rn-mcp snapshot -i +# @e1 TextInput #email "Email" +# @e2 TextInput #password "Password" +# @e3 Pressable #login-btn "Sign In" + +# Fill form and submit +rn-mcp type @e1 "test@example.com" +rn-mcp type @e2 "password123" +rn-mcp tap @e3 + +# Verify navigation +rn-mcp assert text "Dashboard" + +# Get new screen's elements +rn-mcp snapshot -i +``` diff --git a/document/docs/ko/mcp/_meta.json b/document/docs/ko/mcp/_meta.json index 9bc32e7..3aeeec2 100644 --- a/document/docs/ko/mcp/_meta.json +++ b/document/docs/ko/mcp/_meta.json @@ -1,6 +1,7 @@ [ { "type": "file", "name": "index", "label": "설치 및 연결" }, { "type": "file", "name": "cli-init", "label": "CLI Init (빠른 설정)" }, + { "type": "file", "name": "cli", "label": "rn-mcp CLI (Snapshot + Refs)" }, { "type": "file", "name": "getting-started", "label": "시작하기" }, { "type": "file", "name": "architecture", "label": "아키텍처" }, { "type": "file", "name": "mcp-usage", "label": "Cursor / Claude / Copilot" }, diff --git a/document/docs/ko/mcp/cli.md b/document/docs/ko/mcp/cli.md new file mode 100644 index 0000000..77744b7 --- /dev/null +++ b/document/docs/ko/mcp/cli.md @@ -0,0 +1,195 @@ +# rn-mcp CLI (Snapshot + Refs) + +AI 에이전트가 셸에서 직접 React Native 앱을 제어하는 CLI 인터페이스입니다. +[agent-browser](https://github.com/vercel-labs/agent-browser)의 **Snapshot + Refs** 패턴을 적용하여 토큰 효율적인 상호작용을 지원합니다. + +## 왜 CLI인가? + +| | MCP 도구 | rn-mcp CLI | +|---|---|---| +| **토큰 비용** | 높음 — 매 호출마다 전체 JSON 응답 | 낮음 — compact refs (`@e1`, `@e2`) | +| **탭 단계** | query_selector → 좌표 추출 → tap (3회) | `rn-mcp tap @e3` (1회) | +| **설정** | MCP 클라이언트 설정 필요 | 셸 명령, 설정 불필요 | +| **적합 대상** | Cursor, Windsurf (MCP 전용 에디터) | Claude Code, Codex, 셸 스크립트 | + +## 설치 + +CLI는 같은 패키지에 포함되어 있습니다: + +```bash +# 글로벌 설치 +npm install -g @ohah/react-native-mcp-server + +# 또는 npx +npx rn-mcp --help + +# 또는 프로젝트 로컬 +npm install -D @ohah/react-native-mcp-server +npx rn-mcp --help +``` + +## 전제 조건 + +- MCP 서버가 실행 중이어야 합니다 (에디터가 시작하거나 `npx react-native-mcp-server`) +- 앱이 시뮬레이터/에뮬레이터에서 실행 중이고 WebSocket(포트 12300)으로 연결되어야 합니다 +- iOS: [idb](https://fbidb.io/) 설치 필요 +- Android: [adb](https://developer.android.com/tools/adb) 설치 필요 + +## 워크플로우 + +```bash +# 1. 연결 확인 +rn-mcp status + +# 2. interactive 요소를 @ref로 조회 +rn-mcp snapshot -i + +# 3. @ref로 요소 조작 +rn-mcp tap @e3 +rn-mcp type @e5 "user@example.com" + +# 4. 화면 전환 후 refs 갱신 +rn-mcp snapshot -i +``` + +### Snapshot 출력 예시 + +``` +@e1 View #login-screen +@e2 TextInput #email "이메일" +@e3 TextInput #password "비밀번호" +@e4 Pressable #login-btn "로그인" +@e5 Pressable #signup-link "회원가입" +``` + +각 요소에 `@e1`, `@e2`, ... 순번이 depth-first 순회로 부여됩니다. + +## 명령어 + +### 연결 + +| 명령 | 설명 | +|------|------| +| `rn-mcp status` | 연결 상태 및 디바이스 목록 | + +### Snapshot + +| 명령 | 설명 | +|------|------| +| `rn-mcp snapshot` | 전체 컴포넌트 트리 + @refs | +| `rn-mcp snapshot -i` | interactive 요소만 (권장) | +| `rn-mcp snapshot --max-depth 10` | 트리 깊이 제한 (기본: 30) | +| `rn-mcp snapshot -i --json` | 스크립팅용 JSON 출력 | + +### 상호작용 + +| 명령 | 설명 | +|------|------| +| `rn-mcp tap @e3` | ref로 탭 | +| `rn-mcp tap "#login-btn"` | 셀렉터로 탭 | +| `rn-mcp tap @e3 --long 500` | 롱프레스 (500ms) | +| `rn-mcp type @e5 "텍스트"` | TextInput에 입력 | +| `rn-mcp swipe @e2 down` | 스와이프 | +| `rn-mcp swipe @e2 down --dist 300` | 거리 지정 스와이프 (dp) | +| `rn-mcp key back` | 하드웨어 키 입력 | + +사용 가능한 키: `back`, `home`, `enter`, `tab`, `delete`, `up`, `down`, `left`, `right` + +### 검증 (Assert) + +| 명령 | 설명 | +|------|------| +| `rn-mcp assert text "환영합니다"` | 텍스트 존재 확인 (exit 0/1) | +| `rn-mcp assert visible @e3` | 요소 가시성 확인 | +| `rn-mcp assert not-visible @e3` | 요소 비가시성 확인 | +| `rn-mcp assert count "Pressable" 5` | 요소 개수 확인 | + +### 조회 (Query) + +| 명령 | 설명 | +|------|------| +| `rn-mcp query "#my-btn"` | 단일 요소 정보 조회 | +| `rn-mcp query --all "Pressable"` | 매칭되는 모든 요소 조회 | + +### 스크린샷 + +| 명령 | 설명 | +|------|------| +| `rn-mcp screenshot` | 스크린샷 저장 (기본: screenshot.png) | +| `rn-mcp screenshot -o login.png` | 파일명 지정 | + +### AI 에이전트 가이드 설정 + +| 명령 | 설명 | +|------|------| +| `rn-mcp init-agent` | AGENTS.md + CLAUDE.md에 CLI 가이드 추가 | +| `rn-mcp init-agent --lang ko` | 한국어 가이드 | +| `rn-mcp init-agent --target claude` | CLAUDE.md에만 추가 | + +## 전역 옵션 + +``` +-d, --device 대상 디바이스 (다중 연결 시) +-p, --platform ios 또는 android +--port WebSocket 포트 (기본: 12300) +--json 스크립팅용 JSON 출력 +--timeout 명령 타임아웃 (기본: 10000) +-h, --help 도움말 +-v, --version 버전 정보 +``` + +## Refs 시스템 + +### 동작 방식 + +1. `rn-mcp snapshot -i`가 각 요소에 `@e1`, `@e2`, ... 할당 (depth-first 순회) +2. Refs가 `~/.rn-mcp/session.json`에 저장됨 +3. 이후 명령에서 refs 사용: `rn-mcp tap @e3` +4. snapshot을 다시 실행하면 **이전 refs 전부 무효화** + +### 언제 re-snapshot 해야 하나 + +- 화면 전환 후 (네비게이션, 모달 열기/닫기) +- `@ref not found` 에러 발생 시 +- UI 구조가 변경되는 액션 후 + +### 셀렉터 (refs 대안) + +snapshot 없이 셀렉터를 직접 사용할 수도 있습니다: + +```bash +rn-mcp tap "#login-btn" # testID로 +rn-mcp tap "Pressable:text(\"로그인\")" # type + text로 +rn-mcp tap "TextInput[placeholder=\"이메일\"]" # 속성으로 +``` + +## 주의사항 + +- **iOS orientation**은 자동 처리됩니다 — 별도 조치 불필요 +- **Android dp→px 변환**도 자동입니다 +- **좌표는 points(dp)** 단위, 픽셀이 아닙니다 +- `--json` 플래그로 프로그래밍 방식의 출력 파싱 가능 + +## 예시: 로그인 플로우 + +```bash +# 연결 확인 +rn-mcp status + +# 화면 요소 확인 +rn-mcp snapshot -i +# @e1 TextInput #email "이메일" +# @e2 TextInput #password "비밀번호" +# @e3 Pressable #login-btn "로그인" + +# 폼 입력 및 제출 +rn-mcp type @e1 "test@example.com" +rn-mcp type @e2 "password123" +rn-mcp tap @e3 + +# 네비게이션 확인 +rn-mcp assert text "대시보드" + +# 새 화면 요소 확인 +rn-mcp snapshot -i +``` diff --git a/examples/demo-app/e2e/cli-smoke.sh b/examples/demo-app/e2e/cli-smoke.sh new file mode 100755 index 0000000..554bdf1 --- /dev/null +++ b/examples/demo-app/e2e/cli-smoke.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# rn-mcp CLI E2E smoke test +# MCP 서버를 백그라운드로 시작하고, CLI로 앱을 제어하는 통합 테스트. +# 앱은 이미 시뮬레이터/에뮬레이터에서 실행 중이어야 함. +# +# Usage: bash examples/demo-app/e2e/cli-smoke.sh [--platform ios|android] +set -euo pipefail + +PLATFORM="${1:---platform}" +PLATFORM_VAL="${2:-ios}" +SCRIPT_DIR="$(cd "$(dirname "$0")/../../.." && pwd)" +MCP_SERVER="node $SCRIPT_DIR/packages/react-native-mcp-server/dist/index.js" +CLI="node $SCRIPT_DIR/packages/react-native-mcp-server/dist/cli.js" +TIMEOUT="--timeout 25000" +MCP_PID="" + +# 도움말: 함수 정의 +pass() { echo " ✓ $1"; } +fail() { echo " ✗ $1"; cleanup; exit 1; } +step() { echo ""; echo "── $1 ──"; } + +cleanup() { + if [ -n "$MCP_PID" ]; then + echo "Stopping MCP server..." + # 서브셸의 자식(tail, node) 먼저 kill + pkill -P "$MCP_PID" 2>/dev/null || true + kill "$MCP_PID" 2>/dev/null || true + # 잔여 프로세스 강제 정리 (emulator-runner 행 방지) + sleep 1 + pkill -f "tail -f /dev/null" 2>/dev/null || true + wait 2>/dev/null || true + fi +} +trap cleanup EXIT + +# MCP 서버 시작 (WebSocket 서버 제공) +# 서브셸(tail + node)로 실행, cleanup 시 pkill -P + pkill -f로 확실히 정리. +step "0. MCP 서버 시작 (백그라운드)" +(tail -f /dev/null | $MCP_SERVER > /dev/null 2>&1) & +MCP_PID=$! +echo " MCP server PID: $MCP_PID" + +# 앱이 WebSocket에 연결될 때까지 대기 (최대 30초) +echo " Waiting for app to connect..." +for i in $(seq 1 30); do + if $CLI status --timeout 3000 2>&1 | grep -q "Connected"; then + pass "app connected (${i}s)" + break + fi + if [ "$i" -eq 30 ]; then + fail "app did not connect within 30s" + fi + sleep 1 +done + +step "1. CLI --help / --version" +$CLI --version | grep -q "rn-mcp v" && pass "--version" || fail "--version" +$CLI --help | grep -q "WORKFLOW" && pass "--help" || fail "--help" + +step "2. status (디바이스 정보 확인)" +$CLI status $TIMEOUT 2>&1 | grep -q "$PLATFORM_VAL\|Connected" && pass "status shows device" || fail "status" + +step "3. snapshot -i (interactive 요소 조회)" +SNAPSHOT=$($CLI snapshot -i $TIMEOUT 2>&1) +echo "$SNAPSHOT" | head -10 +echo "$SNAPSHOT" | grep -q "@e1" && pass "snapshot refs assigned" || fail "snapshot: no refs" +echo "$SNAPSHOT" | grep -q "Pressable\|Button\|TextInput" && pass "snapshot has interactive elements" || fail "snapshot: no interactive elements" + +step "4. snapshot --json (JSON 출력)" +$CLI snapshot -i --json $TIMEOUT 2>&1 | grep -q '"refs"' && pass "snapshot --json" || fail "snapshot --json" + +step "5. assert text (텍스트 검증)" +$CLI assert text "Count:" $TIMEOUT && pass "assert text found" || fail "assert text" + +step "6. tap @ref (ref로 탭)" +# snapshot에서 Count 버튼의 ref를 찾아서 탭 +COUNT_REF=$(echo "$SNAPSHOT" | grep 'Pressable.*"Count:' | head -1 | awk '{print $1}' || true) +if [ -z "$COUNT_REF" ]; then + # ref가 없으면 셀렉터로 직접 탭 + echo " (Count ref not found in snapshot, using selector fallback)" + $CLI tap 'Pressable:text("Count:")' $TIMEOUT && pass "tap by selector" || fail "tap by selector" +else + $CLI tap "$COUNT_REF" $TIMEOUT && pass "tap $COUNT_REF" || fail "tap $COUNT_REF" +fi +sleep 2 + +step "7. assert text after tap (탭 후 텍스트 변경 확인)" +# Count가 증가했으면 "Count: " 뒤에 0보다 큰 숫자 +$CLI assert text "Count:" $TIMEOUT && pass "text still exists after tap" || fail "text gone after tap" + +step "8. query (셀렉터 조회)" +$CLI query 'Pressable:text("Count:")' $TIMEOUT 2>&1 | grep -q "Pressable" && pass "query" || fail "query" + +step "9. snapshot 재호출 (refs 갱신)" +NEW_SNAPSHOT=$($CLI snapshot -i $TIMEOUT 2>&1) +echo "$NEW_SNAPSHOT" | grep -q "@e1" && pass "re-snapshot" || fail "re-snapshot" + +step "10. init-agent (가이드 생성)" +TMPDIR=$(mktemp -d) +cd "$TMPDIR" +$CLI init-agent --lang en 2>&1 +[ -f AGENTS.md ] && grep -q "rn-mcp" AGENTS.md && pass "init-agent AGENTS.md" || fail "init-agent AGENTS.md" +[ -f CLAUDE.md ] && grep -q "rn-mcp" CLAUDE.md && pass "init-agent CLAUDE.md" || fail "init-agent CLAUDE.md" +rm -rf "$TMPDIR" + +echo "" +echo "═══════════════════════════════════" +echo " ✓ All CLI smoke tests passed!" +echo "═══════════════════════════════════" diff --git a/packages/react-native-mcp-server/package.json b/packages/react-native-mcp-server/package.json index fbbb991..5281df5 100644 --- a/packages/react-native-mcp-server/package.json +++ b/packages/react-native-mcp-server/package.json @@ -21,7 +21,8 @@ "directory": "packages/react-native-mcp-server" }, "bin": { - "react-native-mcp-server": "dist/index.js" + "react-native-mcp-server": "dist/index.js", + "rn-mcp": "dist/cli.js" }, "files": [ "dist", diff --git a/packages/react-native-mcp-server/src/__tests__/cli/agent-guide.test.ts b/packages/react-native-mcp-server/src/__tests__/cli/agent-guide.test.ts new file mode 100644 index 0000000..292bd8f --- /dev/null +++ b/packages/react-native-mcp-server/src/__tests__/cli/agent-guide.test.ts @@ -0,0 +1,100 @@ +/** + * CLI init-agent 가이드 텍스트 테스트. + * 마커 존재, 한영 버전 구조, 교체 로직 검증. + */ +import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { GUIDE_EN, GUIDE_KO, MARKER_START, MARKER_END } from '../../cli/agent-guide.js'; + +describe('agent-guide', () => { + it('영어 가이드에 마커 포함', () => { + expect(GUIDE_EN).toContain(MARKER_START); + expect(GUIDE_EN).toContain(MARKER_END); + }); + + it('한국어 가이드에 마커 포함', () => { + expect(GUIDE_KO).toContain(MARKER_START); + expect(GUIDE_KO).toContain(MARKER_END); + }); + + it('영어 가이드에 필수 명령어 포함', () => { + expect(GUIDE_EN).toContain('rn-mcp status'); + expect(GUIDE_EN).toContain('rn-mcp snapshot -i'); + expect(GUIDE_EN).toContain('rn-mcp tap'); + expect(GUIDE_EN).toContain('rn-mcp type'); + expect(GUIDE_EN).toContain('rn-mcp assert'); + }); + + it('한국어 가이드에 필수 명령어 포함', () => { + expect(GUIDE_KO).toContain('rn-mcp status'); + expect(GUIDE_KO).toContain('rn-mcp snapshot -i'); + expect(GUIDE_KO).toContain('rn-mcp tap'); + }); + + it('영어 가이드에 워크플로우 순서 포함', () => { + expect(GUIDE_EN).toContain('Check connection'); + expect(GUIDE_EN).toContain('snapshot -i'); + expect(GUIDE_EN).toContain('screen transition'); + }); + + it('한국어 가이드에 워크플로우 순서 포함', () => { + expect(GUIDE_KO).toContain('연결 확인'); + expect(GUIDE_KO).toContain('snapshot -i'); + expect(GUIDE_KO).toContain('화면 전환'); + }); +}); + +describe('init-agent file operations', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(path.join(os.tmpdir(), 'rn-mcp-test-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('기존 파일에 가이드 추가', () => { + const filePath = path.join(tmpDir, 'AGENTS.md'); + writeFileSync(filePath, '# My Project\n\nSome content.\n'); + + // 가이드 추가 + const content = readFileSync(filePath, 'utf8'); + writeFileSync(filePath, content + '\n' + GUIDE_EN + '\n'); + + const result = readFileSync(filePath, 'utf8'); + expect(result).toContain('# My Project'); + expect(result).toContain('Some content.'); + expect(result).toContain(MARKER_START); + expect(result).toContain('rn-mcp snapshot -i'); + expect(result).toContain(MARKER_END); + }); + + it('기존 가이드 교체', () => { + const filePath = path.join(tmpDir, 'CLAUDE.md'); + // 영어 가이드가 이미 있는 파일 + writeFileSync(filePath, `# Claude\n\n${GUIDE_EN}\n\n## Other section\n`); + + // 한국어로 교체 + const content = readFileSync(filePath, 'utf8'); + const regex = new RegExp( + `${MARKER_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[\\s\\S]*?${MARKER_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + 'g' + ); + const updated = content.replace(regex, GUIDE_KO); + writeFileSync(filePath, updated); + + const result = readFileSync(filePath, 'utf8'); + expect(result).toContain('# Claude'); + expect(result).toContain('## Other section'); + // 한국어로 교체됨 + expect(result).toContain('연결 확인'); + // 영어 워크플로우는 없어야 함 + expect(result).not.toContain('Check connection'); + // 마커는 한 쌍만 + expect(result.split(MARKER_START).length).toBe(2); + }); +}); diff --git a/packages/react-native-mcp-server/src/__tests__/cli/cli-help.test.ts b/packages/react-native-mcp-server/src/__tests__/cli/cli-help.test.ts new file mode 100644 index 0000000..cde81a4 --- /dev/null +++ b/packages/react-native-mcp-server/src/__tests__/cli/cli-help.test.ts @@ -0,0 +1,66 @@ +/** + * CLI --help / --version 출력 테스트. + * 빌드된 dist/cli.js가 정상 동작하는지 smoke test. + */ +import { describe, expect, it } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const CLI_PATH = path.resolve(import.meta.dirname, '../../../dist/cli.js'); + +function runCli(args: string[]): string { + return execFileSync('node', [CLI_PATH, ...args], { + encoding: 'utf8', + timeout: 10000, + }); +} + +describe('CLI smoke tests', () => { + it('--help 출력에 필수 섹션 포함', () => { + const output = runCli(['--help']); + expect(output).toContain('rn-mcp'); + expect(output).toContain('USAGE'); + expect(output).toContain('WORKFLOW'); + expect(output).toContain('COMMANDS'); + expect(output).toContain('snapshot'); + expect(output).toContain('tap'); + expect(output).toContain('type'); + expect(output).toContain('assert'); + expect(output).toContain('REFS SYSTEM'); + expect(output).toContain('EXAMPLES'); + expect(output).toContain('init-agent'); + }); + + it('--help에 iOS orientation 자동 처리 안내', () => { + const output = runCli(['--help']); + expect(output).toContain('iOS'); + expect(output).toContain('orientation'); + }); + + it('--help에 @ref 설명', () => { + const output = runCli(['--help']); + expect(output).toContain('@e1'); + expect(output).toContain('@e2'); + expect(output).toContain('session.json'); + }); + + it('--version 출력', () => { + const output = runCli(['--version']); + expect(output.trim()).toMatch(/^rn-mcp v\d+\.\d+\.\d+/); + }); + + it('인자 없으면 help 출력', () => { + const output = runCli([]); + expect(output).toContain('USAGE'); + }); + + it('잘못된 명령 → exit code 1', () => { + try { + runCli(['nonexistent-command']); + expect(true).toBe(false); // should not reach + } catch (err: any) { + expect(err.status).toBe(1); + expect(err.stderr.toString()).toContain('Unknown command'); + } + }); +}); diff --git a/packages/react-native-mcp-server/src/__tests__/cli/ref-map.test.ts b/packages/react-native-mcp-server/src/__tests__/cli/ref-map.test.ts new file mode 100644 index 0000000..7eb0c47 --- /dev/null +++ b/packages/react-native-mcp-server/src/__tests__/cli/ref-map.test.ts @@ -0,0 +1,138 @@ +/** + * CLI ref-map 테스트. + * Snapshot 트리에서 @e1, @e2, ... refs 할당 로직 검증. + */ +import { describe, expect, it } from 'bun:test'; +import { assignRefs } from '../../cli/ref-map.js'; + +const sampleTree = { + uid: 'root', + type: 'View', + testID: 'app-root', + children: [ + { + uid: 'header', + type: 'View', + testID: 'header', + children: [ + { uid: 'title', type: 'Text', text: 'Welcome' }, + { uid: 'subtitle', type: 'Text', text: 'Hello world' }, + ], + }, + { + uid: 'email-input', + type: 'TextInput', + testID: 'email-input', + }, + { + uid: 'login-btn', + type: 'Pressable', + testID: 'login-btn', + text: '로그인', + }, + { + uid: '1.0.3', + type: 'Pressable', + text: '회원가입', + }, + { + uid: 'no-info', + type: 'View', + children: [{ uid: 'nested-btn', type: 'Button', text: '확인' }], + }, + ], +}; + +describe('assignRefs', () => { + it('전체 모드: 의미 있는 모든 노드에 ref 할당', () => { + const { refs, lines } = assignRefs(sampleTree, false); + + // refs에 @e1부터 순번이 할당되어야 함 + expect(Object.keys(refs).length).toBeGreaterThan(0); + expect(refs['@e1']).toBeDefined(); + + // root 노드가 @e1 + expect(refs['@e1']!.type).toBe('View'); + expect(refs['@e1']!.testID).toBe('app-root'); + + // lines가 생성되어야 함 + expect(lines.length).toBeGreaterThan(0); + expect(lines[0]).toContain('@e1'); + expect(lines[0]).toContain('View'); + }); + + it('interactive 모드: interactive 요소만 포함', () => { + const { refs, lines } = assignRefs(sampleTree, true); + + // TextInput, Pressable, Button만 포함 + const types = Object.values(refs).map((r) => r.type); + for (const t of types) { + // root(depth=0)는 항상 포함, 나머지는 interactive만 + if (t === 'View') continue; // root + expect( + [ + 'TextInput', + 'Pressable', + 'Button', + 'TouchableOpacity', + 'TouchableHighlight', + 'Switch', + ].includes(t) + ).toBe(true); + } + + // Text 노드는 포함되지 않아야 함 + const hasText = Object.values(refs).some((r) => r.type === 'Text'); + expect(hasText).toBe(false); + }); + + it('ref에 uid, type, testID, text 저장', () => { + const { refs } = assignRefs(sampleTree, false); + + // testID가 있는 요소 + const loginRef = Object.values(refs).find((r) => r.testID === 'login-btn'); + expect(loginRef).toBeDefined(); + expect(loginRef!.uid).toBe('login-btn'); + expect(loginRef!.type).toBe('Pressable'); + expect(loginRef!.text).toBe('로그인'); + + // testID가 없는 요소 + const signupRef = Object.values(refs).find((r) => r.text === '회원가입'); + expect(signupRef).toBeDefined(); + expect(signupRef!.uid).toBe('1.0.3'); + expect(signupRef!.testID).toBeUndefined(); + }); + + it('depth-first 순회 순서', () => { + const { lines } = assignRefs(sampleTree, false); + + // @e1이 @e2보다 먼저, 형제는 선언 순서 + const refOrder = lines.map((l) => l.match(/@e\d+/)?.[0]).filter(Boolean); + expect(refOrder[0]).toBe('@e1'); + + // 순번이 증가해야 함 + for (let i = 1; i < refOrder.length; i++) { + const prev = parseInt(refOrder[i - 1]!.replace('@e', ''), 10); + const curr = parseInt(refOrder[i]!.replace('@e', ''), 10); + expect(curr).toBe(prev + 1); + } + }); + + it('빈 트리', () => { + const { refs, lines } = assignRefs({ type: 'View', uid: 'root' }, false); + // root만 있어도 @e1 할당 + expect(refs['@e1']).toBeDefined(); + expect(lines.length).toBe(1); + }); + + it('lines 포맷: ref + indent + type + testID + text', () => { + const { lines } = assignRefs(sampleTree, false); + + // root: "@e1 View #app-root" + expect(lines[0]).toMatch(/^@e1\s+View #app-root$/); + + // text가 있는 줄: 따옴표로 감싸져야 함 + const textLine = lines.find((l) => l.includes('"Welcome"')); + expect(textLine).toBeDefined(); + }); +}); diff --git a/packages/react-native-mcp-server/src/__tests__/cli/session.test.ts b/packages/react-native-mcp-server/src/__tests__/cli/session.test.ts new file mode 100644 index 0000000..aa84071 --- /dev/null +++ b/packages/react-native-mcp-server/src/__tests__/cli/session.test.ts @@ -0,0 +1,48 @@ +/** + * CLI session 테스트. + * ~/.rn-mcp/session.json 관리 로직 검증. + */ +import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +// session.ts의 SESSION_DIR을 직접 테스트하기 어려우므로 resolveRef만 단위 테스트 +import { resolveRef, type Session } from '../../cli/session.js'; + +describe('resolveRef', () => { + const session: Session = { + port: 12300, + deviceId: 'ios-1', + platform: 'ios', + refs: { + '@e1': { uid: 'root', type: 'View', testID: 'app-root' }, + '@e2': { uid: 'login-btn', type: 'Pressable', testID: 'login-btn', text: '로그인' }, + '@e3': { uid: '1.0.3', type: 'Pressable', text: '회원가입' }, + }, + updatedAt: '2026-03-21T10:00:00Z', + }; + + it('유효한 ref 반환', () => { + const ref = resolveRef(session, '@e2'); + expect(ref.uid).toBe('login-btn'); + expect(ref.type).toBe('Pressable'); + expect(ref.testID).toBe('login-btn'); + expect(ref.text).toBe('로그인'); + }); + + it('testID 없는 ref도 반환', () => { + const ref = resolveRef(session, '@e3'); + expect(ref.uid).toBe('1.0.3'); + expect(ref.testID).toBeUndefined(); + expect(ref.text).toBe('회원가입'); + }); + + it('존재하지 않는 ref → 에러', () => { + expect(() => resolveRef(session, '@e99')).toThrow('@e99 not found'); + }); + + it('에러 메시지에 snapshot 안내 포함', () => { + expect(() => resolveRef(session, '@e10')).toThrow('snapshot'); + }); +}); diff --git a/packages/react-native-mcp-server/src/cli.ts b/packages/react-native-mcp-server/src/cli.ts new file mode 100644 index 0000000..aeacd78 --- /dev/null +++ b/packages/react-native-mcp-server/src/cli.ts @@ -0,0 +1,263 @@ +#!/usr/bin/env node +/** + * rn-mcp CLI — React Native 앱 제어 CLI (Snapshot + Refs 패턴) + * + * MCP 프로토콜 없이 셸에서 직접 React Native 앱을 제어. + * AI 에이전트 (Claude Code, Codex 등)가 셸 명령으로 앱과 상호작용. + */ + +import { parseArgs } from 'node:util'; +import { VERSION } from './version.js'; + +// ─── Help text ─────────────────────────────────────────────────── + +const HELP = ` +rn-mcp v${VERSION} — React Native app control CLI (Snapshot + Refs) + +USAGE + rn-mcp [options] + +WORKFLOW (IMPORTANT — follow this order) + 1. rn-mcp status Check connection (is the app running?) + 2. rn-mcp snapshot -i Get interactive elements as @refs + 3. rn-mcp tap @e3 Interact using @refs + 4. rn-mcp snapshot -i Refresh refs after screen change + + ⚠ iOS: orientation is handled automatically during tap/swipe. + ⚠ Refs become invalid after screen transitions — re-run snapshot. + ⚠ If a ref returns "not found", run snapshot -i to refresh. + +COMMANDS + status Show connection status and devices + snapshot [-i] [--max-depth N] Capture component tree with @refs + -i, --interactive Interactive elements only (recommended) + --max-depth N Tree depth limit (default: 30) + + tap <@ref|selector> Tap an element + --long Long press duration in ms + + type <@ref|selector> Type text into TextInput + + swipe <@ref|selector> Swipe on element + : up, down, left, right + --dist Swipe distance in dp + + key