From a8f1282d4e21bf1c6f978610a5a9636e3bf5c24e Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:55:52 +0900 Subject: [PATCH 1/2] =?UTF-8?q?chore(e2e):=20=EC=B1=84=ED=8C=85=20ANR=20?= =?UTF-8?q?=EC=A7=84=EB=8B=A8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-chatting.md | 59 ++- .maestro/README.md | 10 +- .maestro/scripts/features/chatting.py | 58 ++- .maestro/scripts/features/test_chatting.py | 170 +++++++- .maestro/scripts/host-resources.py | 329 ++++++++++++++ .maestro/scripts/run-tests.py | 243 +++++++++-- .maestro/scripts/test_host_resources.py | 424 ++++++++++++++++++ .maestro/scripts/test_run_tests.py | 473 ++++++++++++++++++++- 8 files changed, 1722 insertions(+), 44 deletions(-) create mode 100755 .maestro/scripts/host-resources.py create mode 100644 .maestro/scripts/test_host_resources.py diff --git a/.maestro/README-chatting.md b/.maestro/README-chatting.md index 5b62775e..5c3ec4e1 100644 --- a/.maestro/README-chatting.md +++ b/.maestro/README-chatting.md @@ -122,8 +122,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나 선행 단계 성공을 채팅 전체 성공으로 계산하지 않습니다. @@ -161,19 +195,20 @@ 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·채팅 성공을 주장하지 않습니다.** 정적 확인 범위는 실패 hierarchy/로그, bootstrap 인증→앱 진입→방 준비 경로, 전체 Python 교환 순서와 실패 계약, 송신 helper/입력/outgoing 및 기존 incoming·목록·이력 selector입니다. -컨트롤러는 Python 회귀 테스트와 일반 CI를 확인하고, 같은 실제 두 계정·기기로 네 시나리오를 -실행해야 합니다. ANR가 재발하면 같은 단계 hierarchy와 `last-anr-summary.json`의 분류·부하를 -대조해야 합니다. 새 비밀값이나 대체 계정은 필요하다고 확인되지 않았으며, 정상 응답하는 +컨트롤러는 승인된 원격 CI의 Python 회귀 테스트·APK 빌드 결과와 실제 두 계정·기기의 네 +시나리오를 확인해야 합니다. ANR가 재발하면 같은 단계 hierarchy, 네 checkpoint와 이벤트 +시각, 호스트 주기 자료를 대조합니다. 진단 PR의 CI도 기존 ANR로 실패할 수 있습니다. +새 비밀값이나 대체 계정은 필요하다고 확인되지 않았으며, 정상 응답하는 두 기기와 기존 DEV 인증·REST·WebSocket 접근이 실제 검증의 외부 전제입니다. diff --git a/.maestro/README.md b/.maestro/README.md index 54d3d66a..8bd4b221 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -252,7 +252,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 +268,7 @@ 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로 실패할 수 있습니다. 저장소 루트에서 사용하는 표준 라이브러리 회귀 테스트 명령: 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..a714abc1 100755 --- a/.maestro/scripts/run-tests.py +++ b/.maestro/scripts/run-tests.py @@ -916,48 +916,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 +1197,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_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): From 7189fe53e1865c3723afa0e9729c8e2d179ae487 Mon Sep 17 00:00:00 2001 From: Dongwon Shin <99808693+edv-Shin@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:06:43 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore(e2e):=20=EA=B8=B0=EB=B3=B8=20CI?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=91=90=20=EA=B8=B0=EA=B8=B0=20=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .maestro/README-chatting.md | 24 ++++++---- .maestro/README.md | 16 +++++-- .maestro/scripts/run-tests.py | 6 ++- .maestro/scripts/test_feature_suites.py | 64 +++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 13 deletions(-) diff --git a/.maestro/README-chatting.md b/.maestro/README-chatting.md index 5c3ec4e1..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 \ @@ -175,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나 준비 단계 성공은 채팅 성공의 증거가 아닙니다. @@ -203,12 +210,13 @@ outgoing 서버 에코 대기(기존 60초)를 복구했습니다. 양방향 inc 원문 배제·미지원/부분 수집·읽기/쓰기 실패·중단과 준비/최초 open 실패의 정리 순서를 다룹니다. 기존 actor/메시지·각 송수신·목록·이력의 acceptance는 바꾸지 않습니다. -**이 변경에 대한 로컬 코드·설치·빌드·테스트·lint/formatter·adb·emulator·Maestro 실행은 -없습니다. 아래 원격 검증이 끝나기 전에는 전체 CI·채팅 성공을 주장하지 않습니다.** +**이번 기본 `ci` 선택 변경에 대한 로컬 코드·설치·빌드·테스트·lint/formatter·adb·emulator·Maestro 실행은 +없습니다. 기본 `ci`와 채팅의 성공은 각각 아래 원격 결과로 확인해야 합니다.** 정적 확인 범위는 실패 hierarchy/로그, bootstrap 인증→앱 진입→방 준비 경로, 전체 Python 교환 순서와 실패 계약, 송신 helper/입력/outgoing 및 기존 incoming·목록·이력 selector입니다. -컨트롤러는 승인된 원격 CI의 Python 회귀 테스트·APK 빌드 결과와 실제 두 계정·기기의 네 -시나리오를 확인해야 합니다. ANR가 재발하면 같은 단계 hierarchy, 네 checkpoint와 이벤트 -시각, 호스트 주기 자료를 대조합니다. 진단 PR의 CI도 기존 ANR로 실패할 수 있습니다. +컨트롤러는 승인된 원격 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 8bd4b221..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은 업로드하지 않습니다. @@ -270,6 +276,8 @@ 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`의 채팅 검증 결과는 별개입니다. + 저장소 루트에서 사용하는 표준 라이브러리 회귀 테스트 명령: ```bash diff --git a/.maestro/scripts/run-tests.py b/.maestro/scripts/run-tests.py index a714abc1..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])] 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"]}):