diff --git a/.maestro/README-chatting.md b/.maestro/README-chatting.md index 5b62775e..019edd88 100644 --- a/.maestro/README-chatting.md +++ b/.maestro/README-chatting.md @@ -1,7 +1,9 @@ -# 채팅 2계정·2기기 실제 CI +# 채팅 2계정·2기기 독립 검증 `.maestro/suites/chatting.json`은 공통 러너에 다음 네 시나리오를 등록합니다. -`ci`는 이 descriptor가 있는 PR에서 채팅 드라이버를 실행합니다. +기본 `ci`는 이 채팅 네 시나리오를 제외하고 나머지 19개를 실행합니다. +이는 두 기기 채팅의 System UI ANR 원인을 해결하거나 채팅 통과를 확인한 변경이 아닙니다. +`--suite chatting` 독립 실행과 명시적 `--suite all`은 기존 두 기기 프로토콜을 그대로 실행합니다. | 리포트 이름 | 실제 완료 조건 | | --- | --- | @@ -41,7 +43,7 @@ ## 기기와 실행 계약 -컨트롤러의 승인된 CI는 공통 러너를 다음 형태로 호출합니다. 이 문서는 실행 결과가 아닙니다. +컨트롤러가 승인된 환경에서 채팅을 독립 실행할 때 공통 러너를 다음 형태로 호출합니다. 기본 `ci`의 호출이 아니며 이 문서는 실행 결과가 아닙니다. ```bash python3 .maestro/scripts/run-tests.py --suite chatting \ @@ -122,8 +124,42 @@ callback을 시작하지 않으며, 기존 닉네임·미리보기·빈 화면 공통 Redactor에는 **파싱한 양쪽 credentials와 방 ID**를 넣습니다. 원본 logcat, bootstrap 요청/세션 파일, 토큰, socket ticket URL은 업로드하지 않습니다. 실패 시 `last-anr-summary.json`은 마지막 시스템 ANR의 프로세스·원인 **분류**와 숫자 CPU/load만 -기록합니다. 원본 dumpsys·PID·창 제목·Intent는 저장하지 않습니다. 마지막 기록은 이전 사건일 수 -있으므로 현재 hierarchy와 함께 판단합니다. 읽기/쓰기 실패는 원래 UI 실패를 대체하지 않습니다. +기록합니다. Android 14의 들여쓴 `Reason:`도 허용하며 process·load·CPU 정보가 없으면 +`unknown`/`null`로 남깁니다. 별도 `anr-events-summary.json`은 events 버퍼의 `am_anr` 태그를 +한 번 조회해 기기 시각 `timestamp_epoch`(Unix 초)와 고정 process/reason 분류를 시각 기준 +최신 8건까지 기록합니다. 모든 이벤트 상태에 `scope: device-history`를 명시하며 현재 플로우의 +사건으로 분류하지 않습니다. `status`는 `captured`(파싱한 이벤트), `partial`(인식 불가 행도 +있는 불완전한 결과), `empty`(빈 버퍼/헤더만), `unrecognized`(인식 불가 내용), +`unavailable`(조회 실패)입니다. `unknown` 분류·빈 결과·파일 부재는 ANR 부재의 증거가 아닙니다. +두 ANR 요약은 원본 로그/dumpsys·사용자 데이터·패키지·PID·사용자 ID·원인 원문·창 제목· +Intent·URL·토큰·예외 본문을 공개하지 않습니다. 읽기·파싱·쓰기 실패는 원래 UI 실패나 +fixture 정리를 대체하지 않습니다. + +A의 별도 checkpoint는 `chatting/diagnostics//`에 `checkpoint.json`과 두 ANR 요약을 +기록합니다. phase는 B 사용 가능 직후·A 설정 전 `peer-ready-before-primary-prepare`, +A prepare·identity 검증 후·B 설정 전 `primary-fixture-ready`, B receiver-ready barrier 후·A +최초 open 직전 `receiver-ready-before-primary-launch`, A가 존재하는 실패·중단의 정리 전 +`failure` 네 고정 값입니다. 각 진단 조회는 5초로 제한합니다. checkpoint는 호스트 캡처 +시작/끝 epoch·monotonic과 얻을 수 있는 기기 wall epoch·uptime을 기록합니다. +checkpoint의 마지막 ANR도 `scope: device-history`와 위 상태로 빈 보고서·일부 정보·조회 불가를 +구분하고 기기 clock은 `captured`/`partial`/`unrecognized`/`unavailable`을 사용합니다. + +`chatting/diagnostics/host-resources.log`는 B 생성/부팅 또는 명시적 peer 처리 전부터 +A 최초 open의 성공/실패 결과까지 호스트를 1초 간격으로 수집하는 JSONL입니다. 앞선 단계의 +실패·중단 시에도 sampler를 fixture 정리 전에 중단합니다. Linux CPU capacity·구간 busy/iowait, +memory/swap·load·지원되는 PSI의 허용 숫자와 source 상태만 보관합니다. 첫 CPU 구간과 +counter reset은 미측정이며 최대 5400 samples·출력 크기 제한, 수집 종료·잘림·coverage gap을 +표시합니다. 프로세스 이름·인수·환경 변수나 원본 `/proc` 텍스트는 보관하지 않습니다. +파일 또는 종료 기록이 없으면 수집 완료나 정상 부하로 해석하지 않습니다. 기존 JSON/log +artifact glob이 이 자료도 업로드하며 별도 workflow 설정은 없습니다. + +마지막 ANR과 이벤트는 과거 기록이며 재부팅으로 지워질 수 있습니다. 이벤트 시각과 실패 +hierarchy·로그 시점을 대조하되 호스트/기기 wall clock의 차이·변경과 캡처 창을 고려합니다. +캡처 시각을 ANR 시작으로 보거나 fixture-ready를 UI 건강 확인으로 보지 않습니다. +호스트 주기 측정은 연속 측정이 아니며 adb 진단 자체도 실행 부하·순서에 영향을 줄 수 있습니다. +깨끗한 checkpoint나 sample 사이 간격만으로 순간 contention 또는 미측정 기기 하위 시스템의 +문제를 배제할 수 없습니다. 최근 이벤트만으로 현재 실패와의 인과관계를 확정하지 않습니다. +이 진단 변경은 ANR 수정이나 근본 원인 확정이 아닙니다. 상위 report는 네 시나리오를 모두 기재합니다. 예를 들어 수신 실패는 `chat-a-to-b-live-receive-failed`이고, 이후 미완료 시나리오는 `chat-scenario-not-completed`로 실패합니다. smoke나 선행 단계 성공을 채팅 전체 성공으로 계산하지 않습니다. @@ -141,6 +177,11 @@ cleanup 실패 시 해당 기기의 private recovery marker를 지우거나 `pm ## 검증 범위 +기본 `ci` 성공은 채팅 네 시나리오의 통과 증거가 아닙니다. 채팅 검증에는 명시적 `chatting` +또는 `all`에서 실제 두 계정·두 기기로 실행한 네 시나리오의 결과가 필요합니다. +채팅 전용 job은 아직 없으며, 후속 추가 시에도 기존 계정 직렬화와 `maestro-dev` 신뢰 경계를 +유지해야 합니다. + upstream `ad0d097b`의 일반 [CI run `39016562465`](https://github.com/projects200/android/actions/runs/39016562465)은 통과했습니다. 그러나 아래 두 실제 Maestro 실행은 실패했습니다. 일반 CI나 준비 단계 성공은 채팅 성공의 증거가 아닙니다. @@ -161,19 +202,21 @@ A의 실제 인증/진입 상태를 확정하지 못합니다. MainActivity는 outgoing 서버 에코 대기(기존 60초)를 복구했습니다. 양방향 incoming·A의 M2 목록·B의 M3 목록·재진입 뒤 기존 M1/새 M3 확인은 유지합니다. -최신 ANR의 원인은 아직 확정할 수 없어 재시도·추가 UI 대기·ANR 팝업 닫기 대신 실패 진단만 -추가했습니다. 기존 logcat 필터는 시스템 ANR를 수집하지 않으므로 실패 시 별도 30초 제한의 -`dumpsys activity lastanr`에서 고정 분류·숫자 부하만 추출합니다. 원문은 공개하거나 파일로 -쓰지 않습니다. 이전 `save_failure_logcat` 운영 오류 격리와 기존 다섯 회귀 테스트는 보존하며, -새 진단도 `RunnerFailure`·`OSError`만 격리하고 `RunInterrupted`는 그대로 전파합니다. -진단 분류·민감 원문 배제·운영 오류·중단 테스트와 새 교환 순서의 정확한 actor/메시지, -각 송수신·목록·이력 실패의 시나리오 전파 테스트를 추가했습니다. +최신 ANR의 원인은 아직 확정할 수 없어 재시도·추가 UI 대기·ANR 팝업 닫기 대신 진단만 +추가했습니다. 기존 실패 경로의 30초 제한 `dumpsys activity lastanr` 요약과 실패 logcat +격리를 보존하고 events 요약·네 checkpoint·호스트 주기 수집을 추가합니다. 원문은 파일로 +쓰지 않습니다. 채팅 드라이버는 진단의 운영 오류를 UI 결과와 격리하며 `RunInterrupted`와 +`KeyboardInterrupt`의 중단 의미와 기존 fixture 복구를 보존합니다. 회귀 테스트는 분류·민감 +원문 배제·미지원/부분 수집·읽기/쓰기 실패·중단과 준비/최초 open 실패의 정리 순서를 다룹니다. +기존 actor/메시지·각 송수신·목록·이력의 acceptance는 바꾸지 않습니다. -**이 변경에 대한 로컬 코드·설치·빌드·테스트·lint/formatter·adb·emulator·Maestro 실행은 -없습니다. 아래 원격 검증이 끝나기 전에는 전체 CI·채팅 성공을 주장하지 않습니다.** +**이번 기본 `ci` 선택 변경에 대한 로컬 코드·설치·빌드·테스트·lint/formatter·adb·emulator·Maestro 실행은 +없습니다. 기본 `ci`와 채팅의 성공은 각각 아래 원격 결과로 확인해야 합니다.** 정적 확인 범위는 실패 hierarchy/로그, bootstrap 인증→앱 진입→방 준비 경로, 전체 Python 교환 순서와 실패 계약, 송신 helper/입력/outgoing 및 기존 incoming·목록·이력 selector입니다. -컨트롤러는 Python 회귀 테스트와 일반 CI를 확인하고, 같은 실제 두 계정·기기로 네 시나리오를 -실행해야 합니다. ANR가 재발하면 같은 단계 hierarchy와 `last-anr-summary.json`의 분류·부하를 -대조해야 합니다. 새 비밀값이나 대체 계정은 필요하다고 확인되지 않았으며, 정상 응답하는 +컨트롤러는 승인된 원격 CI의 Python 회귀 테스트·APK 빌드와 기본 `ci`의 19개 플로우 결과를 +확인해야 합니다. 채팅은 별도로 명시적 `chatting`/`all`에서 실제 두 계정·기기의 네 시나리오를 +확인해야 합니다. ANR가 재발하면 같은 단계 hierarchy, 네 checkpoint와 이벤트 시각, 호스트 +주기 자료를 대조합니다. 명시적 채팅 실행은 기존 ANR로 실패할 수 있습니다. +새 비밀값이나 대체 계정은 필요하다고 확인되지 않았으며, 정상 응답하는 두 기기와 기존 DEV 인증·REST·WebSocket 접근이 실제 검증의 외부 전제입니다. diff --git a/.maestro/README.md b/.maestro/README.md index 54d3d66a..40664309 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -143,7 +143,11 @@ UI가 생성·수정·삭제를 수행합니다. bootstrap은 테스트를 통 ### 기능 descriptor와 공유 계약 -`--suite ci`는 smoke-tabs → 현재 트리의 모든 `.maestro/suites/.json`(파일명 순, 각 flows 선언 순) → login-screen입니다. `all`은 여기에 기존 exercise 회귀 두 개를 포함합니다. **`regression`은 기존 운동 두 개만** 실행하며 전체 기능 회귀의 별칭이 아닙니다. `--suite `는 해당 descriptor만 실행합니다. descriptor가 없으면 그 기능을 실행했다고 주장하지 않으며 잘못된 descriptor/빈 flows/없는 파일/중복 흐름은 실패합니다. 기능 상세는 `.maestro/README-.md`를 참조합니다. +`--suite ci`는 smoke-tabs → `chatting`을 제외한 현재 트리의 `.maestro/suites/.json`(파일명 순, 각 flows 선언 순) → login-screen입니다. 현재 19개를 실행하며 두 기기 채팅 4개만 기본 선택에서 제외합니다. `matching-member-to-chat`과 smoke의 채팅 탭 검증은 유지합니다. + +`all`은 채팅 4개와 기존 exercise 회귀 두 개까지 포함하며 두 기기가 필요합니다. **`regression`은 기존 운동 두 개만** 실행하며 전체 기능 회귀의 별칭이 아닙니다. `--suite `는 해당 descriptor만 실행하므로 `--suite chatting` 독립 실행도 유지합니다. descriptor가 없으면 그 기능을 실행했다고 주장하지 않으며 잘못된 descriptor/빈 flows/없는 파일/중복 흐름은 실패합니다. 기능 상세는 `.maestro/README-.md`를 참조합니다. + +채팅 전용 job은 이번 변경에서 추가하지 않습니다. 기본 `ci` 성공은 채팅 4개 통과의 증거가 아니며, 채팅 제외가 System UI ANR의 원인 해결을 뜻하지 않습니다. descriptor 스키마(모든 필드 필수): @@ -191,9 +195,9 @@ XML ID는 `id` 셀렉터를 사용합니다. Compose의 `feed_content`, `feed_em |트리거|ref/신뢰 조건|suite/결과| |---|---|---| -|`push`|`refs/heads/dev`만 허용|`ci`(smoke + 현재 트리의 실제 기능)| +|`push`|`refs/heads/dev`만 허용|`ci`(현재 19개, 두 기기 채팅 4개 제외)| |`push`|다른 branch/tag ref|트리거 필터에서 제외, 비밀 미제공| -|`pull_request`|base가 `dev`, 같은 저장소 head, 작성자 association `OWNER`/`MEMBER`/`COLLABORATOR`/`CONTRIBUTOR`|`ci`(smoke + 현재 트리의 실제 기능)| +|`pull_request`|base가 `dev`, 같은 저장소 head, 작성자 association `OWNER`/`MEMBER`/`COLLABORATOR`/`CONTRIBUTOR`|`ci`(현재 19개, 두 기기 채팅 4개 제외)| |`pull_request`|base가 `dev`, fork head 또는 비신뢰 작성자|비밀 없는 거절 job 실패, 인증 job 미실행| |`pull_request`|다른 base branch|트리거 필터에서 제외, 비밀 미제공| |`workflow_dispatch`|ref `refs/heads/dev`, 선택한 suite|`regression`, `all`, `ci`| @@ -204,7 +208,7 @@ XML ID는 `id` 셀렉터를 사용합니다. Compose의 `feed_content`, `feed_em - 세 실행 경로 모두 **`maestro-dev` 환경**과 기존 `maestro-dev-dedicated-account` concurrency group을 유지합니다. `deployment: false`로 새 Deployment 기록 생성을 끄되, 환경 Secrets와 ref 제한 정책은 계속 적용됩니다. 기존 Deployment 기록과 Actions 검사·로그는 유지됩니다. - 인증 실행에는 외부 `maestro-dev` 정책에서 `dev`와 각 대상 PR의 정확한 `refs/pull/<번호>/merge` ref를 허용해야 합니다. 워크플로는 이 정책을 생성하거나 넓히지 않습니다. required reviewers 설정도 별도 환경 정책이며 `deployment: false`의 효과가 아닙니다. - 기존 dev CI는 `lintDebug`, `testDebugUnitTest`, `assembleDebug`와 기존 Ktlint/Firebase 배포 검사를 유지합니다. 기존 master CI는 `lintRelease`, `testReleaseUnitTest`와 릴리스 빌드·배포를 유지합니다. 두 일반 CI는 e2e Firebase 파일을 요구하지 않도록 변형별 검증을 분리합니다. -- Maestro job은 계정 없는 공통 Python 동작 테스트를 먼저 실행하고, `.maestro/scripts/features/`가 있으면 그 디렉터리도 별도로 discovery합니다. 이후 제공한 DEV 설정의 `:app:lintE2e`, `:app:testE2eUnitTest`, `:app:assembleE2e`를 실행한 뒤 기능별 인증 입력을 검증합니다. 두 번째 계정 입력이 없어도 소스 빌드 결과는 확인할 수 있지만 실제 UI 실행은 실패로 차단하며 smoke 통과로 대체하지 않습니다. JDK17, 선택된 최대 API Google APIs x86_64/KVM/4096MB, 한국어/서울 시간대, 설치 전 실제 ARM ABI 확인과 순차 실행·finally 정리를 사용합니다. +- Maestro job은 계정 없는 공통 Python 동작 테스트를 먼저 실행하고, `.maestro/scripts/features/`가 있으면 그 디렉터리도 별도로 discovery합니다. 이후 제공한 DEV 설정의 `:app:lintE2e`, `:app:testE2eUnitTest`, `:app:assembleE2e`를 실행한 뒤 기능별 인증 입력을 검증합니다. 소스 빌드 뒤 선택된 descriptor의 필수 입력이 없으면 실제 UI 실행은 실패로 차단하며 smoke 통과로 대체하지 않습니다. JDK17, 선택된 최대 API Google APIs x86_64/KVM/4096MB, 한국어/서울 시간대, 설치 전 실제 ARM ABI 확인과 순차 실행·finally 정리를 사용합니다. - CI 에뮬레이터는 호스트 기본 시간대에 의존하지 않도록 `-timezone Asia/Seoul`로 시작합니다. `--configure-emulator`의 재부팅 후 한국어·서울 시간대 검증은 유지합니다. - SDK 설정은 `packages: 'platform-tools'`를 명시해 액션 기본 목록의 구형 `tools` 설치를 제외합니다. `sdkmanager`는 액션이 별도로 설치하는 `cmdline-tools`를 사용합니다. - KVM 설정은 러너 계정을 기록하고, udev 이벤트 처리가 끝날 때까지 최대 30초 기다린 뒤 `/dev/kvm` 권한을 확인합니다. `Enable KVM` 단계에만 명령 추적을 켜므로 실패한 명령을 로그에서 구분할 수 있으며, 장치가 없거나 읽기·쓰기 권한이 없으면 계속 실패 처리합니다. @@ -233,6 +237,8 @@ XML ID는 `id` 셀렉터를 사용합니다. Compose의 `feed_content`, `feed_em |`MAESTRO_MATCHING_CATALOG_PLACE_NAME`|실제 Kakao 검색 결과에서 유일하게 일치하는 장소명| |`MAESTRO_MATCHING_ROOM_ID`|matching 두 계정의 검증할 기존 전용 방(정리 시 보존)| +기본 `ci`는 채팅 전용 `MAESTRO_PEER_AUTH_STATE_JSON`, `MAESTRO_PEER_FIXTURE_START_DATE`, `MAESTRO_CHAT_ROOM_ID`를 요구하지 않습니다. matching의 `MAESTRO_PEER_MEMBER_ID`, `MAESTRO_MATCHING_ROOM_ID`와 지도 입력은 계속 필수입니다. `all`과 `chatting`은 채팅 입력도 모두 요구합니다. + peer/matching 비밀은 선택된 descriptor가 요구할 때만 필수입니다. workflow는 정확히 이 이름을 authenticated preflight와 emulator 실행 단계에만 전달합니다. 누락 시 **이름만** 표시하고 실패합니다. CI 계정 잠금은 기능별 job으로 나누지 않습니다. GitHub concurrency의 `queue: max`는 최대 100개의 pending job을 대기시키며, 용량을 넘는 job은 취소됩니다. FIFO 순서는 workflow dispatch 순서가 아니라 job이 concurrency 대기에 들어간 순서입니다([공식 문서](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency)). 필수 비밀 누락은 구성 단계에서 이름만 표시하고 실패합니다. 잘못된 세션·등록되지 않은 계정·만료/철회·다른 DEV pool/client·잘못된 member ID는 준비 실패입니다. CI에 개인 OAuth 자격 증명을 입력하거나 테스트마다 브라우저 로그인을 조작하지 않습니다. 원본 인증 파일, APK, google-services/local.properties, raw logcat은 업로드하지 않습니다. @@ -252,7 +258,14 @@ peer/matching 비밀은 선택된 descriptor가 요구할 때만 필수입니다 - `/console.log`, `debug-*.log`, `debug-*.json`: 인증 값/JWT/인증 관련 줄을 제거한 Maestro 출력과 debug 정보. 원본 subprocess 예외/traceback을 출력하지 않습니다. - `/screenshot-*.png`: **Maestro 실행 중** 생성된 PNG만 보관합니다. 준비/내보내기 화면은 캡처하지 않습니다. 원본 텍스트에서 민감 정보가 감지되면 이미지도 보관하지 않습니다. - `/device-log.log`: Maestro 비정상 종료 시 logcat 최근 500줄 중 `[MEASURE]`/`WebSocket`/`Connectivity`/`AndroidRuntime`/`FATAL EXCEPTION`/`OkHttp`가 있는 줄만 선별하고 인증 값/JWT/인증 관련 줄 제거 규칙을 적용한 로그입니다. -- `/last-anr-summary.json`: 이어서 조회한 마지막 ANR의 고정 process/reason 분류와 load average/전체 CPU 수치만 보관합니다. 이전 사건일 수 있으며 원문·PID·창 제목·Intent는 공개하지 않습니다. +- `/last-anr-summary.json`: 이어서 조회한 마지막 ANR의 고정 process/reason 분류와 load average/전체 CPU 수치만 보관합니다. Android 14의 들여쓴 `Reason:`도 허용하며 process·load·CPU 정보가 없으면 `unknown`/`null`로 남깁니다. 마지막 기록은 이전 사건일 수 있습니다. +- `/anr-events-summary.json`: events 버퍼의 `am_anr` 태그를 한 번 조회해 기기 시각 `timestamp_epoch`(Unix 초)와 고정 process/reason 분류를 시각 기준 최신 8건까지 보관합니다. 모든 상태에 `scope: device-history`를 명시하며 현재 플로우의 사건으로 분류하지 않습니다. +- `chatting/diagnostics//{checkpoint,last-anr-summary,anr-events-summary}.json`: A의 `peer-ready-before-primary-prepare`(B 사용 가능 직후·A 설정 전), `primary-fixture-ready`(A prepare·identity 확인 후·B 설정 전), `receiver-ready-before-primary-launch`(B 준비 barrier 후·A 최초 open 직전), `failure`(A가 존재하는 실패·중단 시 정리 전) 기록입니다. 각 조회는 5초로 제한하며 metadata는 고정 phase, 호스트 캡처 시작/끝 epoch·monotonic과 얻을 수 있는 기기 wall epoch·uptime만 기록합니다. +- `chatting/diagnostics/host-resources.log`: B 생성/부팅 또는 명시적 peer 처리 전부터 A 최초 open 결과까지 Linux 호스트를 1초 간격으로 수집하는 JSONL입니다. 이전 단계 실패·중단 시에도 fixture 정리 전에 중단합니다. CPU capacity·구간 busy/iowait, memory/swap, load, 지원되는 PSI의 허용 숫자와 source 상태만 보관하며 첫 CPU 구간·counter reset은 미측정으로 남깁니다. 최대 5400 samples와 출력 크기 제한을 적용하고 수집 종료·잘림·coverage gap을 고정 상태로 기록합니다. 파일/종료 기록 부재는 수집 완료나 정상 부하의 증거가 아닙니다. + +이벤트 요약의 `status`는 `captured`(파싱한 이벤트), `partial`(인식 불가 행도 있어 불완전한 이벤트), `empty`(빈 버퍼/헤더만), `unrecognized`(파싱할 수 없는 내용), `unavailable`(조회 실패)로 구분합니다. checkpoint의 마지막 ANR도 같은 상태 이름으로 빈 보고서·일부 정보·조회 불가를 구분하고 기기 clock은 `captured`/`partial`/`unrecognized`/`unavailable`을 기록합니다. 개별 `unknown` 분류나 빈 결과·파일 부재는 ANR이 없었다는 증거가 아닙니다. 두 ANR 요약은 원문·사용자 데이터·패키지·PID·창 제목·Intent·URL·토큰·예외 본문을 공개하지 않습니다. 읽기·파싱·쓰기 실패는 원래 UI 실패나 fixture 정리를 대체하지 않습니다. + +ANR 자료는 기기 과거 이력이며 재부팅으로 지워질 수 있습니다. 이벤트 시각과 실패 hierarchy/로그 시점을 대조하되 호스트·기기 wall clock의 차이/변경과 수집 창을 고려해야 합니다. 캡처 시각은 ANR 발생 시각이 아니며 fixture-ready는 UI 건강 확인이 아닙니다. 호스트 주기 수집은 연속 측정이 아니고 checkpoint의 adb 조회도 실행 부하·순서를 조금 바꿀 수 있습니다. 깨끗한 snapshot이나 sample 사이 간격만으로 순간 contention 또는 측정하지 않은 기기 하위 시스템의 문제를 배제하지 않습니다. 이 변경은 ANR 수정이나 근본 원인 확정이 아닙니다. Maestro의 HOME/Java user.home은 실행별 임시 디렉터리로 격리하며 analytics, 업데이트 확인, AI 알림을 끕니다. 원본 CLI 임시 출력은 finally에서 제거하고 업로드하지 않습니다. authState는 호스트 메모리/앱 private stdin 경로에만 전달됩니다. 앱은 e2e에서 자격 증명을 포함할 수 있는 Timber 로깅을 사용하지 않습니다. raw logcat과 dumpsys 원문은 파일로 저장하거나 업로드하지 않습니다. @@ -261,6 +274,9 @@ CI는 실패해도 `always()`로 상태 파일과 안전한 XML/log/JSON/PNG만 ## 검증 상태와 남은 외부 준비 사용자 보고 run `39007766770`에서는 고정 keystore 복원과 APK 빌드가 성공했고 smoke·피드·타이머·로그인은 통과했습니다. matching 세 플로우는 모두 `matching_map_ready` 대기에서 실패했으며 SDK callback의 종류·코드는 아직 수집되지 않았습니다. 이번 SDK 진단 변경은 정적 확인만 했고 로컬 빌드·테스트·기기 실행은 수행하지 않았습니다. 고정 서명 이후에도 실패 원인은 미확정입니다. +이번 ANR checkpoint·호스트 주기 진단은 정적으로만 검토했으며 로컬 Python 테스트·빌드·lint·formatter·adb·Maestro는 실행하지 않았습니다. 게시 후 승인된 CI의 unittest·APK 빌드·실제 인증 Maestro 결과를 별도로 확인해야 하며 진단 추가 후에도 기존 ANR로 실패할 수 있습니다. + +이번 기본 `ci` 선택 변경에서는 로컬 코드 실행·빌드·테스트·lint·formatter·기기 검증을 수행하지 않았습니다. 승인된 원격 CI에서 Python 회귀 테스트·APK 빌드와 채팅을 제외한 19개 플로우의 실제 결과를 확인해야 합니다. 기본 `ci` 성공과 명시적 `chatting`/`all`의 채팅 검증 결과는 별개입니다. 저장소 루트에서 사용하는 표준 라이브러리 회귀 테스트 명령: diff --git a/.maestro/scripts/features/chatting.py b/.maestro/scripts/features/chatting.py index e451b75d..6a7172ff 100644 --- a/.maestro/scripts/features/chatting.py +++ b/.maestro/scripts/features/chatting.py @@ -109,12 +109,37 @@ def run_suite(args, core): actors = {} cleanup_requests = [] emulator = None + primary = None + sampler = None keep_peer = False run_id = uuid.uuid4().hex[:16] room_id = None redactor = None stage_started = False + def checkpoint(phase): + if primary is None: + return + try: + core.capture_anr_checkpoint( + primary, args.output / "chatting" / "diagnostics" / phase, phase, + ) + except core.RunInterrupted: + raise + except Exception: + # 진단 부재는 UI 성공 증거가 아니며 원래 흐름과 복구를 바꾸지 않습니다. + pass + + def stop_sampler(): + if sampler is None: + return + try: + sampler.stop() + except core.RunInterrupted: + raise + except Exception: + pass + def stage(name, owner, actor, flow, message, **extra): nonlocal stage_started stage_started = True @@ -159,6 +184,15 @@ def stage(name, owner, actor, flow, message, **extra): raise core.RunnerFailure("chat-requires-e2e-apk") redactor = core.Redactor({"primary": credentials, "peer": peer_credentials, "room": room_id}) primary = core.Device(args.device) + try: + sampler = core.host_resource_sampler( + args.output / "chatting" / "diagnostics" / "host-resources.log", + ) + sampler.start() + except core.RunInterrupted: + raise + except Exception: + pass if args.peer_device: if args.peer_device == primary.serial: raise core.RunnerFailure("chat-requires-two-distinct-devices") @@ -170,6 +204,7 @@ def stage(name, owner, actor, flow, message, **extra): "a": {"device": primary, "credentials": credentials}, "b": {"device": peer, "credentials": peer_credentials}, } + checkpoint("peer-ready-before-primary-prepare") for actor, opposite in (("a", "b"), ("b", "a")): device = actors[actor]["device"] if args.configure_emulator or (actor == "b" and emulator is not None): @@ -200,6 +235,8 @@ def stage(name, owner, actor, flow, message, **extra): "PEER_LABEL_SELECTOR": selector(variables["PEER_NICKNAME"] + "님과의 채팅"), }) actors[actor]["variables"] = variables + if actor == "a": + checkpoint("primary-fixture-ready") message_id = str(int(run_id[:8], 16)) first = "1-" + message_id @@ -209,7 +246,11 @@ def stage(name, owner, actor, flow, message, **extra): # B is live and M1 is absent before A sends. No B launch/bootstrap occurs until receipt. stage("a-to-b-open-receiver", "chatting-receive", "b", "chatting-open", first) stage("a-to-b-receiver-barrier", "chatting-receive", "b", "chatting-ready", first) - stage("a-to-b-open-sender", "chatting-send", "a", "chatting-open", first) + checkpoint("receiver-ready-before-primary-launch") + try: + stage("a-to-b-open-sender", "chatting-send", "a", "chatting-open", first) + finally: + stop_sampler() stage("a-to-b-send", "chatting-send", "a", "chatting-send", first) stage("a-to-b-live-receive", "chatting-receive", "b", "chatting-receive", first) # 두 소켓이 준비된 동안 B가 같은 화면에서 바로 답장하고 A가 실시간으로 받습니다. @@ -252,6 +293,21 @@ def stage(name, owner, actor, flow, message, **extra): finally: handlers = {sig: signal.signal(sig, signal.SIG_IGN) for sig in (signal.SIGINT, signal.SIGTERM)} try: + try: + if primary is not None and len(completed) != len(FLOWS): + checkpoint("failure") + except (core.RunInterrupted, KeyboardInterrupt): + for flow in FLOWS: + if flow not in completed: + errors[flow].append("chat-run-interrupted") + finally: + # 이전 단계에서 실패해도 fixture 정리보다 먼저 수집 스레드를 중단합니다. + try: + stop_sampler() + except (core.RunInterrupted, KeyboardInterrupt): + for flow in FLOWS: + if flow not in completed: + errors[flow].append("chat-run-interrupted") for actor, request in reversed(cleanup_requests): device = actors[actor]["device"] try: diff --git a/.maestro/scripts/features/test_chatting.py b/.maestro/scripts/features/test_chatting.py index e5d9585c..ee153e60 100644 --- a/.maestro/scripts/features/test_chatting.py +++ b/.maestro/scripts/features/test_chatting.py @@ -27,6 +27,11 @@ def setUp(self): self.events = [] self.failure_stage = None self.cleanup_failure = None + self.prepare_failure = None + self.interruption_stage = None + self.diagnostics_unavailable = False + self.sampler_failure = None + self.sampler_active = False self.credentials = { "MAESTRO_": {"authState": {"refresh": "unit-a"}, "memberId": "a", "fixtureStartDate": "2026-01-01"}, "MAESTRO_PEER_": {"authState": {"refresh": "unit-b"}, "memberId": "b", "fixtureStartDate": "2026-01-01"}, @@ -57,6 +62,8 @@ def bootstrap(self, operation, credentials, **request): owner.events.append((operation, self.serial, credentials["memberId"], request)) if operation == "cleanup" and owner.cleanup_failure == self.serial: raise RunnerFailure("unit-cleanup-failed") + if operation == "prepare" and owner.prepare_failure == self.serial: + raise RunnerFailure("bootstrap-prepare-failed-setup") return { "CHAT_ROOM_ID": "200", "PEER_MEMBER_ID": request["parameters"]["MAESTRO_PEER_MEMBER_ID"], @@ -66,14 +73,39 @@ def bootstrap(self, operation, credentials, **request): def run_maestro(device, flow, output, redactor, timeout, fixture_start_date, variables=None): name = output.parent.name self.events.append(("flow", device.serial, flow, name, variables)) + if name == self.interruption_stage: + raise RunInterrupted("run-interrupted") if name == self.failure_stage: raise RunnerFailure("maestro-flow-failed") + def capture_anr_checkpoint(device, destination, phase): + self.events.append(("checkpoint", device.serial, phase)) + if self.diagnostics_unavailable: + raise OSError("synthetic-private-diagnostic-error") + + class HostResourceSampler: + def __init__(self, destination): + self.destination = destination + + def start(self): + owner.events.append(("sampling-start",)) + if owner.sampler_failure == "start": + raise RuntimeError("synthetic-private-thread-error") + owner.sampler_active = True + + def stop(self): + owner.events.append(("sampling-stop",)) + owner.sampler_active = False + if owner.sampler_failure == "stop": + raise OSError("synthetic-private-write-error") + self.core = SimpleNamespace( RunnerFailure=RunnerFailure, RunInterrupted=RunInterrupted, Device=Device, Redactor=lambda values: values, read_credentials=lambda prefix="MAESTRO_": self.credentials[prefix], run_maestro=run_maestro, + capture_anr_checkpoint=capture_anr_checkpoint, + host_resource_sampler=HostResourceSampler, ) self.args = SimpleNamespace( device="device-a", peer_device="device-b", apk="unit-e2e.apk", @@ -119,7 +151,10 @@ def test_each_actor_keeps_its_identity_and_receiver_stays_live(self): self.assertLess(stages.index(sender), stages.index(receiver)) first_flow = next(index for index, event in enumerate(self.events) if event[0] == "flow") last_flow = max(index for index, event in enumerate(self.events) if event[0] == "flow") - self.assertTrue(all(event[0] == "flow" for event in self.events[first_flow:last_flow + 1])) + self.assertFalse(any( + event[0] in ("configure", "install", "prepare", "cleanup", "clear") + for event in self.events[first_flow:last_flow + 1] + )) for actor, member, peer in (("device-a", "a", "b"), ("device-b", "b", "a")): prepared = next(event for event in self.events if event[:2] == ("prepare", actor)) cleaned = next(event for event in self.events if event[:2] == ("cleanup", actor)) @@ -204,6 +239,139 @@ def test_failed_cleanup_preserves_that_actors_private_recovery_state(self): self.assertEqual(self.events.count(("clear", "device-b")), 1) self.assertEqual(self.events.count(("clear", "device-a")), 2) + def test_checkpoints_bound_preparation_and_launch_without_extending_sampler_lifetime(self): + self.args.configure_emulator = True + results = chatting.run_suite(self.args, self.core) + self.assertTrue(all(not errors for _, _, errors in results)) + peer_ready = self.events.index(("checkpoint", "device-a", "peer-ready-before-primary-prepare")) + fixture_ready = self.events.index(("checkpoint", "device-a", "primary-fixture-ready")) + launch_ready = self.events.index(("checkpoint", "device-a", "receiver-ready-before-primary-launch")) + self.assertLess(self.events.index(("sampling-start",)), self.events.index(("device", "device-b"))) + self.assertLess(self.events.index(("device", "device-b")), peer_ready) + self.assertLess(peer_ready, self.events.index(("configure", "device-a"))) + prepare_a = next(index for index, event in enumerate(self.events) if event[:2] == ("prepare", "device-a")) + self.assertLess(prepare_a, fixture_ready) + self.assertLess(fixture_ready, self.events.index(("configure", "device-b"))) + barrier = next(index for index, event in enumerate(self.events) + if event[0] == "flow" and event[3] == "a-to-b-receiver-barrier") + opened = next(index for index, event in enumerate(self.events) + if event[0] == "flow" and event[3] == "a-to-b-open-sender") + sent = next(index for index, event in enumerate(self.events) + if event[0] == "flow" and event[3] == "a-to-b-send") + self.assertLess(barrier, launch_ready) + self.assertLess(launch_ready, opened) + self.assertLess(opened, self.events.index(("sampling-stop",))) + self.assertLess(self.events.index(("sampling-stop",)), sent) + self.assertFalse(self.sampler_active) + self.assertNotIn(("checkpoint", "device-a", "failure"), self.events) + + def test_primary_prepare_failure_captures_before_recovery_and_stops_sampling(self): + self.prepare_failure = "device-a" + self.diagnostics_unavailable = True + results = chatting.run_suite(self.args, self.core) + self.assertTrue(all("bootstrap-prepare-failed-setup" in errors for _, _, errors in results)) + failure = self.events.index(("checkpoint", "device-a", "failure")) + cleanup = next(index for index, event in enumerate(self.events) if event[0] == "cleanup") + self.assertLess(failure, cleanup) + self.assertLess(self.events.index(("sampling-stop",)), cleanup) + self.assertFalse(self.sampler_active) + self.assertNotIn(("checkpoint", "device-a", "primary-fixture-ready"), self.events) + self.assertFalse(any(event[0] == "flow" for event in self.events)) + self.assertEqual(self.events.count(("clear", "device-a")), 2) + self.assertFalse(any(event[:2] == ("prepare", "device-b") for event in self.events)) + + def test_identity_mismatch_does_not_publish_fixture_ready_or_prepare_peer(self): + with mock.patch.object(self.core.Device, "bootstrap", return_value={"CHAT_ROOM_ID": "wrong"}): + results = chatting.run_suite(self.args, self.core) + self.assertTrue(all("chat-fixture-identity-mismatch" in errors for _, _, errors in results)) + self.assertNotIn(("checkpoint", "device-a", "primary-fixture-ready"), self.events) + self.assertIn(("checkpoint", "device-a", "failure"), self.events) + self.assertFalse(any(event[0] == "flow" for event in self.events)) + self.assertFalse(self.sampler_active) + self.assertEqual(self.events.count(("clear", "device-a")), 2) + self.assertNotIn(("locale", "device-b"), self.events) + + def test_automatic_peer_boot_is_observed_before_primary_preparation(self): + self.args.peer_device = None + + def boot(core, primary): + self.assertTrue(self.sampler_active) + self.assertFalse(any(event[0] == "prepare" for event in self.events)) + return SimpleNamespace(serial="device-b", stop=lambda: self.events.append(("peer-stop",))) + + with mock.patch.object(chatting, "PeerEmulator", side_effect=boot): + results = chatting.run_suite(self.args, self.core) + self.assertTrue(all(not errors for _, _, errors in results)) + self.assertFalse(self.sampler_active) + self.assertIn(("peer-stop",), self.events) + + def test_failed_primary_open_keeps_flow_error_when_diagnostics_and_cleanup_fail(self): + self.failure_stage = "a-to-b-open-sender" + self.diagnostics_unavailable = True + self.cleanup_failure = "device-b" + self.sampler_failure = "stop" + results = chatting.run_suite(self.args, self.core) + errors = {name: reasons for name, _, reasons in results} + self.assertIn("chat-a-to-b-open-sender-failed", errors["chatting-send"]) + self.assertTrue(all("chat-b-fixture-cleanup-failed" in reasons for reasons in errors.values())) + self.assertTrue(all("chat-scenario-not-completed" in reasons for reasons in errors.values())) + failure = self.events.index(("checkpoint", "device-a", "failure")) + first_cleanup = next(index for index, event in enumerate(self.events) if event[0] == "cleanup") + self.assertLess(failure, first_cleanup) + self.assertLess(self.events.index(("sampling-stop",)), first_cleanup) + self.assertFalse(self.sampler_active) + self.assertEqual(self.events.count(("clear", "device-b")), 1) + self.assertEqual(self.events.count(("clear", "device-a")), 2) + self.assertNotIn("synthetic-private", str(results)) + self.assertFalse(any(event[0] == "flow" and event[3] == "a-to-b-send" for event in self.events)) + + def test_interruption_stops_sampling_and_keeps_fixture_recovery_when_diagnostics_unavailable(self): + for stage in ("a-to-b-receiver-barrier", "a-to-b-open-sender"): + with self.subTest(stage=stage): + self.events.clear() + self.interruption_stage = stage + self.diagnostics_unavailable = True + results = chatting.run_suite(self.args, self.core) + self.assertTrue(all("chat-run-interrupted" in errors for _, _, errors in results)) + owner = "chatting-send" if stage == "a-to-b-open-sender" else "chatting-receive" + errors = next(errors for flow, _, errors in results if flow == owner) + self.assertIn("chat-" + stage + "-interrupted", errors) + cleanup = next(index for index, event in enumerate(self.events) if event[0] == "cleanup") + self.assertLess(self.events.index(("checkpoint", "device-a", "failure")), cleanup) + self.assertLess(self.events.index(("sampling-stop",)), cleanup) + self.assertFalse(self.sampler_active) + self.assertEqual(len([event for event in self.events if event[0] == "cleanup"]), 2) + + def test_peer_boot_failure_stops_sampling_and_captures_primary_before_return(self): + self.args.peer_device = None + with mock.patch.object(chatting, "PeerEmulator", side_effect=RunnerFailure("chat-peer-emulator-exited")): + results = chatting.run_suite(self.args, self.core) + self.assertTrue(all("chat-peer-emulator-exited" in errors for _, _, errors in results)) + self.assertIn(("checkpoint", "device-a", "failure"), self.events) + self.assertIn(("sampling-start",), self.events) + self.assertIn(("sampling-stop",), self.events) + self.assertFalse(self.sampler_active) + self.assertFalse(any(event[0] in ("prepare", "cleanup", "flow") for event in self.events)) + + def test_sampler_start_failure_does_not_replace_success_or_prevent_cleanup(self): + self.sampler_failure = "start" + results = chatting.run_suite(self.args, self.core) + self.assertTrue(all(not errors for _, _, errors in results)) + self.assertFalse(self.sampler_active) + self.assertEqual(len([event for event in self.events if event[0] == "cleanup"]), 2) + + def test_keyboard_interrupt_propagates_after_failure_checkpoint_and_cleanup(self): + interruption = KeyboardInterrupt() + with mock.patch.object(self.core, "run_maestro", side_effect=interruption): + with self.assertRaises(KeyboardInterrupt) as caught: + chatting.run_suite(self.args, self.core) + self.assertIs(caught.exception, interruption) + cleanup = next(index for index, event in enumerate(self.events) if event[0] == "cleanup") + self.assertLess(self.events.index(("checkpoint", "device-a", "failure")), cleanup) + self.assertLess(self.events.index(("sampling-stop",)), cleanup) + self.assertFalse(self.sampler_active) + self.assertEqual(len([event for event in self.events if event[0] == "cleanup"]), 2) + def test_selectors_quote_regex_names_and_literal_quote_terminators(self): self.assertEqual(chatting.selector("peer.*[]"), "\\Qpeer.*[]\\E") self.assertEqual(chatting.selector("a\\Eb"), "\\Qa\\E\\\\E\\Qb\\E") diff --git a/.maestro/scripts/host-resources.py b/.maestro/scripts/host-resources.py new file mode 100755 index 00000000..6743faca --- /dev/null +++ b/.maestro/scripts/host-resources.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""Collect bounded, numeric host evidence without accessing an Android device.""" + +import json +import math +import os +from pathlib import Path +import stat +import sys +from threading import Event, Thread +import time + + +PROC_ROOT = Path("/proc") +INTERVAL_SECONDS = 1.0 +MAX_SAMPLES = 5400 +MAX_SOURCE_BYTES = 262144 +MAX_RECORD_BYTES = 4096 +MAX_LOG_BYTES = (MAX_SAMPLES + 2) * MAX_RECORD_BYTES +STOP_TIMEOUT_SECONDS = 0.25 +MAX_COUNTER = (1 << 64) - 1 + + +def _counter(text): + if not text.isascii() or not text.isdecimal() or len(text) > 20: + raise ValueError + value = int(text) + if value > MAX_COUNTER: + raise ValueError + return value + + +def _number(text): + if len(text) > 64: + raise ValueError + value = float(text) + if not math.isfinite(value) or value < 0: + raise ValueError + return value + + +def _parse_cpu(text): + counters = None + capacity = 0 + for line in text.splitlines(): + fields = line.split() + if not fields: + continue + if fields[0] == "cpu": + if counters is not None or len(fields) < 9: + raise ValueError + # guest/guest_nice는 user/nice에 이미 포함되므로 다시 더하지 않습니다. + counters = tuple(_counter(value) for value in fields[1:9]) + elif fields[0].startswith("cpu") and fields[0][3:].isascii() and fields[0][3:].isdecimal(): + capacity += 1 + if counters is None: + raise ValueError + return counters, capacity or None + + +def _parse_memory(text): + names = { + "MemTotal:": "total_bytes", "MemAvailable:": "available_bytes", + "MemFree:": "free_bytes", "SwapTotal:": "swap_total_bytes", + "SwapFree:": "swap_free_bytes", + } + values = {} + for line in text.splitlines(): + fields = line.split() + if not fields or fields[0] not in names: + continue + key = names[fields[0]] + if key in values or len(fields) != 3 or fields[2] != "kB": + raise ValueError + values[key] = _counter(fields[1]) * 1024 + if len(values) != len(names): + raise ValueError + return values + + +def _parse_load(text): + fields = text.split() + if len(fields) < 3: + raise ValueError + # 뒤의 runnable/total 및 마지막 PID는 보관하지 않습니다. + return dict(zip(("avg1", "avg5", "avg15"), (_number(value) for value in fields[:3]))) + + +def _parse_pressure(text): + values = {} + names = {"avg10", "avg60", "avg300", "total"} + for line in text.splitlines(): + fields = line.split() + if not fields or fields[0] not in ("some", "full"): + continue + if fields[0] in values: + raise ValueError + metrics = {} + for field in fields[1:]: + name, separator, value = field.partition("=") + if name not in names: + continue + if not separator or name in metrics: + raise ValueError + metrics[name] = _counter(value) if name == "total" else _number(value) + if name != "total" and metrics[name] > 100: + raise ValueError + if metrics.keys() != names: + raise ValueError + metrics["total_us"] = metrics.pop("total") + values[fields[0]] = metrics + if "some" not in values: + raise ValueError + return values + + +def _clock(function): + try: + value = function() + return value if math.isfinite(value) and value >= 0 else None + except (OSError, ValueError, OverflowError): + return None + + +def _difference(current, previous): + if current is None or previous is None or current < previous: + return None + return current - previous + + +class HostResourceSampler: + def __init__(self, destination: Path): + self.destination = destination + self._stop = Event() + self._thread = None + self._started = False + self._stopped = False + self._initialized = False + self._bytes_written = 0 + self._samples = 0 + self._previous_cpu = None + self._origin = None + self._last_sample = None + self._write_status = "write-unavailable" + + def start(self): + if self._started or self._stop.is_set(): + return + self._started = True + try: + self._thread = Thread(target=self._run, daemon=True) + self._thread.start() + except (OSError, RuntimeError): + # 스레드를 만들 수 없으면 파일도 없을 수 있습니다. UI 결과는 유지합니다. + self._stop.set() + + def stop(self): + if self._stopped: + return + self._stopped = True + self._stop.set() + if self._thread is not None: + try: + self._thread.join(timeout=STOP_TIMEOUT_SECONDS) + except (OSError, RuntimeError): + pass + + def _source(self, relative, parser, optional=False): + if not sys.platform.startswith("linux"): + return "unsupported", None + if self._stop.is_set(): + return "unavailable", None + descriptor = None + try: + flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(PROC_ROOT / relative, flags) + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + return "unsupported", None + with open(descriptor, "r", encoding="ascii", closefd=False) as source: + text = source.read(MAX_SOURCE_BYTES + 1) + if len(text) > MAX_SOURCE_BYTES: + return "malformed", None + return "ok", parser(text) + except FileNotFoundError: + return "unsupported" if optional else "unavailable", None + except (UnicodeError, ValueError, OverflowError): + return "malformed", None + except OSError: + return "unavailable", None + finally: + if descriptor is not None: + try: + os.close(descriptor) + except OSError: + pass + + def _sample(self, now): + epoch = _clock(time.time) + elapsed = _difference(now, self._origin) + cpu_status, parsed_cpu = self._source("stat", _parse_cpu) + cpu = { + "status": cpu_status, "logical_cpus": None, + "capacity_status": "unavailable", "interval_status": "unavailable", + "busy_percent": None, "iowait_percent": None, + } + if parsed_cpu is not None: + counters, capacity = parsed_cpu + cpu["logical_cpus"] = capacity + cpu["capacity_status"] = "ok" if capacity is not None else "unavailable" + cpu["interval_status"] = "first-sample" + if self._previous_cpu is not None: + deltas = tuple(current - previous for current, previous in zip(counters, self._previous_cpu)) + if any(delta < 0 for delta in deltas): + cpu["interval_status"] = "counter-reset" + elif sum(deltas) == 0: + cpu["interval_status"] = "no-progress" + else: + total = sum(deltas) + cpu["interval_status"] = "ok" + cpu["busy_percent"] = 100 * (total - deltas[3] - deltas[4]) / total + cpu["iowait_percent"] = 100 * deltas[4] / total + self._previous_cpu = counters + else: + # 관찰하지 못한 구간을 다음 샘플의 정상 CPU 구간으로 합치지 않습니다. + self._previous_cpu = None + sources = {"cpu": cpu} + for name, relative, parser, optional in ( + ("memory", "meminfo", _parse_memory, False), + ("load", "loadavg", _parse_load, False), + ("pressure_cpu", "pressure/cpu", _parse_pressure, True), + ("pressure_memory", "pressure/memory", _parse_pressure, True), + ("pressure_io", "pressure/io", _parse_pressure, True), + ): + status, values = self._source(relative, parser, optional) + sources[name] = {"status": status, **(values or {})} + statuses = {source["status"] for source in sources.values()} + status = "ok" if statuses == {"ok"} else "partial" if "ok" in statuses else "unavailable" + if statuses == {"unsupported"}: + status = "unsupported" + return { + "kind": "sample", "status": status, "index": self._samples + 1, + "host_epoch": epoch, "elapsed_seconds": elapsed, + "clock_status": "ok" if epoch is not None and elapsed is not None else "unavailable", + "sample_gap_seconds": _difference(now, self._last_sample), + **sources, + } + + def _append(self, record, final=False): + descriptor = None + self._write_status = "write-unavailable" + try: + payload = (json.dumps(record, allow_nan=False, separators=(",", ":")) + "\n").encode("ascii") + limit = MAX_LOG_BYTES if final else MAX_LOG_BYTES - MAX_RECORD_BYTES + if len(payload) > MAX_RECORD_BYTES or self._bytes_written + len(payload) > limit: + self._write_status = "output-limit" + return False + flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND + flags |= getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(self.destination, flags, 0o600) + metadata = os.fstat(descriptor) + if not stat.S_ISREG(metadata.st_mode): + return False + if not self._initialized: + os.ftruncate(descriptor, 0) + self._initialized = True + elif metadata.st_size != self._bytes_written: + return False + written = 0 + while written < len(payload): + count = os.write(descriptor, payload[written:]) + if count <= 0: + raise OSError + written += count + self._bytes_written += written + return True + except (OSError, ValueError, TypeError): + if descriptor is not None and self._initialized: + try: + os.ftruncate(descriptor, self._bytes_written) + except OSError: + pass + return False + finally: + if descriptor is not None: + try: + os.close(descriptor) + except OSError: + pass + + def _run(self): + reason = "stopped" + self._origin = _clock(time.monotonic) + try: + self.destination.parent.mkdir(parents=True, exist_ok=True) + if not self._append({ + "kind": "start", "status": "started", "scope": "host-only", + "coverage": "periodic-not-continuous", "interval_seconds": INTERVAL_SECONDS, + "sample_limit": MAX_SAMPLES, + }): + reason = self._write_status + return + while not self._stop.is_set() and self._samples < MAX_SAMPLES: + now = _clock(time.monotonic) + sample = self._sample(now) + if self._stop.is_set(): + break + if not self._append(sample): + reason = self._write_status + break + self._samples += 1 + self._last_sample = now + if self._samples == MAX_SAMPLES: + reason = "sample-limit" + break + duration = _difference(_clock(time.monotonic), now) + delay = INTERVAL_SECONDS if duration is None else max(0, INTERVAL_SECONDS - duration) + if self._stop.wait(delay): + break + except (OSError, RuntimeError, ValueError, OverflowError): + reason = "sampling-unavailable" + finally: + if self._initialized: + now = _clock(time.monotonic) + self._append({ + "kind": "summary", "status": reason, "samples": self._samples, + "coverage": "periodic-not-continuous", + "elapsed_seconds": _difference(now, self._origin), + "unsampled_tail_seconds": _difference(now, self._last_sample), + }, final=True) diff --git a/.maestro/scripts/run-tests.py b/.maestro/scripts/run-tests.py index 599ca8bf..39a23ace 100755 --- a/.maestro/scripts/run-tests.py +++ b/.maestro/scripts/run-tests.py @@ -237,7 +237,11 @@ def select_suites(name): suites = [builtin_suite(("smoke-tabs",))] if name == "all": suites.append(builtin_suite(FLOWS["regression"])) - suites.extend(descriptors.values()) + # 두 기기 채팅의 System UI ANR가 나머지 기본 CI 검증을 막지 않도록 자동 선택에서만 제외한다. + suites.extend( + descriptor for feature, descriptor in descriptors.items() + if name != "ci" or feature != "chatting" + ) suites.append(builtin_suite(("login-screen",))) elif name in FLOWS: suites = [builtin_suite(FLOWS[name])] @@ -916,48 +920,236 @@ def save_failure_logcat(device, destination, redactor): +def diagnostic_number(value): + try: + number = float(value) + except (TypeError, ValueError, OverflowError): + return None + return number if math.isfinite(number) and number >= 0 else None + + +def parse_last_anr(report): + process = re.search(r"^ANR in ([A-Za-z0-9_.:]+)(?:\s|$)", report, re.MULTILINE) + process_name = process.group(1) if process else None + category = { + "com.android.systemui": "system-ui", APP_ID: "application", + }.get(process_name, "other" if process_name else "unknown") + reason = re.search(r"^[ \t]*Reason: ([^\n]*)", report, re.MULTILINE) + reason_category = "unknown" + if reason: + for prefix, name in ( + ("Input dispatching timed out", "input-dispatch"), + ("Broadcast of Intent", "broadcast"), + ("executing service", "service"), + ("ContentProvider not responding", "content-provider"), + ): + if reason.group(1).startswith(prefix): + reason_category = name + break + load = re.search(r"^Load: ([0-9]+(?:\.[0-9]+)?) / ([0-9]+(?:\.[0-9]+)?) / ([0-9]+(?:\.[0-9]+)?)$", + report, re.MULTILINE) + cpu = re.search(r"^\s*([0-9]+(?:\.[0-9]+)?)% TOTAL:", report, re.MULTILINE) + load_average = [diagnostic_number(value) for value in load.groups()] if load else None + if load_average is not None and any(value is None for value in load_average): + load_average = None + return { + "process": category, "reason": reason_category, + "load_average": load_average, + "total_cpu_percent": diagnostic_number(cpu.group(1)) if cpu else None, + } + + def save_failure_anr(device, destination): try: code, output, _ = device.call("shell", "dumpsys", "activity", "lastanr", timeout=30) if code != 0: return - report = output.decode("utf-8", "replace") - process = re.search(r"^ANR in ([A-Za-z0-9_.:]+)(?:\s|$)", report, re.MULTILINE) - process_name = process.group(1) if process else None - category = { - "com.android.systemui": "system-ui", APP_ID: "application", - }.get(process_name, "other" if process_name else "unknown") - reason = re.search(r"^Reason: ([^\n]*)", report, re.MULTILINE) - reason_category = "unknown" - if reason: - for prefix, name in ( - ("Input dispatching timed out", "input-dispatch"), - ("Broadcast of Intent", "broadcast"), - ("executing service", "service"), - ("ContentProvider not responding", "content-provider"), - ): - if reason.group(1).startswith(prefix): - reason_category = name - break - load = re.search(r"^Load: ([0-9]+(?:\.[0-9]+)?) / ([0-9]+(?:\.[0-9]+)?) / ([0-9]+(?:\.[0-9]+)?)$", - report, re.MULTILINE) - cpu = re.search(r"^\s*([0-9]+(?:\.[0-9]+)?)% TOTAL:", report, re.MULTILINE) # 마지막 ANR은 이전 사건일 수 있습니다. 원문·PID·창 제목·Intent는 공개하지 않습니다. - summary = { - "process": category, "reason": reason_category, - "load_average": [float(value) for value in load.groups()] if load else None, - "total_cpu_percent": float(cpu.group(1)) if cpu else None, - } + summary = parse_last_anr(output.decode("utf-8", "replace")) destination.mkdir(parents=True, exist_ok=True) (destination / "last-anr-summary.json").write_text( - json.dumps(summary, sort_keys=True) + "\n", encoding="utf-8", + json.dumps(summary, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8", ) except RunInterrupted: raise + except (RunnerFailure, OSError, ValueError): + return + + +def parse_anr_events(report): + events = [] + unrecognized = False + for line in report.splitlines(): + line = line.strip() + if not line or re.fullmatch(r"--------- (?:beginning of|switch to) events", line): + continue + event = re.fullmatch( + r"([0-9]+(?:\.[0-9]+)?)\s+[0-9]+\s+[0-9]+\s+I\s+am_anr\s*:\s*" + r"\[-?[0-9]+,[0-9]+,([A-Za-z0-9_.:]+),-?[0-9]+,(.*)\]", + line, + ) + if not event: + unrecognized = True + continue + try: + timestamp = float(event.group(1)) + except ValueError: + unrecognized = True + continue + if not math.isfinite(timestamp) or timestamp < 0: + unrecognized = True + continue + process = { + "com.android.systemui": "system-ui", APP_ID: "application", + }.get(event.group(2), "other") + reason = "unknown" + for prefix, category in ( + ("Input dispatching timed out", "input-dispatch"), + ("Broadcast of Intent", "broadcast"), + ("executing service", "service"), + ("ContentProvider not responding", "content-provider"), + ): + if event.group(3).startswith(prefix): + reason = category + break + # 쉼표·괄호가 있는 reason도 원문은 버리고 고정 분류와 시각만 남깁니다. + events.append({"timestamp_epoch": timestamp, "process": process, "reason": reason}) + events.sort(key=lambda item: item["timestamp_epoch"]) + del events[:-8] + if events: + status = "partial" if unrecognized else "captured" + else: + status = "unrecognized" if unrecognized else "empty" + return {"status": status, "events": events} + + +def save_failure_anr_events(device, destination): + summary = {"status": "unavailable", "events": []} + try: + code, output, _ = device.call( + "logcat", "-b", "events", "-d", "-v", "epoch", "-s", "am_anr:I", "*:S", timeout=30, + ) + if code == 0: + summary = parse_anr_events(output.decode("utf-8", "replace")) + except RunInterrupted: + raise except (RunnerFailure, OSError): + pass + except ValueError: + summary = {"status": "unrecognized", "events": []} + # events 버퍼는 기기의 과거 기록이며 현재 플로우에서 발생했다는 증거가 아닙니다. + summary["scope"] = "device-history" + try: + destination.mkdir(parents=True, exist_ok=True) + # 원본 로그·stderr·예외 본문은 저장하지 않고 허용한 분류만 공개합니다. + (destination / "anr-events-summary.json").write_text( + json.dumps(summary, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8", + ) + except OSError: return +def capture_anr_checkpoint(device, destination, phase): + if phase not in ( + "peer-ready-before-primary-prepare", "primary-fixture-ready", + "receiver-ready-before-primary-launch", "failure", + ): + raise ValueError("invalid-anr-checkpoint-phase") + started_epoch = diagnostic_number(time.time()) + started_monotonic = diagnostic_number(time.monotonic()) + + def read(*arguments): + try: + code, output, _ = device.call(*arguments, timeout=5) + return output.decode("utf-8", "replace") if code == 0 else None + except RunInterrupted: + raise + except (RunnerFailure, OSError): + return None + + last_anr = { + "status": "unavailable", "scope": "device-history", + "process": "unknown", "reason": "unknown", + "load_average": None, "total_cpu_percent": None, + } + report = read("shell", "dumpsys", "activity", "lastanr") + if report is not None: + try: + last_anr.update(parse_last_anr(report)) + if not report.strip() or report.strip() == "": + last_anr["status"] = "empty" + elif last_anr["process"] != "unknown" and last_anr["reason"] != "unknown": + last_anr["status"] = "captured" + elif (last_anr["process"] != "unknown" or last_anr["reason"] != "unknown" or + last_anr["load_average"] is not None or last_anr["total_cpu_percent"] is not None): + last_anr["status"] = "partial" + else: + last_anr["status"] = "unrecognized" + except RunInterrupted: + raise + except ValueError: + last_anr["status"] = "unrecognized" + events = {"status": "unavailable", "scope": "device-history", "events": []} + report = read("logcat", "-b", "events", "-d", "-v", "epoch", "-s", "am_anr:I", "*:S") + if report is not None: + try: + events.update(parse_anr_events(report)) + except RunInterrupted: + raise + except ValueError: + events["status"] = "unrecognized" + clock = {"status": "unavailable", "wall_epoch": None, "uptime_seconds": None} + wall = read("shell", "date", "+%s") + uptime = read("shell", "cat", "/proc/uptime") + if wall is not None: + value = wall.strip() + if re.fullmatch(r"[0-9]+(?:\.[0-9]+)?", value): + clock["wall_epoch"] = diagnostic_number(value) + if uptime is not None: + value = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)\s+[0-9]+(?:\.[0-9]+)?\s*", uptime) + if value: + clock["uptime_seconds"] = diagnostic_number(value.group(1)) + if clock["wall_epoch"] is not None and clock["uptime_seconds"] is not None: + clock["status"] = "captured" + elif clock["wall_epoch"] is not None or clock["uptime_seconds"] is not None: + clock["status"] = "partial" + elif wall is not None or uptime is not None: + clock["status"] = "unrecognized" + metadata = { + "phase": phase, "scope": "device-history", + "host_capture_started_epoch": started_epoch, + "host_capture_ended_epoch": diagnostic_number(time.time()), + "host_capture_started_monotonic": started_monotonic, + "host_capture_ended_monotonic": diagnostic_number(time.monotonic()), + "device_clock": clock, + "last_anr_status": last_anr["status"], "anr_events_status": events["status"], + } + # 파일별 실패를 격리하여 다른 요약과 원래 UI 결과·fixture 정리를 보존합니다. + for name, summary in ( + ("last-anr-summary.json", last_anr), ("anr-events-summary.json", events), + ("checkpoint.json", metadata), + ): + try: + destination.mkdir(parents=True, exist_ok=True) + (destination / name).write_text( + json.dumps(summary, sort_keys=True, allow_nan=False) + "\n", encoding="utf-8", + ) + except RunInterrupted: + raise + except (OSError, ValueError): + continue + + +def host_resource_sampler(destination): + # sys.path나 패키지 import에 의존하지 않아 파일 경로로 로드하는 기존 테스트와 호환됩니다. + path = Path(__file__).with_name("host-resources.py") + spec = importlib.util.spec_from_file_location("maestro_host_resources", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module.HostResourceSampler(destination) + + def maestro_result(report): try: root = ET.parse(report).getroot() @@ -1009,6 +1201,7 @@ def run_maestro(device, flow, output, redactor, timeout, fixture_start_date=None if code != 0: save_failure_logcat(device, output / flow, redactor) save_failure_anr(device, output / flow) + save_failure_anr_events(device, output / flow) raise RunnerFailure("maestro-flow-failed") maestro_result(report) finally: diff --git a/.maestro/scripts/test_feature_suites.py b/.maestro/scripts/test_feature_suites.py index d14d6990..84a054c0 100755 --- a/.maestro/scripts/test_feature_suites.py +++ b/.maestro/scripts/test_feature_suites.py @@ -46,6 +46,31 @@ def publish(self, descriptor=None): path = self.root / ".maestro" / "suites" / "profile.json" path.write_text(json.dumps(self.descriptor if descriptor is None else descriptor)) + def publish_matching_and_chatting(self): + self.publish() + descriptors = { + "matching": { + "flows": ["matching-member-to-chat"], "apiLevel": 30, "fixture": True, + "requiredEnvironment": ["MAESTRO_PEER_MEMBER_ID"], "driver": None, + }, + "chatting": { + "flows": ["chatting-send", "chatting-receive", "chatting-list-refresh", "chatting-history"], + "apiLevel": 35, "fixture": True, + "requiredEnvironment": [ + "MAESTRO_PEER_AUTH_STATE_JSON", "MAESTRO_PEER_MEMBER_ID", + "MAESTRO_PEER_FIXTURE_START_DATE", "MAESTRO_CHAT_ROOM_ID", + ], + "driver": "chatting", + }, + } + features = self.root / ".maestro" / "scripts" / "features" + features.mkdir(parents=True) + (features / "chatting.py").write_text("# 선택 검증용 자료이며 기기 코드는 실행하지 않습니다.\n") + for name, descriptor in descriptors.items(): + (self.root / ".maestro" / "suites" / (name + ".json")).write_text(json.dumps(descriptor)) + for flow in descriptor["flows"]: + (self.root / ".maestro" / "flows" / (flow + ".yaml")).write_text("appId: test\n---\n") + def test_empty_installation_keeps_smoke_and_exercise_contracts(self): self.assertEqual(runner.selected_flows(runner.select_suites("ci")), ["smoke-tabs", "login-screen"]) self.assertEqual(runner.selected_flows(runner.select_suites("regression")), @@ -57,6 +82,45 @@ def test_ci_discovers_feature_and_selects_api_with_login_last(self): self.assertEqual(runner.selected_flows(suites), ["smoke-tabs", "profile-edit", "login-screen"]) self.assertEqual(runner.required_api(suites), 33) + def test_only_ci_excludes_chatting_and_keeps_matching_with_login_last(self): + self.publish_matching_and_chatting() + chatting_flows = ["chatting-send", "chatting-receive", "chatting-list-refresh", "chatting-history"] + cases = ( + ("ci", ["smoke-tabs", "matching-member-to-chat", "profile-edit", "login-screen"], 33), + ("all", [ + "smoke-tabs", "exercise-form-entry", "exercise-crud", *chatting_flows, + "matching-member-to-chat", "profile-edit", "login-screen", + ], 35), + ("chatting", chatting_flows, 35), + ) + for name, flows, api_level in cases: + with self.subTest(suite=name): + suites = runner.select_suites(name) + self.assertEqual(runner.selected_flows(suites), flows) + self.assertEqual(runner.required_api(suites), api_level) + + def test_ci_keeps_matching_peer_without_chatting_inputs(self): + self.publish_matching_and_chatting() + environment = {"MAESTRO_PEER_MEMBER_ID": "test-peer"} + with mock.patch.dict(runner.os.environ, environment, clear=True): + self.assertEqual(runner.read_required_environment(runner.select_suites("ci")), environment) + + def test_all_and_chatting_still_require_chatting_inputs(self): + self.publish_matching_and_chatting() + with mock.patch.dict(runner.os.environ, {"MAESTRO_PEER_MEMBER_ID": "test-peer"}, clear=True): + for name in ("all", "chatting"): + with self.subTest(suite=name): + with self.assertRaisesRegex(runner.RunnerFailure, "missing-required-environment"): + runner.read_required_environment(runner.select_suites(name)) + + def test_ci_still_requires_matching_peer(self): + self.publish_matching_and_chatting() + with mock.patch.dict(runner.os.environ, {}, clear=True): + with self.assertRaisesRegex( + runner.RunnerFailure, "missing-required-environment: MAESTRO_PEER_MEMBER_ID", + ): + runner.read_required_environment(runner.select_suites("ci")) + def test_invalid_descriptor_fails_before_device_access(self): for change in ({"flows": []}, {"flows": ["../private"]}, {"apiLevel": True}, {"driver": "other"}, {"requiredEnvironment": ["PATH"]}): diff --git a/.maestro/scripts/test_host_resources.py b/.maestro/scripts/test_host_resources.py new file mode 100644 index 00000000..c2d149d5 --- /dev/null +++ b/.maestro/scripts/test_host_resources.py @@ -0,0 +1,424 @@ +"""Check persisted host diagnostics with synthetic proc data, never adb.""" + +import importlib.util +import json +import math +from pathlib import Path +import tempfile +import threading +from types import SimpleNamespace +import unittest +from unittest import mock + + +SPEC = importlib.util.spec_from_file_location( + "maestro_host_resources", Path(__file__).with_name("host-resources.py"), +) +resources = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(resources) + +CANARY = "synthetic-private-token-command-url-identifier" +CPU_FIRST = "cpu 100 20 30 400 50 10 5 5 900 800\ncpu0 0\ncpu1 0\n" +CPU_SECOND = "cpu 120 20 40 450 60 15 8 7 9900 9800\ncpu0 0\ncpu1 0\n" +MEMORY = "MemTotal: 1000 kB\nMemAvailable: 600 kB\nMemFree: 200 kB\nSwapTotal: 300 kB\nSwapFree: 250 kB\n" +PRESSURE = "some avg10=1.25 avg60=2.5 avg300=3.75 total=400\nfull avg10=0.25 avg60=0.5 avg300=0.75 total=50\n" + + +class Clock: + def __init__(self): + self.now = 0.0 + + def monotonic(self): + return 100.0 + self.now + + def time(self): + return 1700000000.0 + self.now + + +class SampleGate: + """Replace the worker's timed wait with explicit, synchronized sample steps.""" + + def __init__(self): + self.condition = threading.Condition() + self.waits = 0 + self.tokens = 0 + self.stopped = False + + def is_set(self): + with self.condition: + return self.stopped + + def set(self): + with self.condition: + self.stopped = True + self.condition.notify_all() + + def wait(self, timeout): + with self.condition: + self.waits += 1 + self.condition.notify_all() + ready = self.condition.wait_for(lambda: self.stopped or self.tokens, timeout=5) + if not ready: + raise RuntimeError("sample-gate-timeout") + if self.stopped: + return True + self.tokens -= 1 + return False + + def advance(self): + with self.condition: + self.tokens += 1 + self.condition.notify_all() + + def wait_for_samples(self, count): + with self.condition: + return self.condition.wait_for(lambda: self.waits >= count, timeout=5) + + +class HostResourceTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory( + prefix="maestro-host-resources-", dir=Path("/tmp").resolve(), + ) + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.proc = self.root / "proc" + self.proc.mkdir() + (self.proc / "pressure").mkdir() + self.write("stat", CPU_FIRST) + self.write("meminfo", MEMORY) + self.write("loadavg", "0.25 0.5 0.75 2/300 12345\n") + for name in ("cpu", "memory", "io"): + self.write("pressure/" + name, PRESSURE) + self.clock = Clock() + self.gate = SampleGate() + for patch in ( + mock.patch.object(resources, "PROC_ROOT", self.proc), + mock.patch.object(resources, "sys", SimpleNamespace(platform="linux")), + mock.patch.object(resources, "time", self.clock), + mock.patch.object(resources, "Event", return_value=self.gate), + ): + patch.start() + self.addCleanup(patch.stop) + self.destination = self.root / "diagnostics" / "host-resources.log" + self.sampler = resources.HostResourceSampler(self.destination) + self.addCleanup(self.sampler.stop) + + def write(self, name, text): + (self.proc / name).write_text(text, encoding="ascii") + + def records(self): + return [json.loads(line) for line in self.destination.read_text(encoding="ascii").splitlines()] + + def samples(self): + return [record for record in self.records() if record["kind"] == "sample"] + + def start_sample(self): + self.sampler.start() + self.assertTrue(self.gate.wait_for_samples(1), "worker did not persist its first sample") + return self.samples()[0] + + def next_sample(self, count, gap=1.0): + self.clock.now += gap + self.gate.advance() + self.assertTrue(self.gate.wait_for_samples(count), "worker did not persist the next sample") + return self.samples()[-1] + + def join_worker(self): + self.sampler._thread.join(timeout=5) + self.assertFalse(self.sampler._thread.is_alive(), "worker did not terminate") + + def assert_safe_values(self, value): + if isinstance(value, dict): + for item in value.values(): + self.assert_safe_values(item) + elif isinstance(value, list): + for item in value: + self.assert_safe_values(item) + elif isinstance(value, (int, float)): + self.assertTrue(math.isfinite(value)) + self.assertGreaterEqual(value, 0) + elif value is not None: + self.assertIn(value, { + "start", "sample", "summary", "started", "stopped", "host-only", + "periodic-not-continuous", "ok", "partial", "unsupported", "unavailable", + "malformed", "first-sample", "counter-reset", "no-progress", "sample-limit", + "output-limit", "write-unavailable", "sampling-unavailable", + }) + + def test_persisted_cpu_deltas_exclude_duplicate_guest_time(self): + first = self.start_sample() + self.assertEqual(first["cpu"]["logical_cpus"], 2) + self.assertEqual(first["cpu"]["interval_status"], "first-sample") + self.assertIsNone(first["cpu"]["busy_percent"]) + self.assertIsNone(first["cpu"]["iowait_percent"]) + self.write("stat", CPU_SECOND) + second = self.next_sample(2) + self.assertEqual(second["status"], "ok") + self.assertEqual(second["cpu"]["interval_status"], "ok") + self.assertAlmostEqual(second["cpu"]["busy_percent"], 40.0) + self.assertAlmostEqual(second["cpu"]["iowait_percent"], 10.0) + self.assertEqual(second["host_epoch"], 1700000001.0) + self.assertEqual(second["elapsed_seconds"], 1.0) + self.assertEqual(second["sample_gap_seconds"], 1.0) + self.assertEqual(second["clock_status"], "ok") + + def test_memory_load_and_pressure_keep_only_numeric_allowed_fields(self): + self.write("stat", CPU_FIRST + "intr " + CANARY + "\n") + self.write("meminfo", MEMORY + "PrivateIdentifier: " + CANARY + "\n") + self.write("loadavg", "0.25 0.5 0.75 " + CANARY + " " + CANARY + "\n") + self.write("pressure/cpu", PRESSURE.replace("total=400", "total=400 secret=" + CANARY)) + sample = self.start_sample() + self.assertEqual(sample["memory"], { + "status": "ok", "total_bytes": 1024000, "available_bytes": 614400, + "free_bytes": 204800, "swap_total_bytes": 307200, "swap_free_bytes": 256000, + }) + self.assertEqual(sample["load"], {"status": "ok", "avg1": 0.25, "avg5": 0.5, "avg15": 0.75}) + self.assertEqual(sample["pressure_cpu"]["some"], { + "avg10": 1.25, "avg60": 2.5, "avg300": 3.75, "total_us": 400, + }) + self.assertEqual(sample["pressure_io"]["full"]["total_us"], 50) + self.sampler.stop() + self.assertNotIn(CANARY, self.destination.read_text(encoding="ascii")) + self.assert_safe_values(self.records()) + + def test_any_counter_reset_is_unknown_even_when_total_increases(self): + self.start_sample() + self.write("stat", "cpu 1 20 900 450 60 15 8 7 0 0\ncpu0 0\n") + reset = self.next_sample(2) + self.assertEqual(reset["cpu"]["interval_status"], "counter-reset") + self.assertIsNone(reset["cpu"]["busy_percent"]) + self.assertIsNone(reset["cpu"]["iowait_percent"]) + self.write("stat", "cpu 11 20 905 480 65 15 8 7 0 0\ncpu0 0\n") + recovered = self.next_sample(3) + self.assertAlmostEqual(recovered["cpu"]["busy_percent"], 30.0) + self.assertAlmostEqual(recovered["cpu"]["iowait_percent"], 10.0) + + def test_unchanged_counters_do_not_fabricate_idle_load(self): + self.start_sample() + sample = self.next_sample(2) + self.assertEqual(sample["cpu"]["interval_status"], "no-progress") + self.assertIsNone(sample["cpu"]["busy_percent"]) + self.assertIsNone(sample["cpu"]["iowait_percent"]) + + def test_partial_source_failure_preserves_other_metrics_and_breaks_cpu_continuity(self): + self.start_sample() + self.write("stat", "cpu " + CANARY + "\n") + failed = self.next_sample(2, gap=5.0) + self.assertEqual(failed["status"], "partial") + self.assertEqual(failed["cpu"]["status"], "malformed") + self.assertIsNone(failed["cpu"]["busy_percent"]) + self.assertEqual(failed["memory"]["available_bytes"], 614400) + self.assertEqual(failed["sample_gap_seconds"], 5.0) + self.write("stat", CPU_SECOND) + recovered = self.next_sample(3) + self.assertEqual(recovered["cpu"]["interval_status"], "first-sample") + self.assertIsNone(recovered["cpu"]["busy_percent"]) + self.sampler.stop() + self.assertEqual(self.records()[-1]["elapsed_seconds"], 6.0) + self.assertNotIn(CANARY, self.destination.read_text(encoding="ascii")) + + def test_unavailable_unsupported_and_malformed_sources_are_distinct(self): + (self.proc / "stat").unlink() + (self.proc / "pressure/cpu").unlink() + self.write("pressure/memory", "some avg10=" + CANARY + "\n") + sample = self.start_sample() + self.assertEqual(sample["cpu"]["status"], "unavailable") + self.assertIsNone(sample["cpu"]["logical_cpus"]) + self.assertEqual(sample["pressure_cpu"], {"status": "unsupported"}) + self.assertEqual(sample["pressure_memory"], {"status": "malformed"}) + self.assertEqual(sample["status"], "partial") + self.assertEqual(sample["load"]["avg1"], 0.25) + self.assertNotIn(CANARY, self.destination.read_text(encoding="ascii")) + + def test_non_linux_sources_are_unsupported_not_zero_measurements(self): + with mock.patch.object(resources, "sys", SimpleNamespace(platform="darwin")): + sample = self.start_sample() + self.sampler.stop() + self.assertEqual(sample["status"], "unsupported") + self.assertEqual(sample["cpu"]["status"], "unsupported") + self.assertIsNone(sample["cpu"]["busy_percent"]) + for name in ("memory", "load", "pressure_cpu", "pressure_memory", "pressure_io"): + self.assertEqual(sample[name], {"status": "unsupported"}) + + def test_nonfinite_negative_or_oversized_numbers_never_reach_json(self): + self.start_sample() + for count, bad in enumerate(("NaN", "inf", "-1", "1e999", "9" * 100), start=2): + with self.subTest(value=bad): + self.write("loadavg", bad + " 0 0 1/1 1\n") + self.write("pressure/cpu", "some avg10=" + bad + " avg60=0 avg300=0 total=1\n") + self.write("meminfo", MEMORY.replace("1000 kB", bad + " kB")) + sample = self.next_sample(count) + self.assertEqual(sample["load"], {"status": "malformed"}) + self.assertEqual(sample["pressure_cpu"], {"status": "malformed"}) + self.assertEqual(sample["memory"], {"status": "malformed"}) + self.sampler.stop() + self.assert_safe_values(self.records()) + + def test_missing_fields_and_oversized_sources_are_not_success_shaped(self): + self.write("meminfo", "MemTotal: 1000 kB\n") + self.write("loadavg", CANARY * (resources.MAX_SOURCE_BYTES // len(CANARY) + 1)) + self.write("pressure/io", "some avg10=0 avg60=0 total=0\n") + sample = self.start_sample() + self.assertEqual(sample["memory"], {"status": "malformed"}) + self.assertEqual(sample["load"], {"status": "malformed"}) + self.assertEqual(sample["pressure_io"], {"status": "malformed"}) + self.assertNotIn(CANARY, self.destination.read_text(encoding="ascii")) + + def test_pressure_full_is_optional_without_fabricating_zero(self): + self.write("pressure/cpu", "some avg10=0 avg60=0 avg300=0 total=0\n") + sample = self.start_sample() + self.assertEqual(sample["pressure_cpu"]["status"], "ok") + self.assertEqual(sample["pressure_cpu"]["some"]["total_us"], 0) + self.assertNotIn("full", sample["pressure_cpu"]) + + def test_invalid_clocks_are_null_without_nonfinite_json(self): + clock = SimpleNamespace(monotonic=lambda: float("nan"), time=lambda: float("inf")) + with mock.patch.object(resources, "time", clock): + sample = self.start_sample() + self.sampler.stop() + self.assertIsNone(sample["host_epoch"]) + self.assertIsNone(sample["elapsed_seconds"]) + self.assertIsNone(sample["sample_gap_seconds"]) + self.assertEqual(sample["clock_status"], "unavailable") + self.assert_safe_values(self.records()) + + def test_sample_limit_records_truncation_and_closes_worker(self): + with mock.patch.object(resources, "MAX_SAMPLES", 2): + self.start_sample() + self.clock.now = 1.0 + self.gate.advance() + self.join_worker() + records = self.records() + self.assertEqual(len(self.samples()), 2) + self.assertEqual(records[0]["sample_limit"], 2) + self.assertEqual(records[-1]["status"], "sample-limit") + self.assertEqual(records[-1]["samples"], 2) + self.assertEqual(records[-1]["coverage"], "periodic-not-continuous") + self.assertLessEqual(self.destination.stat().st_size, resources.MAX_LOG_BYTES) + self.assertTrue(all(len(line) <= resources.MAX_RECORD_BYTES for line in self.destination.read_bytes().splitlines(keepends=True))) + + def test_byte_limit_reserves_a_terminal_truncation_record(self): + limit = resources.MAX_RECORD_BYTES + 512 + with mock.patch.object(resources, "MAX_LOG_BYTES", limit): + self.sampler.start() + self.join_worker() + records = self.records() + self.assertEqual(self.samples(), []) + self.assertEqual(records[-1]["status"], "output-limit") + self.assertEqual(records[-1]["samples"], 0) + self.assertLessEqual(self.destination.stat().st_size, limit) + + def test_start_and_stop_are_idempotent_and_durable(self): + self.start_sample() + self.sampler.start() + self.sampler.stop() + self.join_worker() + before = self.destination.read_bytes() + self.sampler.stop() + self.sampler.start() + self.assertEqual(self.destination.read_bytes(), before) + self.assertEqual(len(self.samples()), 1) + self.assertEqual(self.records()[-1]["status"], "stopped") + self.assertEqual(self.records()[-1]["samples"], 1) + self.destination.unlink() + self.assertFalse(self.destination.exists()) + + def test_stop_before_start_does_not_create_a_collector(self): + self.sampler.stop() + self.sampler.start() + self.sampler.stop() + self.assertFalse(self.destination.exists()) + + def test_unavailable_threads_do_not_replace_caller_failure(self): + with mock.patch.object(resources, "Thread", side_effect=RuntimeError(CANARY)): + with self.assertRaisesRegex(ValueError, "original-ui-failure"): + try: + self.sampler.start() + raise ValueError("original-ui-failure") + finally: + self.sampler.stop() + self.assertFalse(self.destination.exists()) + + def test_unavailable_output_does_not_replace_caller_failure(self): + self.destination.mkdir(parents=True) + with self.assertRaisesRegex(ValueError, "original-ui-failure"): + try: + self.sampler.start() + self.join_worker() + raise ValueError("original-ui-failure") + finally: + self.sampler.stop() + self.assertEqual(list(self.destination.iterdir()), []) + + def test_caller_interruption_preserves_samples_and_terminates_worker(self): + self.start_sample() + with self.assertRaises(KeyboardInterrupt): + try: + raise KeyboardInterrupt + finally: + self.sampler.stop() + self.join_worker() + self.assertEqual(len(self.samples()), 1) + self.assertEqual(self.records()[-1]["status"], "stopped") + + def test_stop_is_bounded_when_a_source_is_stalled(self): + entered = threading.Event() + release = threading.Event() + stopped = threading.Event() + original = self.sampler._source + + def blocked_source(relative, parser, optional=False): + if relative == "stat": + entered.set() + release.wait(timeout=5) + return original(relative, parser, optional) + + def stop_collector(): + try: + self.sampler.stop() + finally: + stopped.set() + + with mock.patch.object(self.sampler, "_source", blocked_source): + self.sampler.start() + stopper = threading.Thread(target=stop_collector, daemon=True) + try: + self.assertTrue(entered.wait(timeout=5)) + stopper.start() + self.assertTrue(stopped.wait(timeout=2), "stop waited for stalled source IO") + self.assertTrue(self.sampler._thread.daemon) + self.assertEqual(self.samples(), []) + finally: + release.set() + self.join_worker() + if stopper.ident is not None: + stopper.join(timeout=5) + self.assertEqual(self.samples(), []) + self.assertEqual(self.records()[-1]["status"], "stopped") + + def test_partial_write_failure_keeps_previous_complete_records(self): + self.start_sample() + original_write = resources.os.write + failed = False + + def fail_one_write(descriptor, payload): + nonlocal failed + if not failed: + failed = True + original_write(descriptor, payload[:10]) + raise OSError(CANARY) + return original_write(descriptor, payload) + + with mock.patch.object(resources.os, "write", fail_one_write): + self.clock.now = 1.0 + self.gate.advance() + self.join_worker() + self.assertEqual(len(self.samples()), 1) + self.assertEqual(self.records()[-1]["status"], "write-unavailable") + self.assertEqual(self.records()[-1]["samples"], 1) + self.assertNotIn(CANARY, self.destination.read_text(encoding="ascii")) + + +if __name__ == "__main__": + unittest.main() diff --git a/.maestro/scripts/test_run_tests.py b/.maestro/scripts/test_run_tests.py index f7d6b05a..2c5e1326 100755 --- a/.maestro/scripts/test_run_tests.py +++ b/.maestro/scripts/test_run_tests.py @@ -755,7 +755,9 @@ def test_system_ui_anr_summary_keeps_only_categories_and_load(self): " 98.5% TOTAL: 70% user + 28.5% kernel\n" "Subject: " + self.escaped_secret + "\n" ) - self.device.call.side_effect = [(0, b"", b""), (0, report.encode("utf-8"), b"")] + self.device.call.side_effect = [ + (0, b"", b""), (0, report.encode("utf-8"), b""), (0, b"", b""), + ] with self.assertRaises(runner.RunnerFailure) as caught: runner.run_maestro( self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, @@ -775,6 +777,70 @@ def test_system_ui_anr_summary_keeps_only_categories_and_load(self): ): self.assertNotIn(value, content) + def test_android14_indented_anr_reason_does_not_infer_process_from_activity_records(self): + private = "synthetic-private-window-and-intent" + for indentation, reason, category in ( + (" ", "Input dispatching timed out (" + private + ")", "input-dispatch"), + ("\t", "Broadcast of Intent { " + private + " }", "broadcast"), + (" \t", "executing service synthetic.private.service", "service"), + (" ", "ContentProvider not responding: synthetic.private.provider", "content-provider"), + (" ", private, "unknown"), + (" ", "", "unknown"), + (" ", None, "unknown"), + ): + with self.subTest(indentation=indentation, reason=reason): + reason_line = indentation + "Reason: " + reason + "\n" if reason is not None else "" + # Android 14의 들여쓴 Reason만 분류하고 활동·창 기록에서 ANR 프로세스를 추측하지 않습니다. + report = ( + "ACTIVITY MANAGER LAST ANR (dumpsys activity lastanr)\n" + " ANR time: Oct 22, 2026, 1:12:34 PM\n" + + reason_line + + "\nACTIVITY MANAGER STARTER (dumpsys activity starter)\n" + " mLastStartActivityRecord=ActivityRecord{synthetic u0 " + + runner.APP_ID + "/.MainActivity t42}\n" + "\nACTIVITY MANAGER ACTIVITIES (dumpsys activity activities)\n" + " * Hist #0: ActivityRecord{synthetic u0 " + + runner.APP_ID + "/.MainActivity t42}\n" + " intent={act=android.intent.action.MAIN " + private + "}\n" + " mCurrentFocus=Window{synthetic u0 com.android.systemui/StatusBar}\n" + " pid=6175 uid=1000 https://synthetic.invalid/?token=synthetic-token\n" + ' authState={"accessToken":"' + self.escaped_secret + '"}\n' + " memberId=" + CREDENTIALS["memberId"] + "\n" + ) + self.device.call.side_effect = [ + (0, b"", b""), (0, report.encode("utf-8"), b"synthetic-private-command-stderr"), + (0, b"", b""), + ] + published = io.StringIO() + with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): + with self.assertRaises(runner.RunnerFailure) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertEqual(str(caught.exception), "maestro-flow-failed") + self.assertEqual( + json.loads((self.destination / "last-anr-summary.json").read_text(encoding="utf-8")), + { + "process": "unknown", "reason": category, + "load_average": None, "total_cpu_percent": None, + }, + ) + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertEqual(published.getvalue(), "") + for path in self.destination.iterdir(): + content = path.read_text(encoding="utf-8") + for value in ( + private, runner.APP_ID, "com.android.systemui", "ActivityRecord", "Window{", + "ANR time", "Oct 22", "6175", "1000", "Intent", "https://", "synthetic-token", + "authState", "accessToken", CREDENTIALS["memberId"], SYNTHETIC_SECRET, + self.escaped_secret, "synthetic-private-command-stderr", + "synthetic.private.service", "synthetic.private.provider", + ): + self.assertNotIn(value, content) + def test_application_anr_summary_uses_fixed_reason_categories(self): for reason, category in ( ("Input dispatching timed out (synthetic-private-window)", "input-dispatch"), @@ -816,6 +882,7 @@ def test_unavailable_anr_preserves_original_failure_and_console(self): self.device.call.side_effect = [ (0, b"", b""), (1, SYNTHETIC_SECRET.encode("utf-8"), self.escaped_secret.encode("utf-8")), + (0, b"", b""), ] published = io.StringIO() with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): @@ -834,7 +901,7 @@ def test_unavailable_anr_preserves_original_failure_and_console(self): def test_anr_capture_failure_preserves_original_failure_and_console(self): for failure in (runner.RunnerFailure("tool-timeout"), OSError(SYNTHETIC_SECRET)): with self.subTest(failure=type(failure).__name__): - self.device.call.side_effect = [(0, b"", b""), failure] + self.device.call.side_effect = [(0, b"", b""), failure, (0, b"", b"")] published = io.StringIO() with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): with self.assertRaises(runner.RunnerFailure) as caught: @@ -853,6 +920,7 @@ def test_anr_write_failure_preserves_original_failure_without_private_error(self self.device.call.side_effect = [ (0, b"", b""), (0, b"ANR in com.project200.undabang\nReason: Input dispatching timed out\n", b""), + (0, b"", b""), ] write_text = Path.write_text @@ -890,6 +958,407 @@ def test_anr_interruption_propagates_unchanged(self): ) self.assertFalse((self.destination / "last-anr-summary.json").exists()) + def test_system_ui_anr_events_summary_keeps_only_timestamp_and_categories(self): + private = "synthetic-private-window-and-intent" + report = ( + "--------- beginning of events\n" + "1700000100.250 1000 1001 I am_anr : [0,6175,com.android.systemui,0," + "Broadcast of Intent { " + private + ", extras=[one,two] } " + "https://synthetic.invalid/?key=synthetic-key&token=synthetic-token " + 'authState={"accessToken":"' + self.escaped_secret + '"}]\n' + ) + self.device.call.side_effect = [ + (0, b"", b""), (0, b"\n", b""), + (0, report.encode("utf-8"), b"synthetic-private-command-stderr"), + ] + published = io.StringIO() + with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): + with self.assertRaises(runner.RunnerFailure) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertEqual(str(caught.exception), "maestro-flow-failed") + self.assertEqual( + json.loads((self.destination / "anr-events-summary.json").read_text(encoding="utf-8")), + { + "scope": "device-history", "status": "captured", + "events": [ + {"timestamp_epoch": 1700000100.25, "process": "system-ui", "reason": "broadcast"}, + ], + }, + ) + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertEqual(published.getvalue(), "") + for path in self.destination.iterdir(): + content = path.read_text(encoding="utf-8") + for value in ( + private, "com.android.systemui", "6175", "1000", "1001", "Intent", + "https://", "synthetic-key", "synthetic-token", "authState", "accessToken", + SYNTHETIC_SECRET, self.escaped_secret, "synthetic-private-command-stderr", + ): + self.assertNotIn(value, content) + + def test_anr_event_process_and_reason_classification_boundaries(self): + for process, reason, process_category, reason_category in ( + ("com.android.systemui", "Input dispatching timed out (private)", "system-ui", "input-dispatch"), + (runner.APP_ID, "Broadcast of Intent { private, [extras] }", "application", "broadcast"), + (runner.APP_ID, "executing service synthetic.private.service", "application", "service"), + (runner.APP_ID, "ContentProvider not responding: private", "application", "content-provider"), + ("com.android.systemui:private", "private reason", "other", "unknown"), + ("com.android.systemui.private", "private reason", "other", "unknown"), + (runner.APP_ID + ":private", "private reason", "other", "unknown"), + (runner.APP_ID + ".private", "private reason", "other", "unknown"), + ("synthetic.private.process", "prefix Input dispatching timed out", "other", "unknown"), + (runner.APP_ID, "input dispatching timed out", "application", "unknown"), + ): + with self.subTest(process=process, reason_category=reason_category): + self.device.call.return_value = ( + 0, + ("1700000100.250 1000 1001 I am_anr: [0,6175," + process + ",0," + reason + "]\n") + .encode("utf-8"), + b"", + ) + runner.save_failure_anr_events(self.device, self.destination) + self.assertEqual( + json.loads((self.destination / "anr-events-summary.json").read_text(encoding="utf-8")), + { + "scope": "device-history", "status": "captured", + "events": [{ + "timestamp_epoch": 1700000100.25, + "process": process_category, "reason": reason_category, + }], + }, + ) + + def test_anr_events_distinguish_empty_unrecognized_and_partial_capture(self): + valid = ( + b"1700000100.250 1000 1001 I am_anr: " + b"[0,6175,com.android.systemui,0,synthetic-private-reason]\n" + ) + for report, status, events in ( + (b"", "empty", []), + (b"\n--------- beginning of events\n--------- switch to events\n", "empty", []), + ( + valid.replace(b"[0,6175,", b"[-1,6175,").replace(b",0,", b",-2147483648,"), + "captured", + [{"timestamp_epoch": 1700000100.25, "process": "system-ui", "reason": "unknown"}], + ), + (valid, "captured", [ + {"timestamp_epoch": 1700000100.25, "process": "system-ui", "reason": "unknown"}, + ]), + (b"synthetic-private-unrecognized-output\n", "unrecognized", []), + (valid.replace(b"am_anr:", b"other_tag:"), "unrecognized", []), + (valid.replace(b"com.android.systemui", b"invalid process"), "unrecognized", []), + (valid.replace(b"[0,6175,", b"[0,"), "unrecognized", []), + ( + b"--------- beginning of events\n" + valid + + b"synthetic-private-malformed-row\xff\n", + "partial", + [{"timestamp_epoch": 1700000100.25, "process": "system-ui", "reason": "unknown"}], + ), + ): + with self.subTest(status=status, report=report): + self.device.call.return_value = (0, report, b"") + runner.save_failure_anr_events(self.device, self.destination) + self.assertEqual( + json.loads((self.destination / "anr-events-summary.json").read_text(encoding="utf-8")), + {"scope": "device-history", "status": status, "events": events}, + ) + + def test_anr_events_retain_only_latest_eight_finite_nonnegative_timestamps(self): + timestamps = ["0", "12.5", "2", "11", "1", "10", "3", "9", "4", "8", "5", "7", "6"] + invalid = ["-1", "nan", "NaN", "inf", "Infinity", "1e3", "9" * 400] + report = "\n".join( + timestamp + " 1000 1001 I am_anr: [0,6175,com.android.systemui,0,private]" + for timestamp in timestamps + ) + self.device.call.return_value = (0, report.encode("utf-8"), b"") + runner.save_failure_anr_events(self.device, self.destination) + summary = json.loads((self.destination / "anr-events-summary.json").read_text(encoding="utf-8")) + self.assertEqual(set(summary), {"scope", "status", "events"}) + self.assertEqual(summary["scope"], "device-history") + self.assertEqual(summary["status"], "captured") + self.assertEqual( + [event["timestamp_epoch"] for event in summary["events"]], + [5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.5], + ) + for timestamp in invalid: + with self.subTest(timestamp=timestamp): + self.device.call.return_value = ( + 0, + (report + "\n" + timestamp + + " 1000 1001 I am_anr: [0,6175,com.android.systemui,0,private]\n").encode("utf-8"), + b"", + ) + runner.save_failure_anr_events(self.device, self.destination) + mixed = json.loads((self.destination / "anr-events-summary.json").read_text(encoding="utf-8")) + self.assertEqual(mixed, {**summary, "status": "partial"}) + self.device.call.return_value = ( + 0, b"0.000 1000 1001 I am_anr: [0,6175,com.android.systemui,0,private]\n", b"", + ) + runner.save_failure_anr_events(self.device, self.destination) + self.assertEqual( + json.loads((self.destination / "anr-events-summary.json").read_text(encoding="utf-8")), + { + "scope": "device-history", "status": "captured", + "events": [{"timestamp_epoch": 0.0, "process": "system-ui", "reason": "unknown"}], + }, + ) + + def test_anr_events_unavailable_or_unreadable_preserves_original_failure_and_console(self): + for failure in ( + (1, SYNTHETIC_SECRET.encode("utf-8"), self.escaped_secret.encode("utf-8")), + runner.RunnerFailure("tool-timeout"), + OSError(SYNTHETIC_SECRET), + ): + with self.subTest(failure=type(failure).__name__): + self.device.call.side_effect = [(0, b"", b""), (0, b"", b""), failure] + published = io.StringIO() + with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): + with self.assertRaises(runner.RunnerFailure) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertEqual(str(caught.exception), "maestro-flow-failed") + self.assertEqual( + json.loads((self.destination / "anr-events-summary.json").read_text(encoding="utf-8")), + {"scope": "device-history", "status": "unavailable", "events": []}, + ) + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertEqual(published.getvalue(), "") + + def test_anr_events_parse_failure_preserves_original_failure_and_console(self): + self.device.call.side_effect = [(0, b"", b""), (0, b"", b""), (0, b"", b"")] + published = io.StringIO() + with mock.patch.object(runner, "parse_anr_events", side_effect=ValueError(SYNTHETIC_SECRET)): + with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): + with self.assertRaises(runner.RunnerFailure) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertEqual(str(caught.exception), "maestro-flow-failed") + self.assertEqual( + json.loads((self.destination / "anr-events-summary.json").read_text(encoding="utf-8")), + {"scope": "device-history", "status": "unrecognized", "events": []}, + ) + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertEqual(published.getvalue(), "") + + def test_anr_events_write_failure_preserves_ui_failure_console_and_fixture_cleanup(self): + self.device.call.side_effect = [ + (0, b"", b""), (0, b"", b""), + (0, b"1700000100.250 1000 1001 I am_anr: [0,6175,com.android.systemui,0,private]\n", b""), + ] + self.device.clear = mock.Mock() + self.device.bootstrap = mock.Mock(return_value={}) + write_text = Path.write_text + + def fail_events_write(path, *args, **kwargs): + if path == self.destination / "anr-events-summary.json": + raise OSError(SYNTHETIC_SECRET) + return write_text(path, *args, **kwargs) + + published = io.StringIO() + with mock.patch.object(Path, "write_text", new=fail_events_write): + with contextlib.redirect_stdout(published), contextlib.redirect_stderr(published): + result, safe_to_continue = runner.run_ordinary_flow( + SimpleNamespace(flow_timeout=60), self.device, + {"name": "chatting", "fixture": True}, self.flow, self.output, + runner.Redactor(CREDENTIALS), CREDENTIALS, {}, None, + ) + self.assertEqual(result[0], self.flow) + self.assertEqual(result[2], ["maestro-flow-failed"]) + self.assertTrue(safe_to_continue) + operations = [call.args[0] for call in self.device.bootstrap.call_args_list] + self.assertEqual(operations, ["prepare", "cleanup"]) + self.assertEqual(self.device.clear.call_count, 2) + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertFalse((self.destination / "anr-events-summary.json").exists()) + self.assertEqual(published.getvalue(), "") + + def test_anr_events_interruption_propagates_unchanged_and_keeps_sanitized_console(self): + write_text = Path.write_text + for stage in ("read", "parse", "write"): + with self.subTest(stage=stage): + interruption = runner.RunInterrupted("run-interrupted") + self.device.call.side_effect = [ + (0, b"", b""), (0, b"", b""), + interruption if stage == "read" else (0, b"", b""), + ] + + def interrupt_events_write(path, *args, **kwargs): + if path == self.destination / "anr-events-summary.json": + raise interruption + return write_text(path, *args, **kwargs) + + with contextlib.ExitStack() as patches: + if stage == "parse": + patches.enter_context(mock.patch.object( + runner, "parse_anr_events", side_effect=interruption, + )) + elif stage == "write": + patches.enter_context(mock.patch.object( + Path, "write_text", new=interrupt_events_write, + )) + with self.assertRaises(runner.RunInterrupted) as caught: + runner.run_maestro( + self.device, self.flow, self.output, runner.Redactor(CREDENTIALS), timeout=60, + ) + self.assertIs(caught.exception, interruption) + self.assertEqual( + (self.destination / "console.log").read_text(encoding="utf-8"), + "Maestro UI assertion failed: [REDACTED]", + ) + self.assertFalse((self.destination / "anr-events-summary.json").exists()) + + +class AnrCheckpointTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="maestro-checkpoint-test-") + self.addCleanup(temporary.cleanup) + self.destination = Path(temporary.name) / "failure" + self.device = SimpleNamespace(call=mock.Mock(side_effect=[ + (0, b"\n", b""), + (0, b"--------- beginning of events\n", b""), + (0, b"1700000123\n", b""), (0, b"91.25 200.50\n", b""), + ])) + + def capture(self): + runner.capture_anr_checkpoint(self.device, self.destination, "failure") + return json.loads((self.destination / "checkpoint.json").read_text(encoding="utf-8")) + + def test_checkpoint_separates_host_capture_window_device_clocks_and_historical_events(self): + private = "synthetic-private-checkpoint-window" + self.device.call.side_effect = [ + (0, ("ANR in com.android.systemui\n Reason: Broadcast of Intent { " + private + " }\n" + "Load: 14.67 / 12.04 / 6.64\n98.5% TOTAL: private\n").encode(), b"private-stderr"), + (0, ("1700000100.25 6175 6176 I am_anr: [0,6175,com.android.systemui,0," + "Input dispatching timed out (" + private + ")]\n").encode(), b"private-stderr"), + (0, b"1700000123\n", b""), (0, b"91.25 200.50\n", b""), + ] + with mock.patch.object(runner.time, "time", side_effect=[1700000200.0, 1700000201.0]): + with mock.patch.object(runner.time, "monotonic", side_effect=[100.0, 101.0]): + metadata = self.capture() + self.assertEqual(metadata, { + "phase": "failure", "scope": "device-history", + "host_capture_started_epoch": 1700000200.0, "host_capture_ended_epoch": 1700000201.0, + "host_capture_started_monotonic": 100.0, "host_capture_ended_monotonic": 101.0, + "device_clock": {"status": "captured", "wall_epoch": 1700000123.0, "uptime_seconds": 91.25}, + "last_anr_status": "captured", "anr_events_status": "captured", + }) + last_anr = json.loads((self.destination / "last-anr-summary.json").read_text()) + self.assertEqual(last_anr, { + "status": "captured", "scope": "device-history", + "process": "system-ui", "reason": "broadcast", + "load_average": [14.67, 12.04, 6.64], "total_cpu_percent": 98.5, + }) + events = json.loads((self.destination / "anr-events-summary.json").read_text()) + self.assertEqual(events, { + "status": "captured", "scope": "device-history", + "events": [{"timestamp_epoch": 1700000100.25, "process": "system-ui", "reason": "input-dispatch"}], + }) + for path in self.destination.iterdir(): + content = path.read_text() + for secret in (private, "private-stderr", "com.android.systemui", "6175", "Intent", "timed out"): + self.assertNotIn(secret, content) + + def test_missing_malformed_and_partial_sources_never_become_clean_anr_evidence(self): + for last, events, wall, uptime, expected in ( + (b"", b"", b"1700000123", b"91.25 200", ("empty", "empty", "captured")), + (b"private", b"private", b"nan", b"-1 0", ("unrecognized", "unrecognized", "unrecognized")), + (b" Reason: Input dispatching timed out (private)", b"", b"1700000123", b"private", + ("partial", "empty", "partial")), + (None, None, None, None, ("unavailable", "unavailable", "unavailable")), + ): + with self.subTest(expected=expected): + self.device.call.side_effect = [ + (0, value, b"") if value is not None else (1, b"private-output", b"private-stderr") + for value in (last, events, wall, uptime) + ] + metadata = self.capture() + self.assertEqual( + (metadata["last_anr_status"], metadata["anr_events_status"], metadata["device_clock"]["status"]), + expected, + ) + self.assertEqual(metadata["scope"], "device-history") + if expected[2] in ("unavailable", "unrecognized"): + self.assertIsNone(metadata["device_clock"]["wall_epoch"]) + self.assertIsNone(metadata["device_clock"]["uptime_seconds"]) + for path in self.destination.iterdir(): + self.assertNotIn("private", path.read_text()) + + def test_failed_reads_keep_other_sources_and_numeric_metadata(self): + for failure in (runner.RunnerFailure("tool-timeout"), OSError(SYNTHETIC_SECRET)): + with self.subTest(failure=type(failure).__name__): + self.device.call.side_effect = [ + failure, (0, b"", b""), failure, (0, b"91.25 200\n", b""), + ] + metadata = self.capture() + self.assertEqual(metadata["last_anr_status"], "unavailable") + self.assertEqual(metadata["anr_events_status"], "empty") + self.assertEqual(metadata["device_clock"], { + "status": "partial", "wall_epoch": None, "uptime_seconds": 91.25, + }) + self.assertNotIn(SYNTHETIC_SECRET, str(metadata)) + + def test_parse_failure_does_not_discard_other_checkpoint_sources(self): + with mock.patch.object(runner, "parse_last_anr", side_effect=ValueError(SYNTHETIC_SECRET)): + metadata = self.capture() + self.assertEqual(metadata["last_anr_status"], "unrecognized") + self.assertEqual(metadata["anr_events_status"], "empty") + self.assertEqual(metadata["device_clock"]["status"], "captured") + self.assertNotIn(SYNTHETIC_SECRET, str(metadata)) + + def test_nonfinite_numbers_are_unknown_and_never_serialized_as_infinity(self): + huge = "9" * 400 + self.device.call.side_effect = [ + (0, ("ANR in com.android.systemui\nReason: Broadcast of Intent\n" + "Load: " + huge + " / 1 / 2\n" + huge + "% TOTAL:\n").encode(), b""), + (0, b"", b""), (0, huge.encode(), b""), (0, (huge + " 1\n").encode(), b""), + ] + metadata = self.capture() + self.assertEqual(metadata["device_clock"]["status"], "unrecognized") + last_anr = json.loads((self.destination / "last-anr-summary.json").read_text()) + self.assertIsNone(last_anr["load_average"]) + self.assertIsNone(last_anr["total_cpu_percent"]) + for path in self.destination.iterdir(): + self.assertNotIn("Infinity", path.read_text()) + self.assertNotIn("NaN", path.read_text()) + + def test_single_artifact_write_failure_keeps_other_summary_and_checkpoint(self): + write_text = Path.write_text + + def write(path, *args, **kwargs): + if path.name == "last-anr-summary.json": + raise OSError(SYNTHETIC_SECRET) + return write_text(path, *args, **kwargs) + + with mock.patch.object(Path, "write_text", new=write): + metadata = self.capture() + self.assertEqual(metadata["last_anr_status"], "empty") + self.assertTrue((self.destination / "anr-events-summary.json").is_file()) + self.assertFalse((self.destination / "last-anr-summary.json").exists()) + + def test_interruption_during_checkpoint_read_preserves_cancellation(self): + interruption = runner.RunInterrupted("run-interrupted") + self.device.call.side_effect = interruption + with self.assertRaises(runner.RunInterrupted) as caught: + self.capture() + self.assertIs(caught.exception, interruption) + self.assertFalse(self.destination.exists()) + class EmulatorInputTests(unittest.TestCase): def input_method(self, subtypes):