From 47b365d3edfd278f5be3fa1177ac9b4ddf0dbf5a Mon Sep 17 00:00:00 2001 From: ohah Date: Sat, 21 Mar 2026 04:54:18 +0900 Subject: [PATCH 01/17] feat: rn-mcp CLI with Snapshot + Refs pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-browser 스타일의 Snapshot + Refs 패턴을 React Native에 적용한 CLI 도구. AI 에이전트(Claude Code, Codex 등)가 셸에서 직접 앱을 제어할 수 있도록 함. 주요 기능: - snapshot -i: Fiber 트리에서 interactive 요소를 @e1, @e2 refs로 반환 - tap/type/swipe: refs로 요소 조작 (좌표 자동 변환, iOS orientation 자동 처리) - assert: 텍스트/가시성 검증 (exit code 0/1) - init-agent: AGENTS.md/CLAUDE.md에 CLI 가이드 자동 추가 (한영 지원) - query: 셀렉터 직접 사용 가능 구현: - WebSocket extension client로 기존 MCP 서버에 연결 (별도 서버 불필요) - ~/.rn-mcp/session.json에 refs 매핑 저장 (CLI 호출 간 상태 공유) - idb/adb 직접 실행 (기존 유틸 재사용) - bin: rn-mcp → dist/cli.js (기존 react-native-mcp-server와 같은 패키지) Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/test.yml | 18 + AGENTS.MD | 55 ++ docs/cli-spec.md | 254 +++++++ packages/react-native-mcp-server/package.json | 3 +- .../src/__tests__/cli/agent-guide.test.ts | 100 +++ .../src/__tests__/cli/cli-help.test.ts | 69 ++ .../src/__tests__/cli/ref-map.test.ts | 133 ++++ .../src/__tests__/cli/session.test.ts | 48 ++ packages/react-native-mcp-server/src/cli.ts | 263 +++++++ .../src/cli/agent-guide.ts | 123 +++ .../src/cli/commands.ts | 711 ++++++++++++++++++ .../src/cli/ref-map.ts | 89 +++ .../src/cli/session.ts | 52 ++ .../src/cli/ws-client.ts | 143 ++++ .../react-native-mcp-server/tsdown.config.ts | 1 + 15 files changed, 2061 insertions(+), 1 deletion(-) create mode 100644 docs/cli-spec.md create mode 100644 packages/react-native-mcp-server/src/__tests__/cli/agent-guide.test.ts create mode 100644 packages/react-native-mcp-server/src/__tests__/cli/cli-help.test.ts create mode 100644 packages/react-native-mcp-server/src/__tests__/cli/ref-map.test.ts create mode 100644 packages/react-native-mcp-server/src/__tests__/cli/session.test.ts create mode 100644 packages/react-native-mcp-server/src/cli.ts create mode 100644 packages/react-native-mcp-server/src/cli/agent-guide.ts create mode 100644 packages/react-native-mcp-server/src/cli/commands.ts create mode 100644 packages/react-native-mcp-server/src/cli/ref-map.ts create mode 100644 packages/react-native-mcp-server/src/cli/session.ts create mode 100644 packages/react-native-mcp-server/src/cli/ws-client.ts 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..d7d0f49 --- /dev/null +++ b/docs/cli-spec.md @@ -0,0 +1,254 @@ +# 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/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..63a9d33 --- /dev/null +++ b/packages/react-native-mcp-server/src/__tests__/cli/cli-help.test.ts @@ -0,0 +1,69 @@ +/** + * 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..a92e4f2 --- /dev/null +++ b/packages/react-native-mcp-server/src/__tests__/cli/ref-map.test.ts @@ -0,0 +1,133 @@ +/** + * 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