diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml index 2b9cf62..9dc5a26 100644 --- a/.github/workflows/e2e-android.yml +++ b/.github/workflows/e2e-android.yml @@ -39,7 +39,7 @@ jobs: needs: check if: needs.check.outputs.should-run == 'true' runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 steps: - name: 코드 체크아웃 @@ -208,13 +208,15 @@ jobs: - name: 실패 시 스크린샷·로그 저장 if: failure() + timeout-minutes: 2 run: | mkdir -p e2e-artifacts adb exec-out screencap -p > e2e-artifacts/failure-screenshot.png 2>/dev/null || true - adb logcat -d 2>/dev/null | tail -n 3000 > e2e-artifacts/logcat.txt || true + adb logcat -d -t 3000 2>/dev/null > e2e-artifacts/logcat.txt || true - name: 아티팩트 업로드 if: failure() + timeout-minutes: 3 uses: actions/upload-artifact@v4 with: name: e2e-android-failure-artifacts diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml index 6030307..0be2f49 100644 --- a/.github/workflows/e2e-ios.yml +++ b/.github/workflows/e2e-ios.yml @@ -208,6 +208,7 @@ jobs: - name: 실패 시 스크린샷·로그 저장 if: failure() + timeout-minutes: 2 run: | mkdir -p e2e-artifacts xcrun simctl io booted screenshot e2e-artifacts/failure-screenshot.png 2>/dev/null || true @@ -215,6 +216,7 @@ jobs: - name: 아티팩트 업로드 if: failure() + timeout-minutes: 3 uses: actions/upload-artifact@v4 with: name: e2e-ios-failure-artifacts diff --git a/docs/e2e-ci-reliability.md b/docs/e2e-ci-reliability.md index c128304..895c866 100644 --- a/docs/e2e-ci-reliability.md +++ b/docs/e2e-ci-reliability.md @@ -15,10 +15,10 @@ GitHub Actions에서 E2E 테스트가 가끔 실패하는 경우(flakiness)와 ## 자주 나오는 실패와 대응 -| 현상 | 원인 요약 | 대응 | -| ------------------------------ | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| iOS: `Command timed out` (tap) | idb tap이 25초 안에 끝나지 않음 | 워크플로에 이미 25초 설정됨. 여전히 실패하면 시뮬레이터 기기 고정([development-commands](development-commands.md#ci-e2e-시뮬레이터에뮬레이터-기기-지정)) 또는 재실행. | -| Android: `has no measure data` | 요소는 찾았지만 measure 보충 실패(타이밍/레이아웃) | 해당 스텝 직전에 `wait` 또는 `assert_visible`로 대기. 실패 시 **워크플로 재실행**으로 통과하는 경우 많음. | +| 현상 | 원인 요약 | 대응 | +| ------------------------------ | ----------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| iOS: `Command timed out` (tap) | idb `ui tap`이 25초 안에 응답하지 않음. CI에서 시뮬레이터/idb가 간헐적으로 지연되면 발생. | tap 도구에서 타임아웃 시 1.5초 대기 후 1회 재시도. 그래도 실패하면 시뮬레이터 기기 고정([development-commands](development-commands.md#ci-e2e-시뮬레이터에뮬레이터-기기-지정)) 또는 워크플로 재실행. | +| Android: `has no measure data` | 요소는 찾았지만 measure 보충 실패(타이밍/레이아웃) | 해당 스텝 직전에 `wait` 또는 `assert_visible`로 대기. 실패 시 **워크플로 재실행**으로 통과하는 경우 많음. | 상세 원인 분석은 [issue/ci-e2e-failure-analysis.md](issue/ci-e2e-failure-analysis.md) 참고. diff --git a/docs/e2e-comparison.md b/docs/e2e-comparison.md index 61851c5..df3309a 100644 --- a/docs/e2e-comparison.md +++ b/docs/e2e-comparison.md @@ -105,7 +105,7 @@ Appium은 WebView 컨텍스트 전환을 지원하지만, 설정이 복잡하다 | GPS 모킹 | ✓ `setLocation` | `device.setLocation()` | `setLocation` | `setLocation()` | | 권한 다이얼로그 | ✗ | `permissions` 옵션 | 자동 처리 | caps 설정 | | 앱 상태 초기화 | ✓ `clearState` | `launchApp({delete: true})` | `clearState` | `removeApp()` | -| 비디오 녹화 | ✗ | `artifacts` 설정 | `startRecording` | `startRecording()` | +| 비디오 녹화 | ✓ (start/stop 도구) | `artifacts` 설정 | `startRecording` | `startRecording()` | | 네트워크 모킹 | ✓ `set_network_mock` | URL blacklist | ✗ | ✗ | | 재시도 (retry) | ✓ `retry` | ✗ | 자동 재시도 | 코드 레벨 | diff --git a/docs/plans/2026-02-21-feat-e2e-video-recording-plan.md b/docs/plans/2026-02-21-feat-e2e-video-recording-plan.md new file mode 100644 index 0000000..755da03 --- /dev/null +++ b/docs/plans/2026-02-21-feat-e2e-video-recording-plan.md @@ -0,0 +1,137 @@ +--- +title: E2E 화면 비디오 녹화 기능 추가 +type: feat +status: active +date: 2026-02-21 +--- + +# E2E 화면 비디오 녹화 기능 추가 + +## Enhancement Summary + +**Deepened on:** 2026-02-21 +**Sections enhanced:** Overview, Proposed Solution, Technical Considerations, Acceptance Criteria, Implementation Notes, References +**Research agents used:** architecture-strategist, code-simplicity-reviewer, security-sentinel, web search (Node spawn, idb, adb screenrecord) + +### Key Improvements + +1. **프로세스 저장 위치**: 녹화 프로세스 참조를 **AppSession**에 두고, 서버 종료 시 `AppSession.stop()`에서 일괄 SIGINT/SIGTERM 정리. (모듈 레벨 Map 대신) +2. **v1 범위 단순화**: 서버당 **최대 1개 녹화**만 허용(`activeRecording` 단일 상태). `record_video(duration)`·YAML startRecording/stopRecording은 **v2로 연기**. deviceId/udid/serial → **deviceId?** 하나로 통일. +3. **보안**: `filePath` 및 Android pull 대상 경로를 **허용 베이스 디렉터리**(e2e-artifacts 또는 outputDir) 하위로 제한해 path traversal 방지. +4. **Node spawn 패턴**: 녹화는 `runCommand` 사용 금지(완료 대기 전제). `child_process.spawn` 직접 사용, stop 시 `child.kill('SIGINT')` 후 `close` 대기; 필요 시 일정 시간 후 SIGKILL. +5. **idb**: "Video file is written to disk only upon exit of the idb process" — 정상 종료(SIGINT/SIGTERM) 필수. CLI 서브커맨드는 `idb video`(IDB_REFERENCE) 또는 `idb record-video`(fbidb.io) 버전 차이 있음 → 구현 시 설치된 idb로 확인. + +### New Considerations Discovered + +- Android CI 패턴: 일부 파이프라인은 디바이스에서 `screenrecord ... &` 후 `killall -INT screenrecord`, `adb pull` 사용. 우리는 spawn(adb shell screenrecord ...) 후 spawn에 SIGINT 보내는 방식으로 통일해도 됨. +- 유휴 타임아웃은 v1에서 미구현; "서버 exit 시 정리"만 필수로 두면 됨. + +--- + +## Overview + +idb(iOS)와 adb(Android)는 이미 화면 녹화 명령을 제공한다. 이를 MCP 도구로 래핑하고, 선택적으로 E2E YAML 스텝으로 노출하여 테스트 실행 구간을 비디오로 남길 수 있게 한다. e2e-comparison에서 현재 비디오 녹화는 ✗로 되어 있으며, Detox/Maestro는 각각 artifacts·startRecording을 지원한다. + +## Problem Statement / Motivation + +- **현재**: E2E 실패 시 스크린샷·로그만 수집 가능. 실행 흐름을 영상으로 남기려면 사용자가 idb/adb를 직접 호출해야 함. +- **목표**: MCP 클라이언트 또는 E2E 러너가 "녹화 시작 → 스텝 실행 → 녹화 중지"를 한 번에 제어하고, 결과 mp4를 아티팩트로 저장할 수 있게 한다. +- **가치**: CI 실패 디버깅, 회귀 테스트 증거 보존, 데모 영상 생성. + +## Proposed Solution + +### 1. MCP 도구 (start / stop) + +- **start_video_recording** + - 파라미터: `platform` (ios | android), `filePath` (호스트 절대 경로 또는 스펙으로 정의한 규칙), 선택 `deviceId`/`udid`/`serial`. + - 동작: iOS는 `idb video `, Android는 `adb shell screenrecord` 등. **spawn**으로 자식 프로세스를 띄우고, 프로세스 참조를 세션/플랫폼(또는 deviceId) 스코프로 저장. + - 반환: "Recording started. Use stop_video_recording to stop." 등 안내 텍스트. + +- **stop_video_recording** + - 파라미터: `platform`, (동일 스코프 식별용) `deviceId` 등. + - 동작: 저장된 프로세스에 SIGINT 전송, `close` 대기 후 최종 filePath 반환. Android는 디바이스 경로로 녹화했다면 pull 후 호스트 경로 반환. + - 반환: `{ success: true, filePath: string }` 또는 실패 시 `{ success: false, error: string }`. + +**정책 (구현 전 확정 권장)** + +- **filePath**: iOS는 idb가 호스트 경로. Android는 디바이스 경로 녹화 후 stop 시 `adb pull`로 호스트 경로에 저장해 반환(호출자는 항상 호스트 경로 기대). +- **start 중복**: 같은 플랫폼/deviceId에서 이미 녹화 중이면 **거부** ("already recording"). +- **stop 미호출**: (선택) 유휴 타임아웃 또는 서버 exit 시 자식 프로세스 SIGTERM/SIGINT로 정리. + +### 2. 대안: 고정 길이 record_video (선택) + +- **record_video**(platform, filePath, durationSeconds) + - Android: `adb shell screenrecord --time-limit ` → runCommand로 처리 가능. + - iOS: spawn + 타이머 후 SIGINT. 최대 duration 상한(예: 600초) 문서화. + +### 3. E2E YAML 스텝 (선택) + +- **startRecording**: `{ path?: string }` — path 생략 시 러너 outputDir 기준 기본 파일명. +- **stopRecording**: `{}` — 현재 스코프 녹화 중지. +- setup에서 startRecording, teardown에서 stopRecording 권장. teardown에서 "녹화 중이면 stop" 보장. + +### Research Insights (Proposed Solution) + +- **v1 권장 범위**: 아키텍처·단순성 리뷰 반영 시 **v1은 start/stop MCP 도구만** 구현. record_video(duration)·YAML 스텝은 v2로 연기 시 parser/runner/types/app-client 변경 제거, 테스트·유지보수 부담 감소. +- **프로세스 저장**: "디바이스당 1개" Map 대신 **서버당 1개** `activeRecording: { platform, process, filePath, deviceId? } | null`만 두면 v1 요구사항(AC1·AC2 단일 디바이스) 충족. 다중 디바이스 동시 녹화는 추후 확장 시 Map으로 전환. +- **파라미터**: start/stop 모두 **deviceId?** 하나만 노출. 문서에 "iOS: udid, Android: serial; 생략 시 플랫폼별 기본 1대" 명시. 내부는 idb-utils/adb-utils의 resolveUdid/resolveSerial 재사용. + +## Technical Considerations + +- **장시간 프로세스**: `runCommand`는 완료 대기만 지원. **spawn** 후 참조 보관 → stop 시 SIGINT 새 패턴 필요. `video-recording.ts`에서 `child_process.spawn` 직접 사용. +- **타임아웃 분리**: tap용 timeoutMs로 녹화 프로세스 kill 금지. 녹화 전용 타임아웃만 적용. +- **Android**: 스트림 수신 시 `adb exec-out` 사용(PTY 바이너리 깨짐 방지). +- **아티팩트**: 출력 경로 `e2e-artifacts/` 또는 `e2e-artifacts/video/`. CI upload-artifact에 포함 또는 별도 retention. + +### Research Insights (Technical Considerations) + +- **spawn 사용**: `runCommand`는 "완료 대기" 전제이므로 녹화에 사용하지 않음. `video-recording.ts`에서 `child_process.spawn`만 사용한다고 플랜/주석에 명시해 runCommand timeout과 혼동 방지. +- **Graceful shutdown**: `child.kill('SIGINT')`(또는 `SIGTERM`) 후 `child.on('close', ...)` 대기. 일정 시간 내 종료되지 않으면 `child.kill('SIGKILL')`로 강제 종료(파일 미완성 가능성 문서화). +- **AppSession 정리**: 서버 종료 시 `AppSession.stop()`에서 `activeRecording`이 있으면 해당 프로세스에 SIGTERM/SIGINT 전송. 좀비 녹화 프로세스 방지를 위해 **필수**로 명시. +- **Path 제한(보안)**: `filePath`와 Android pull 대상 호스트 경로는 `path.resolve` 후 **허용 베이스**(e2e-artifacts 또는 runner outputDir) 하위인지 검사. `resolvedPath.startsWith(allowedBase)` 실패 시 거부. Path traversal 방지. + +## Acceptance Criteria + +- [x] **AC1** iOS: idb·booted 시뮬 1대·filePath=호스트 절대경로일 때 start → stop 후 해당 경로에 재생 가능한 mp4 생성. +- [x] **AC2** Android: adb·기기 1대일 때 start → stop 후 호스트 경로에 재생 가능한 mp4 생성(pull 포함). +- [x] **AC3** idb/adb가 PATH에 없으면 start 실패, 메시지에 "idb"/"adb" 및 미설치 안내. +- [x] **AC4** 해당 플랫폼에 디바이스 0대면 start 실패. +- [x] **AC5** 이미 해당 스코프에서 녹화 중일 때 start 재호출 시 실패 ("already recording"). +- [x] **AC6** start 없이 stop 호출 시 "no active recording"으로 안전 반환. +- [x] **AC7** (v2) YAML startRecording/stopRecording 시 test run으로 start → steps → stop 실행, 출력 디렉터리에 mp4 생성. +- [x] **AC8** (v2) 스텝 실패 시 teardown의 stopRecording 실행으로 녹화 프로세스 종료. +- [x] **AC9** (보안) filePath 또는 pull 대상 경로가 허용 베이스(e2e-artifacts/outputDir) 하위가 아니면 start 또는 stop 실패. +- [x] **AC10** (라이프사이클) 서버 종료 시 활성 녹화가 있으면 해당 프로세스에 SIGTERM/SIGINT 전송되어 정리됨. + +## Success Metrics + +- e2e-comparison.md "비디오 녹화" 항목 ✓로 변경 가능. +- CI 실패 시 아티팩트에 비디오 포함 가능. + +## Dependencies & Risks + +- **의존성**: idb(macOS·iOS), adb(Android). 기존 도구와 동일. +- **리스크**: stop 미호출 시 좀비 프로세스. 유휴 타임아웃·서버 exit 시 정리로 완화. + +## Implementation Notes (파일·순서) + +| 작업 | 파일/위치 | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------- | +| MCP 도구 구현 | **신규** `packages/react-native-mcp-server/src/tools/video-recording.ts` — spawn, 프로세스 맵, start/stop 핸들러. | +| 도구 등록 | `packages/react-native-mcp-server/src/tools/index.ts` — import 및 registerAllTools 내 호출. | +| idb/adb | `idb-utils.ts` resolveUdid, `adb-utils.ts` resolveSerial 활용. Android는 디바이스 임시 경로 → stop 시 pull 반환. | +| YAML 스텝 (선택) | `parser.ts` stepSchema → `runner.ts` executeStep → `types.ts` TestStep → `app-client.ts` startRecording/stopRecording. | +| 문서 | `document/docs/en/testing/e2e-yaml-reference.md`(및 ko) Video 스텝. `docs/e2e-comparison.md` 비디오 녹화 ✓. | + +### Research Insights (Implementation Notes) + +- **v1 구현 범위**: video-recording.ts에서 **start_video_recording** / **stop_video_recording**만 구현. `activeRecording`은 AppSession에 단일 슬롯으로 보관. record_video·YAML 스텝은 v2에서 추가. +- **허용 경로**: `filePath` 검증 시 `path.resolve(filePath)` 후 `startsWith(allowedBase)` 사용. allowedBase는 기본값 `process.cwd() + '/e2e-artifacts'` 또는 runner의 outputDir; 설정 가능하게 할지 결정. + +## References + +- `docs/IDB_REFERENCE.md` §2.2·2.3 (idb video), `docs/ADB_REFERENCE.md` §3.2 (screenrecord). +- `packages/react-native-mcp-server/src/tools/take-screenshot.ts` (platform 분기), `run-command.ts` (spawn 미사용 → video-recording에서 직접 spawn). +- Node.js: [Child process](https://nodejs.org/docs/latest/api/child_process.html), [SIGINT to child](https://stackoverflow.com/questions/44788013/node-child-processes-how-to-intercept-signals-like-sigint). +- idb: [Video | idb](https://fbidb.io/docs/video) — 서브커맨드가 `record-video`로 문서화된 버전 있음; 구현 시 로컬 `idb --help`로 확인. +- Android CI: Bitrise 등은 `adb shell screenrecord ... &` 후 `killall -INT screenrecord`, `adb pull` 패턴 사용. 우리는 spawn(adb shell screenrecord) + spawn에 SIGINT로 통일 가능. diff --git a/document/docs/en/testing/e2e-yaml-reference.md b/document/docs/en/testing/e2e-yaml-reference.md index 2919794..1d76a53 100644 --- a/document/docs/en/testing/e2e-yaml-reference.md +++ b/document/docs/en/testing/e2e-yaml-reference.md @@ -30,7 +30,7 @@ teardown?: Step[] # Run on exit (optional) ## Step types -The runner supports **32 step types** across 7 categories. See the [Steps Reference](./steps/overview) for full details on every step. +The runner supports **34 step types** across 8 categories. See the [Steps Reference](./steps/overview) for full details on every step. | Category | Steps | Description | | ----------------------------------------- | ----- | ---------------------------------------------------- | @@ -40,6 +40,7 @@ The runner supports **32 step types** across 7 categories. See the [Steps Refere | [Navigation & Device](./steps/navigation) | 7 | Press button, back, home, deep link, location, reset | | [App Lifecycle](./steps/lifecycle) | 2 | Launch and terminate apps | | [Screenshots](./steps/screenshots) | 2 | Capture and compare screenshots | +| [Video](./steps/screenshots) | 2 | Start and stop screen recording (idb/adb) | | [Utilities](./steps/utilities) | 4 | Copy/paste text, run JS, add media | --- @@ -87,6 +88,13 @@ teardown: - terminate: org.example.app ``` +### Video recording + +- **startRecording**: `{ path?: string }` — Start screen recording (idb on iOS, adb screenrecord on Android). If `path` is omitted, saves to `outputDir/e2e-recording.mp4`. Path must be under the current working directory. +- **stopRecording**: `{}` — Stop the current recording and save the file. Safe to call in teardown even when no recording was started (no-op). + +Use `startRecording` in `setup` and `stopRecording` in `teardown` so the full run is captured; teardown runs even on step failure, so the recording is always stopped. + ## E2E CLI (`@ohah/react-native-mcp-server test`) ### Usage diff --git a/document/docs/ko/testing/e2e-yaml-reference.md b/document/docs/ko/testing/e2e-yaml-reference.md index b7cf702..55478f6 100644 --- a/document/docs/ko/testing/e2e-yaml-reference.md +++ b/document/docs/ko/testing/e2e-yaml-reference.md @@ -30,7 +30,7 @@ teardown?: Step[] # 종료 시 실행 (선택) ## Step 타입 -러너는 7개 카테고리에 걸쳐 **32가지 스텝 타입**을 지원한다. 각 스텝의 상세 내용은 [스텝 레퍼런스](./steps/overview)를 참고한다. +러너는 8개 카테고리에 걸쳐 **34가지 스텝 타입**을 지원한다. 각 스텝의 상세 내용은 [스텝 레퍼런스](./steps/overview)를 참고한다. | 카테고리 | 스텝 수 | 설명 | | ------------------------------------------- | ------- | ---------------------------------------------- | @@ -40,6 +40,7 @@ teardown?: Step[] # 종료 시 실행 (선택) | [내비게이션 & 디바이스](./steps/navigation) | 7 | 버튼, 뒤로, 홈, 딥링크, 위치, 초기화 | | [앱 생명주기](./steps/lifecycle) | 2 | 앱 실행 및 종료 | | [스크린샷](./steps/screenshots) | 2 | 스크린샷 캡처 및 비교 | +| [비디오](./steps/screenshots) | 2 | 화면 녹화 시작·중지 (idb/adb) | | [유틸리티](./steps/utilities) | 4 | 텍스트 복사·붙여넣기, JS 실행, 미디어 추가 | --- @@ -87,6 +88,13 @@ teardown: - terminate: org.example.app ``` +### 비디오 녹화 + +- **startRecording**: `{ path?: string }` — 화면 녹화 시작 (iOS: idb, Android: adb screenrecord). `path`를 생략하면 `outputDir/e2e-recording.mp4`에 저장. 경로는 현재 작업 디렉터리 하위여야 한다. +- **stopRecording**: `{}` — 현재 녹화를 중지하고 파일로 저장. 녹화를 시작하지 않았을 때도 teardown에서 호출해도 안전(무시됨). + +`setup`에서 `startRecording`, `teardown`에서 `stopRecording`을 두면 전체 실행이 녹화되고, 스텝 실패 시에도 teardown이 실행되므로 녹화가 항상 중지된다. + ## E2E CLI (`@ohah/react-native-mcp-server test`) ### 사용법 diff --git a/examples/demo-app/e2e/all-steps.yaml b/examples/demo-app/e2e/all-steps.yaml index f1e1a82..6ff500d 100644 --- a/examples/demo-app/e2e/all-steps.yaml +++ b/examples/demo-app/e2e/all-steps.yaml @@ -74,22 +74,25 @@ steps: - waitForText: { text: '버튼 15 (1)', timeout: 15000 } - tap: { selector: 'Pressable:text("다음")' } - waitForVisible: { selector: 'ScrollView', timeout: 15000 } - # ─── Step 8: ScrollView with ref (타입+텍스트로 버튼 찾기) ────────── + # ─── Step 8: ScrollView with ref (testID로 버튼 20 고정, CI 탭 오인 방지) ─ - swipe: { selector: 'ScrollView', direction: 'up', distance: '30%' } - wait: 1500 - - scrollUntilVisible: - { selector: 'Pressable:text("버튼 20 (0)")', direction: 'down', maxScrolls: 5 } - - tap: { selector: 'Pressable:text("버튼 20 (0)")' } - - waitForText: { text: '버튼 20 (1)', timeout: 15000 } + - scrollUntilVisible: { selector: '#scroll-btn-20', direction: 'down', maxScrolls: 5 } + - wait: 500 + - tap: { selector: '#scroll-btn-20' } + - wait: 300 + - waitForText: { text: '버튼 20 (1)', timeout: 20000, selector: '#scroll-btn-20' } - tap: { selector: 'Pressable:text("다음")' } - waitForVisible: { selector: 'Pressable:text("버튼 0 (0)")', timeout: 15000 } - # ─── Step 9: ScrollView no testID ────────────────────────────────── + # ─── Step 9: ScrollView no testID (텍스트 선택자만 사용, 탭 후 대기로 상태 반영 여유) ─ - swipe: { selector: 'ScrollView', direction: 'up', distance: '40%' } - wait: 1500 - scrollUntilVisible: { selector: 'Pressable:text("버튼 20 (0)")', direction: 'down', maxScrolls: 5 } + - wait: 500 - tap: { selector: 'Pressable:text("버튼 20 (0)")' } - - waitForText: { text: '버튼 20 (1)', timeout: 15000 } + - wait: 300 + - waitForText: { text: '버튼 20 (1)', timeout: 20000, selector: 'Pressable:text("버튼 20 (1)")' } - tap: { selector: 'Pressable:text("다음")' } - waitForVisible: { selector: 'FlatList', timeout: 15000 } # ─── Step 10: FlatList (타입+텍스트:nth로 아이템 찾기) ────────────── diff --git a/packages/react-native-mcp-server/src/__tests__/test/parser-steps.test.ts b/packages/react-native-mcp-server/src/__tests__/test/parser-steps.test.ts index b1023ae..e3ad01a 100644 --- a/packages/react-native-mcp-server/src/__tests__/test/parser-steps.test.ts +++ b/packages/react-native-mcp-server/src/__tests__/test/parser-steps.test.ts @@ -514,6 +514,42 @@ describe('compareScreenshot', () => { }); }); +describe('startRecording / stopRecording (Video)', () => { + it('startRecording path 지정 파싱', () => { + const suite = writeAndParse(" - startRecording: { path: './artifacts/rec.mp4' }"); + expect(suite.steps[0]).toEqual({ startRecording: { path: './artifacts/rec.mp4' } }); + }); + + it('startRecording path 생략 가능', () => { + const suite = writeAndParse(' - startRecording: {}'); + expect(suite.steps[0]).toEqual({ startRecording: {} }); + }); + + it('stopRecording null 파싱', () => { + const suite = writeAndParse(' - stopRecording:'); + expect(suite.steps[0]).toEqual({ stopRecording: null }); + }); + + it('stopRecording 빈 객체 파싱', () => { + const suite = writeAndParse(' - stopRecording: {}'); + expect(suite.steps[0]).toEqual({ stopRecording: {} }); + }); + + it('startRecording → stopRecording 순서 파싱', () => { + const suite = writeAndParse( + [ + " - startRecording: { path: './out.mp4' }", + " - tap: { selector: '#btn' }", + ' - stopRecording:', + ].join('\n') + ); + expect(suite.steps).toHaveLength(3); + expect(suite.steps[0]).toEqual({ startRecording: { path: './out.mp4' } }); + expect('tap' in suite.steps[1]!).toBe(true); + expect(suite.steps[2]).toEqual({ stopRecording: null }); + }); +}); + describe('Phase 2 스텝 혼합', () => { it('흐름 제어 + 기존 스텝이 함께 파싱됨', () => { const suite = writeAndParse( diff --git a/packages/react-native-mcp-server/src/__tests__/video-recording.test.ts b/packages/react-native-mcp-server/src/__tests__/video-recording.test.ts new file mode 100644 index 0000000..629da71 --- /dev/null +++ b/packages/react-native-mcp-server/src/__tests__/video-recording.test.ts @@ -0,0 +1,67 @@ +/** + * start_video_recording / stop_video_recording 도구: path 검증, no active recording 반환 검증 + * (실제 idb/adb spawn은 하지 않음) + */ + +import { describe, expect, it, beforeEach } from 'bun:test'; +import { + registerStartVideoRecording, + registerStopVideoRecording, + stopAllRecordings, +} from '../tools/video-recording.js'; + +type ToolHandler = (args: unknown) => Promise; + +function createMockServer(): { + handlers: Map; + registerTool: (name: string, _def: unknown, handler: (args: unknown) => Promise) => void; +} { + const handlers = new Map(); + return { + handlers, + registerTool(name: string, _def: unknown, handler: ToolHandler) { + handlers.set(name, handler); + }, + }; +} + +describe('video-recording tools', () => { + let startHandler: ToolHandler; + let stopHandler: ToolHandler; + + beforeEach(() => { + stopAllRecordings(); + const server = createMockServer(); + registerStartVideoRecording(server as never); + registerStopVideoRecording(server as never); + startHandler = server.handlers.get('start_video_recording')!; + stopHandler = server.handlers.get('stop_video_recording')!; + }); + + describe('start_video_recording', () => { + it('filePath가 cwd 밖이면 isError 반환', async () => { + const result = (await startHandler({ + platform: 'ios', + filePath: '/tmp/outside-cwd-recording.mp4', + })) as { isError?: boolean; content?: Array<{ type: string; text: string }> }; + expect(result.isError).toBe(true); + expect(result.content).toBeDefined(); + expect(result.content!.length).toBeGreaterThan(0); + expect(result.content![0].type).toBe('text'); + expect(result.content![0].text).toContain('filePath must be under current working directory'); + }); + }); + + describe('stop_video_recording', () => { + it('활성 녹화 없을 때 No active recording 반환', async () => { + const result = (await stopHandler({})) as { + content?: Array<{ type: string; text: string }>; + }; + expect(result.content).toHaveLength(1); + const text = result.content![0].text; + const parsed = JSON.parse(text) as { success: boolean; error?: string }; + expect(parsed.success).toBe(false); + expect(parsed.error).toBe('No active recording.'); + }); + }); +}); 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 50f2fbb..641c57c 100644 --- a/packages/react-native-mcp-server/src/client/app-client.ts +++ b/packages/react-native-mcp-server/src/client/app-client.ts @@ -213,6 +213,42 @@ export class AppClient { return res.content; } + /** Start screen recording. Use stopRecording() to stop and save. Path must be under cwd. */ + async startRecording(opts: { filePath: string } & DeviceOpts): Promise { + const mergedArgs = { ...this.defaultOpts(), filePath: opts.filePath }; + const res = await this.client.callTool({ + name: 'start_video_recording', + arguments: mergedArgs, + }); + if (res.isError) { + const text = (res.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; + throw new McpToolError('start_video_recording', text); + } + return res.content; + } + + /** Stop current screen recording and save to the path given in startRecording. No-op if none active. */ + async stopRecording( + opts?: DeviceOpts + ): Promise<{ success: boolean; filePath?: string; error?: string }> { + const mergedArgs = { ...this.defaultOpts(), ...opts }; + const res = await this.client.callTool({ + name: 'stop_video_recording', + arguments: mergedArgs, + }); + const text = (res.content as Array<{ type: string; text: string }>)[0]?.text ?? ''; + try { + const parsed = JSON.parse(text) as { success: boolean; filePath?: string; error?: string }; + return parsed; + } catch { + if (text.includes('No active recording')) { + return { success: false, error: 'No active recording.' }; + } + if (res.isError) throw new McpToolError('stop_video_recording', text); + return { success: false, error: text }; + } + } + async describeUi( opts?: { mode?: 'all' | 'point'; x?: number; y?: number; nested?: boolean } & DeviceOpts ): Promise { diff --git a/packages/react-native-mcp-server/src/test/parser.ts b/packages/react-native-mcp-server/src/test/parser.ts index f913250..3ece603 100644 --- a/packages/react-native-mcp-server/src/test/parser.ts +++ b/packages/react-native-mcp-server/src/test/parser.ts @@ -141,6 +141,11 @@ const stepSchema: z.ZodType = z.lazy(() => update: z.boolean().optional(), }), }), + // Video recording (v2) + z.object({ + startRecording: z.object({ path: z.string().optional() }), + }), + z.object({ stopRecording: z.null().or(z.object({}).strict()).default(null) }).strict(), ]) ); diff --git a/packages/react-native-mcp-server/src/test/runner.ts b/packages/react-native-mcp-server/src/test/runner.ts index 49f9503..01decd2 100644 --- a/packages/react-native-mcp-server/src/test/runner.ts +++ b/packages/react-native-mcp-server/src/test/runner.ts @@ -253,6 +253,16 @@ async function executeStep( err.diffImagePath = diffPath; throw err; } + } else if ('startRecording' in step) { + const filePath = step.startRecording.path + ? step.startRecording.path.startsWith('/') + ? resolve(step.startRecording.path) + : resolve(ctx.outputDir, step.startRecording.path.replace(/^\.\//, '')) + : resolve(ctx.outputDir, 'e2e-recording.mp4'); + mkdirSync(dirname(filePath), { recursive: true }); + await app.startRecording({ filePath }); + } else if ('stopRecording' in step) { + await app.stopRecording(); } else { throw new Error(`Unknown step type: ${stepKey(step as TestStep)}`); } diff --git a/packages/react-native-mcp-server/src/test/types.ts b/packages/react-native-mcp-server/src/test/types.ts index 4b1aeb1..e0dfcbd 100644 --- a/packages/react-native-mcp-server/src/test/types.ts +++ b/packages/react-native-mcp-server/src/test/types.ts @@ -79,7 +79,9 @@ export type TestStep = threshold?: number; update?: boolean; }; - }; + } + | { startRecording: { path?: string } } + | { stopRecording: null | Record }; export interface StepResult { step: TestStep; diff --git a/packages/react-native-mcp-server/src/tools/index.ts b/packages/react-native-mcp-server/src/tools/index.ts index f2f6173..fee3ce7 100644 --- a/packages/react-native-mcp-server/src/tools/index.ts +++ b/packages/react-native-mcp-server/src/tools/index.ts @@ -40,6 +40,7 @@ import { registerVisualCompare } from './visual-compare.js'; import { registerRenderTracking } from './render-tracking.js'; import { registerRenderOverlay } from './render-overlay.js'; import { registerGetComponentSource } from './get-component-source.js'; +import { registerStartVideoRecording, registerStopVideoRecording } from './video-recording.js'; export function registerAllTools(server: McpServer, appSession: AppSession): void { registerEvaluateScript(server, appSession); @@ -86,4 +87,7 @@ export function registerAllTools(server: McpServer, appSession: AppSession): voi registerRenderTracking(server, appSession); // 렌더 오버레이 (시각적 리렌더 하이라이트) registerRenderOverlay(server, appSession); + // 화면 비디오 녹화 (idb / adb screenrecord) + registerStartVideoRecording(server); + registerStopVideoRecording(server); } diff --git a/packages/react-native-mcp-server/src/tools/tap.ts b/packages/react-native-mcp-server/src/tools/tap.ts index 83efaf4..554d83d 100644 --- a/packages/react-native-mcp-server/src/tools/tap.ts +++ b/packages/react-native-mcp-server/src/tools/tap.ts @@ -76,7 +76,17 @@ export function registerTap(server: McpServer, appSession: AppSession): void { const iy = Math.round(t.y); const cmd = ['ui', 'tap', String(ix), String(iy)]; if (isLongPress) cmd.push('--duration', String(duration / 1000)); - await runIdbCommand(cmd, udid, { timeoutMs: TAP_TIMEOUT_MS }); + try { + await runIdbCommand(cmd, udid, { timeoutMs: TAP_TIMEOUT_MS }); + } catch (tapErr) { + const msg = tapErr instanceof Error ? tapErr.message : String(tapErr); + if (msg.includes('Command timed out')) { + await new Promise((r) => setTimeout(r, 1500)); + await runIdbCommand(cmd, udid, { timeoutMs: TAP_TIMEOUT_MS }); + } else { + throw tapErr; + } + } // Allow UI to update before returning so callers (e.g. assert_text) see the result. await new Promise((r) => setTimeout(r, 300)); return { diff --git a/packages/react-native-mcp-server/src/tools/video-recording.ts b/packages/react-native-mcp-server/src/tools/video-recording.ts new file mode 100644 index 0000000..fcd212f --- /dev/null +++ b/packages/react-native-mcp-server/src/tools/video-recording.ts @@ -0,0 +1,304 @@ +/** + * MCP 도구: start_video_recording / stop_video_recording + * idb(iOS) / adb(Android) 화면 녹화를 spawn으로 띄우고, stop 시 SIGINT 후 파일 반환. + * runCommand는 사용하지 않음(완료 대기 전제). child_process.spawn만 사용. + */ + +import { resolve, sep } from 'node:path'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { runCommand } from './run-command.js'; +import { checkIdbAvailable, resolveUdid, idbNotInstalledError } from './idb-utils.js'; +import { checkAdbAvailable, resolveSerial, adbNotInstalledError } from './adb-utils.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +function isPathUnderBase(filePath: string, allowedBase: string): boolean { + const resolved = resolve(filePath); + const base = resolve(allowedBase); + return resolved === base || resolved.startsWith(base + sep); +} + +/** 서버당 최대 1개. v1에서는 Map 없이 단일 슬롯만 사용. */ +interface ActiveRecording { + platform: 'ios' | 'android'; + process: ChildProcess; + hostPath: string; + devicePath?: string; + serial?: string; +} + +let activeRecording: ActiveRecording | null = null; + +/** 서버 종료 또는 테스트 정리 시 호출. 활성 녹화가 있으면 SIGINT 전송. */ +export function stopAllRecordings(): void { + if (!activeRecording) return; + try { + activeRecording.process.kill('SIGINT'); + } catch { + try { + activeRecording.process.kill('SIGKILL'); + } catch { + /* ignore */ + } + } + activeRecording = null; +} + +// Process exit 시 정리 (appSession.stop()이 호출되지 않는 경우 대비) +if (typeof process !== 'undefined') { + const onExit = () => { + stopAllRecordings(); + }; + process.once('beforeExit', onExit); + process.once('SIGINT', onExit); + process.once('SIGTERM', onExit); +} + +const startSchema = z.object({ + platform: z.enum(['ios', 'android']).describe('ios or android.'), + filePath: z + .string() + .describe('Host path to save the recording (must be under e2e-artifacts or cwd).'), + deviceId: z + .string() + .optional() + .describe('Device ID. iOS: udid, Android: serial. Omit for single device.'), +}); + +const stopSchema = z.object({ + platform: z + .enum(['ios', 'android']) + .optional() + .describe('Platform to stop. Omit when only one recording.'), +}); + +export function registerStartVideoRecording(server: McpServer): void { + ( + server as { + registerTool( + name: string, + def: { description: string; inputSchema: z.ZodTypeAny }, + handler: (args: unknown) => Promise + ): void; + } + ).registerTool( + 'start_video_recording', + { + description: + 'Start screen recording on device/simulator. Save with stop_video_recording. iOS: idb, Android: adb screenrecord.', + inputSchema: startSchema, + }, + async (args: unknown) => { + const { platform, filePath, deviceId } = startSchema.parse(args); + + if (activeRecording) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: 'Already recording. Call stop_video_recording first.', + }, + ], + }; + } + + if (!isPathUnderBase(filePath, process.cwd())) { + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `filePath must be under current working directory. Got: ${filePath}`, + }, + ], + }; + } + + try { + if (platform === 'ios') { + if (!(await checkIdbAvailable())) return idbNotInstalledError(); + const udid = await resolveUdid(deviceId); + // idb video --udid . 일부 버전은 record-video 서브커맨드 사용 가능. + const proc = spawn('idb', ['video', filePath, '--udid', udid], { + stdio: 'ignore', + }); + proc.on('error', () => { + if (activeRecording?.platform === 'ios') activeRecording = null; + }); + proc.unref(); + activeRecording = { platform: 'ios', process: proc, hostPath: resolve(filePath) }; + } else { + if (!(await checkAdbAvailable())) return adbNotInstalledError(); + const serial = await resolveSerial(deviceId); + const devicePath = `/sdcard/Download/rn-mcp-recording-${Date.now()}.mp4`; + const proc = spawn('adb', ['-s', serial, 'shell', 'screenrecord', devicePath], { + stdio: 'ignore', + }); + proc.on('error', () => { + if (activeRecording?.platform === 'android') activeRecording = null; + }); + proc.unref(); + activeRecording = { + platform: 'android', + process: proc, + hostPath: resolve(filePath), + devicePath, + serial, + }; + } + + return { + content: [ + { + type: 'text' as const, + text: 'Recording started. Use stop_video_recording to stop and save.', + }, + ], + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: `start_video_recording failed: ${message}. Ensure ${platform === 'ios' ? 'idb list-targets has a booted simulator' : 'adb devices has a device'}.`, + }, + ], + }; + } + } + ); +} + +export function registerStopVideoRecording(server: McpServer): void { + ( + server as { + registerTool( + name: string, + def: { description: string; inputSchema: z.ZodTypeAny }, + handler: (args: unknown) => Promise + ): void; + } + ).registerTool( + 'stop_video_recording', + { + description: 'Stop the current screen recording and return the saved file path.', + inputSchema: stopSchema, + }, + async (args: unknown) => { + const parsed = stopSchema.safeParse(args); + const platform = parsed.success ? parsed.data.platform : undefined; + + if (!activeRecording) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ success: false, error: 'No active recording.' }), + }, + ], + }; + } + + if (platform != null && activeRecording.platform !== platform) { + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: `Active recording is ${activeRecording.platform}, not ${platform}.`, + }), + }, + ], + }; + } + + const rec = activeRecording; + activeRecording = null; + const { process: proc, hostPath, devicePath, serial, platform: recPlatform } = rec; + + return new Promise<{ content: Array<{ type: 'text'; text: string }> }>((resolveContent) => { + const timeout = setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch { + /* ignore */ + } + resolveContent({ + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: 'Recording process did not exit in time. File may be incomplete.', + filePath: hostPath, + }), + }, + ], + }); + }, 15000); + + proc.on('close', async () => { + clearTimeout(timeout); + let resultPath = hostPath; + if (recPlatform === 'android' && devicePath && serial) { + try { + await runCommand('adb', ['-s', serial, 'pull', devicePath, hostPath], { + timeoutMs: 30000, + }); + } catch (pullErr) { + const msg = pullErr instanceof Error ? pullErr.message : String(pullErr); + resolveContent({ + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: `Pull failed: ${msg}`, + filePath: hostPath, + }), + }, + ], + }); + return; + } + } + resolveContent({ + content: [ + { + type: 'text' as const, + text: JSON.stringify({ success: true, filePath: resultPath }), + }, + ], + }); + }); + + try { + proc.kill('SIGINT'); + } catch { + try { + proc.kill('SIGKILL'); + } catch { + /* ignore */ + } + clearTimeout(timeout); + resolveContent({ + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + success: false, + error: 'Failed to send signal to recording process.', + filePath: hostPath, + }), + }, + ], + }); + } + }); + } + ); +} diff --git a/packages/react-native-mcp-server/src/websocket-server.ts b/packages/react-native-mcp-server/src/websocket-server.ts index 64fe250..3d8baa4 100644 --- a/packages/react-native-mcp-server/src/websocket-server.ts +++ b/packages/react-native-mcp-server/src/websocket-server.ts @@ -7,6 +7,7 @@ import { WebSocketServer, WebSocket } from 'ws'; import { setMetroBaseUrlFromApp } from './tools/metro-cdp.js'; import { getAndroidTopInset } from './tools/adb-utils.js'; +import { stopAllRecordings } from './tools/video-recording.js'; const DEFAULT_PORT = 12300; @@ -484,6 +485,7 @@ export class AppSession { /** 서버 종료 */ stop(): void { + stopAllRecordings(); if (this.staleCheckTimer) { clearInterval(this.staleCheckTimer); this.staleCheckTimer = null;