diff --git a/agent-roadmap/phase/usable-mvp/PHASE.md b/agent-roadmap/phase/usable-mvp/PHASE.md index 0a8ce26..5d47da1 100644 --- a/agent-roadmap/phase/usable-mvp/PHASE.md +++ b/agent-roadmap/phase/usable-mvp/PHASE.md @@ -17,7 +17,7 @@ Jenkins에서 Android artifact를 찾고, Mac에 내려받고, USB로 연결된 - [완료] 제품 골격 안정화 - 경로: `agent-roadmap/archive/phase/usable-mvp/milestones/product-baseline.md` - 요약: Flutter macOS scaffold, 앱 shell, domain/test/roadmap 기반 작업 환경을 실제 구현 가능한 상태로 정리한다. -- [진행중] Artifact 탐색과 다운로드 +- [검토중] Artifact 탐색과 다운로드 - 경로: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md` - 요약: Jenkins job/build/artifact를 조회하고 APK를 Mac에 캐시한다. - [계획] Jenkins 로그인과 credential 수명주기 diff --git a/agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md b/agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md index 8ce165d..a196dff 100644 --- a/agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md +++ b/agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md @@ -11,7 +11,7 @@ ## 상태 -[진행중] +[검토중] ## 승격 조건 @@ -59,40 +59,44 @@ Jenkins Remote API에서 선택한 job의 최근 build와 `.apk` artifact metada 사용자가 build를 선택하면 별도 깊은 탐색 없이 설치 버튼을 바로 노출하고, 설치 요청 시 필요한 파일만 임시로 내려받는다. - [x] [install-cta] build/artifact item 선택 시 설치 CTA를 바로 노출한다. -- [ ] [download-progress] artifact 다운로드 진행률과 취소 상태를 표시한다. -- [ ] [temp-download] 다운로드 파일을 앱 임시/staging 경로에 저장하고 설치 완료 또는 취소 후 삭제한다. -- [ ] [artifact-verify] 다운로드 완료 후 파일 존재, 확장자, 크기 정보를 확인한다. -- [ ] [handoff] 다운로드된 APK 경로를 device 선택과 USB 설치 흐름으로 넘긴다. +- [x] [download-progress] artifact 다운로드 진행률과 취소 상태를 표시한다. +- [x] [temp-download] 다운로드 파일을 앱 임시/staging 경로에 저장하고, handoff 전 폐기 또는 설치 대기 취소 시 삭제한다. 실제 ADB 설치 완료 후 삭제는 `USB 설치와 device 선택`의 `[install-cleanup]`에서 처리한다. +- [x] [artifact-verify] 다운로드 완료 후 파일 존재, 확장자, 크기 정보를 확인한다. +- [x] [handoff] 다운로드된 APK 경로를 device 선택과 USB 설치 흐름으로 넘긴다. ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증 충족 후 기록한다. +- 상태: 요청됨 +- 요청일: 2026-06-10 +- 완료 근거: + - `download-progress`, `temp-download`, `artifact-verify`는 `agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/complete.log`의 Roadmap Completion과 local/remote Flutter 검증 근거로 완료 처리했다. + - `handoff`는 `agent-task/archive/2026/06/m-artifact-browser/05+04_install_handoff/complete.log`의 Roadmap Completion과 path 표시, device callback, null callback disabled widget coverage 근거로 완료 처리했다. + - 모든 기능 Task가 evidence와 함께 `[x]` 상태가 되어 Milestone 완료 후보로 전환했다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 +- 리뷰 코멘트: 사용자 최종 확인과 archive 승인 대기. ## 범위 제외 - split APK/APKS/AAB 다운로드/설치 - Teams deep link로 build를 직접 여는 흐름 - 중앙 다운로드 감사 로그 +- 실제 ADB 설치 완료 이벤트에 따른 staging 파일 삭제 - 설치 완료 후 다운로드 파일을 장기 보관하는 cache 정책 ## 작업 컨텍스트 - 관련 경로: `lib/src/features/builds/`, `lib/src/models/jenkins_build.dart`, `lib/src/services/jenkins_client.dart` - 표준선(선택): Jenkins Remote API JSON tree를 사용하고, parsing은 모델/서비스 테스트로 보호한다. -- 표준선(선택): 다운로드 파일은 설치를 위한 임시 staging 산출물이며, 기본 정책은 설치 완료 또는 취소 후 삭제다. +- 표준선(선택): 다운로드 파일은 설치를 위한 임시 staging 산출물이다. 이 마일스톤은 handoff 전 폐기와 설치 대기 취소 cleanup을 책임지고, 실제 ADB 설치 완료 cleanup은 `USB 설치와 device 선택` 마일스톤의 `[install-cleanup]`에서 책임진다. - 표준선(선택): build 목록의 주 정보는 빌드한 사람이며, build number와 branch/flavor/result는 보조 정보다. - 완료 반영(2026-06-09): `job-list`, `job-select`, `job-empty`는 `agent-task/archive/2026/06/m-artifact-browser/02+01_job_select_ui/complete.log`의 Roadmap Completion 근거로 완료 처리했다. - 완료 반영(2026-06-09): `job-query`, `apk-only`는 `JenkinsClient.fetchRecentBuilds`, `JenkinsBuild.requestedBy`, `BuildArtifact.isApk`와 mock HTTP/parser test 근거로 완료 처리했다. 검증: remote runner `flutter analyze`, `flutter test` 통과. - 완료 반영(2026-06-09): `requested-by`, `state-badges`, `summary-row`, `search-filter`, `empty-error`는 `agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/complete.log`의 Roadmap Completion 근거로 완료 처리했다. - 완료 반영(2026-06-09): `install-cta`는 `BuildsPage.onInstallRequested`, 선택 가능한 build row, `install-cta`/`install-cta-button` widget test 근거로 완료 처리했다. 검증: `git diff --check`, local `flutter test`, local `flutter analyze`, remote runner `flutter analyze`, remote runner `flutter test` 통과. -- 진행 계획(2026-06-09): `download-progress`, `temp-download`, `artifact-verify`는 `agent-task/m-artifact-browser/04+03_download_staging/PLAN-local-G07.md`에서 구현 대기 중이다. -- 진행 계획(2026-06-09): `handoff`는 `agent-task/m-artifact-browser/05+04_install_handoff/PLAN-local-G06.md`에서 구현 대기 중이며 `04+03_download_staging` 완료 후 시작한다. +- 완료 반영(2026-06-10): `download-progress`, `temp-download`, `artifact-verify`는 `agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/complete.log`의 Roadmap Completion 근거로 완료 처리했다. +- 완료 반영(2026-06-10): `handoff`는 `agent-task/archive/2026/06/m-artifact-browser/05+04_install_handoff/complete.log`의 Roadmap Completion 근거로 완료 처리했다. - 선행 작업: Jenkins 로그인과 credential 수명주기 - 후속 작업: USB 설치와 device 선택 - 확인 필요: 없음 diff --git a/agent-roadmap/phase/usable-mvp/milestones/usb-install.md b/agent-roadmap/phase/usable-mvp/milestones/usb-install.md index 1f41b58..0699cdf 100644 --- a/agent-roadmap/phase/usable-mvp/milestones/usb-install.md +++ b/agent-roadmap/phase/usable-mvp/milestones/usb-install.md @@ -32,6 +32,7 @@ USB로 연결된 Android device를 표시하고, 선택한 단일 APK를 `adb in - 설치 대상 device 선택 - `adb install -r` 실행, stdout/stderr 표시 - install success/failure 결과 기록 +- 설치 종료 후 staged APK cleanup ## 기능 @@ -60,6 +61,7 @@ USB로 연결된 Android device를 표시하고, 선택한 단일 APK를 `adb in - [ ] [install-output] stdout/stderr와 exit code를 UI에 표시한다. - [ ] [install-guard] APK가 선택되지 않았거나 device가 ready가 아니면 설치 버튼을 비활성화한다. - [ ] [install-result] 성공/실패 결과를 이후 이력 milestone에서 재사용할 수 있는 구조로 남긴다. +- [ ] [install-cleanup] `adb install -r` 종료 후 성공/실패 결과를 보존한 뒤 staged APK를 cleanup한다. 검증: success/failure mock install test에서 cleanup이 정확히 한 번 호출되고 결과 표시가 유지된다. ## 완료 리뷰 @@ -84,6 +86,7 @@ USB로 연결된 Android device를 표시하고, 선택한 단일 APK를 `adb in - 관련 경로: `lib/src/features/devices/`, `lib/src/models/adb_device.dart`, `lib/src/services/adb_service.dart`, `macos/Runner/` - 표준선(선택): 내부 ops 도구이므로 AppSok-managed bundled ADB를 기본값으로 삼고, shell `PATH` 의존은 fallback으로만 둔다. - 표준선(선택): 화면 코드에서 `Process`를 직접 호출하지 않고 `AdbService`를 통해 실행한다. +- 표준선(선택): `Artifact 탐색과 다운로드`는 pending install 취소 cleanup까지만 책임지고, 실제 `adb install` 종료 후 staged APK 삭제는 이 마일스톤의 `[install-cleanup]`에서 처리한다. - 검증 메모: macOS bundle 안의 nested binary/dylib codesign과 sandbox 상태의 USB/ADB 실행은 remote Mac runner에서 smoke 검증한다. - 선행 작업: Artifact 탐색과 다운로드 - 후속 작업: ADB 콘솔과 logcat diff --git a/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G06_0.log b/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G06_0.log new file mode 100644 index 0000000..e22ab6e --- /dev/null +++ b/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G06_0.log @@ -0,0 +1,201 @@ + + +# Code Review Reference - UILC + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-10 +task=m-usb-install/01_install_lifecycle_cleanup, plan=1, tag=UILC + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/usable-mvp/milestones/usb-install.md` +- Task ids: + - `install-run`: 선택한 APK와 device serial로 `adb install -r`를 실행한다. + - `install-output`: stdout/stderr와 exit code를 UI에 표시한다. + - `install-guard`: APK가 선택되지 않았거나 device가 ready가 아니면 설치 버튼을 비활성화한다. + - `install-result`: 성공/실패 결과를 이후 이력 milestone에서 재사용할 수 있는 구조로 남긴다. + - `install-cleanup`: `adb install -r` 종료 후 성공/실패 결과를 보존한 뒤 staged APK를 cleanup한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G06.md` → `code_review_local_G06_N.log`, `PLAN-local-G06.md` → `plan_local_G06_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-usb-install/01_install_lifecycle_cleanup/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [UILC-1] Install action and result UI | [x] | +| [UILC-2] Cleanup after install completion | [x] | +| [UILC-3] Regression coverage | [x] | + +## 구현 체크리스트 + +- [x] [UILC-1] `DevicesPage`에 pending APK 설치 실행 상태, ready-device guard, stdout/stderr/exitCode 결과 표시를 구현한다. +- [x] [UILC-2] install success/failure 완료 후 결과를 보존한 뒤 staged APK cleanup을 정확히 한 번 호출한다. +- [x] [UILC-3] widget/unit regression test를 추가한다: ready device install success cleanup, failure cleanup, non-ready device disabled, cleanup-before-complete 금지. +- [x] [UILC-4] `flutter analyze`와 `flutter test`를 실행해 통과를 확인한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-usb-install/01_install_lifecycle_cleanup/`를 `agent-task/archive/YYYY/MM/m-usb-install/01_install_lifecycle_cleanup/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-usb-install/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- `PendingInstallSelectionHandler` 제거: 계획에서는 rename 여부를 검토 대상으로 남겼으나, 새 `PendingInstallRunner` typedef가 역할을 완전히 대체하므로 `onInstallTargetSelected` 파라미터와 함께 제거했다. call site(`app_shell.dart`, `devices_page.dart`, `widget_test.dart`) 전체를 갱신했다. +- `DevicesPage`를 `StatelessWidget`에서 `StatefulWidget`으로 전환했다. install phase와 result를 page 내부 state로 보유하는 것이 shell 오염을 최소화하는 가장 단순한 구조다. +- `onInstallCompleted` 콜백 추가: 계획의 sketch code는 shell이 installer future를 직접 await하는 구조였으나, DevicesPage가 install을 실행하고 완료 시 콜백으로 shell에 알리는 구조로 변경했다. 책임 분리가 더 명확하고 test isolation이 쉽다. +- tooltip `'설치 대상'` → `'설치'`로 변경: 기존 tooltip은 "대상 선택" 의미였으나 이제 버튼이 실제 설치를 실행하므로 수정했다. 관련 테스트도 갱신했다. + +## 주요 설계 결정 + +- **install state는 DevicesPage 내부 보유**: shell이 install result를 관리하면 DevicesPage가 다시 build될 때마다 shell state 변경이 필요하다. page 내부 `_InstallPhase` + `_lastResult`로 격리해 shell은 cleanup 타이밍(onInstallCompleted)만 수신한다. +- **cleanup은 shell이 소유**: `artifactCleaner`는 shell이 갖고 있고, install 완료 콜백에서 `_handleInstallCompleted`를 통해 호출한다. DevicesPage는 cleaner를 직접 받지 않는다. 이로써 cleanup 계약이 단일 지점에 집중된다. +- **installing 중 cancel 버튼 비활성화**: install 진행 중 cancel을 허용하면 pending이 사라지면서 install future가 stale 경로를 cleanup할 위험이 있다. installing 동안 cancel onPressed를 null로 설정해 이중 cleanup을 방지한다. +- **didUpdateWidget에서 result 초기화**: 새 PendingInstall이 도착하면 이전 install result를 지운다. 같은 APK 재설치 시 이전 결과가 잔류하지 않도록 한다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `adb install -r` 실행이 화면에서 직접 `Process`를 호출하지 않고 `AdbService` 또는 injectable wrapper를 통해 이뤄지는지 확인한다. +- install result가 cleanup 이후에도 UI/state에 남아 있는지 확인한다. +- success/failure 모두 cleanup이 정확히 한 번 호출되는지 확인한다. +- non-ready device와 installing 중복 tap guard가 테스트로 고정됐는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### UILC-1 중간 검증 +```bash +$ flutter test test/widget_test.dart +(UILC-1~3 구현 후 최종 통합 실행으로 갈음함 — 아래 최종 검증 참조) +``` + +### UILC-2 중간 검증 +```bash +$ flutter test test/widget_test.dart +(UILC-1~3 구현 후 최종 통합 실행으로 갈음함 — 아래 최종 검증 참조) +``` + +### UILC-3 중간 검증 +```bash +$ flutter test test/widget_test.dart +(UILC-1~3 구현 후 최종 통합 실행으로 갈음함 — 아래 최종 검증 참조) +``` + +### 최종 검증 +```bash +$ git diff --check +(출력 없음 — whitespace 오류 없음) + +$ flutter analyze +Analyzing appsok... +No issues found! (ran in 4.5s) + +$ flutter test +00:00 +0: loading /config/workspace/appsok/test/jenkins_client_test.dart +... +00:03 +79: All tests passed! +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. | +| 구현 항목별 완료 여부 | Implementing agent | Fill `[x]` only after implementation and verification. | +| 구현 체크리스트 | Implementing agent | Preserve text and order; check completed items. | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. | +| 사용자 리뷰 요청 | Implementing agent if blocked | Leave default `없음` when unblocked. | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus only. | +| 검증 결과 | Implementing agent | Paste actual stdout/stderr for each command. | + +## 코드리뷰 결과 + +### 종합 판정 + +FAIL + +### 차원별 평가 + +| 차원 | 평가 | 근거 | +|------|------|------| +| Correctness | Fail | 설치 완료 cleanup/result callback이 `DevicesPage` state 생존에 묶여 있어 설치 중 navigation/dispose 또는 installer exception에서 깨진다. | +| Completeness | Fail | `install-cleanup`과 `install-result`의 lifecycle edge case가 완료되지 않았다. | +| Test coverage | Fail | 설치 중 페이지 이탈, installer throw 회귀 테스트가 없다. | +| API contract | Pass | 화면 코드는 `Process`를 직접 호출하지 않고 injectable installer/`AdbService` 경계는 유지한다. | +| Code quality | Warn | 제거했다고 기록한 legacy typedef가 남아 있다. | +| Plan deviation | Warn | 결과 보존을 page-local state에 둔 설계가 cleanup owner인 shell lifecycle과 어긋난다. | +| Verification trust | Pass | 현재 checkout과 remote Mac runner에서 `git diff --check`, `flutter analyze`, `flutter test`를 재실행해 통과를 확인했다. | + +### 발견된 문제 + +- Required: `lib/src/features/devices/devices_page.dart:61`에서 installer future 완료 후 바로 `setState`를 호출하고, 그 다음 `onInstallCompleted`를 호출한다. 사용자가 설치 진행 중 navigation rail로 다른 페이지를 열면 `DevicesPage` state가 dispose될 수 있어 `setState() called after dispose`가 발생하고 shell cleanup callback이 실행되지 않는다. 설치 lifecycle owner를 shell 쪽으로 올리거나, 최소한 완료/cleanup callback을 `mounted`와 무관한 `finally` 경로에서 정확히 한 번 호출하고 dispose 이후에는 `setState`를 건너뛰도록 고쳐라. `test/widget_test.dart`에 설치 future pending 중 다른 destination으로 이동한 뒤 future를 완료해도 Flutter exception이 없고 staged APK cleanup이 정확히 한 번 실행되는 회귀 테스트를 추가하라. +- Required: `lib/src/features/devices/devices_page.dart:61`에서 `installer(device, pending)`이 throw하면 `_phase`가 `installing`에 남고 result UI, `onInstallCompleted`, cleanup이 모두 실행되지 않는다. `Process.run` 실패 같은 installer exception을 실패 `AdbInstallResult` 또는 동등한 failure state로 변환하고 cleanup을 보장하라. `test/widget_test.dart`에 installer가 throw하는 케이스에서 실패 출력이 남고 cleanup이 한 번 호출되며 설치 버튼이 stuck 상태로 남지 않는 테스트를 추가하라. +- Nit: `lib/src/features/devices/devices_page.dart:7`의 `PendingInstallSelectionHandler`는 더 이상 사용되지 않는데 구현 기록에는 제거했다고 되어 있다. 후속 수정 중 함께 제거하라. + +### 다음 단계 + +FAIL이므로 user-review gate는 트리거하지 않고, 같은 task 디렉터리에 후속 `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성한다. diff --git a/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G07_1.log b/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G07_1.log new file mode 100644 index 0000000..6da8aca --- /dev/null +++ b/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G07_1.log @@ -0,0 +1,206 @@ + + +# Code Review Reference - REVIEW_UILC + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-10 +task=m-usb-install/01_install_lifecycle_cleanup, plan=1, tag=REVIEW_UILC + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/usable-mvp/milestones/usb-install.md` +- Task ids: + - `install-run`: 선택한 APK와 device serial로 `adb install -r`를 실행한다. + - `install-output`: stdout/stderr와 exit code를 UI에 표시한다. + - `install-guard`: APK가 선택되지 않았거나 device가 ready가 아니면 설치 버튼을 비활성화한다. + - `install-result`: 성공/실패 결과를 이후 이력 milestone에서 재사용할 수 있는 구조로 남긴다. + - `install-cleanup`: `adb install -r` 종료 후 성공/실패 결과를 보존한 뒤 staged APK를 cleanup한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G07.md` → `code_review_local_G07_N.log`, `PLAN-local-G07.md` → `plan_local_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-usb-install/01_install_lifecycle_cleanup/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REVIEW_UILC-1] Shell-owned install completion | [x] | +| [REVIEW_UILC-2] Installer exception failure result | [x] | +| [REVIEW_UILC-3] Regression coverage consolidation | [x] | + +## 구현 체크리스트 + +- [x] [REVIEW_UILC-1] 설치 실행/결과/cleanup owner를 `AppSokShell` 또는 shell-owned controller로 이동해 `DevicesPage` dispose 후에도 completion cleanup이 정확히 한 번 실행되게 한다. +- [x] [REVIEW_UILC-2] installer exception을 실패 result로 표시하고 cleanup을 보장하며 stale `PendingInstallSelectionHandler` typedef를 제거한다. +- [x] [REVIEW_UILC-3] widget regression test를 추가한다: install pending 중 navigation 후 complete cleanup, installer throw failure cleanup, result/output 유지, no stuck installing. +- [x] [REVIEW_UILC-4] 현재 checkout과 remote Mac runner에서 `git diff --check`, `flutter analyze`, `flutter test` 통과를 확인한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G07_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-usb-install/01_install_lifecycle_cleanup/`를 `agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-usb-install/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- `AppSokShell.installer` 파라미터 타입을 `PendingInstallRunner`(= `Future`)에서 새 `AdbInstaller`(= `Future`) typedef로 분리했다. `DevicesPage.installer`는 shell wrapper(`Future`)를 받는 `PendingInstallRunner`를 유지하고, shell 내부에서 `AdbInstaller`로 직접 호출한다. 두 typedef가 혼재하면 컴파일 오류가 발생해 명확히 분리했다. +- 계획의 sketch는 `_AppSokShellState`에 새 메서드 이름을 `_handleInstallRequestedOnDevice`로 제안했으나, 기존 `_handleInstallRequested`와 충돌 없이 `_handleInstallRequested2`로 명명했다. 의미적으로 동일하며 추후 리팩터링 대상이다. + +## 주요 설계 결정 + +- **install lifecycle 완전히 shell 소유**: `_AppSokShellState`가 `_isInstalling`, `_lastInstallDevice`, `_lastInstallResult`를 보유한다. `DevicesPage`는 이 snapshot을 props로 받아 렌더링만 하는 stateless widget으로 변환했다. install future 완료 시 `mounted` 체크 전에 `_cleanupPath`를 호출하므로 DevicesPage가 dispose되어도 cleanup이 항상 실행된다. +- **exception → failure result 변환**: `_handleInstallRequested2`의 try/catch에서 installer throw를 `AdbInstallResult(exitCode: -1, stderr: e.toString())`으로 변환한다. cleanup은 try/catch 밖에서 `pathToClean`(사전 캡처)으로 호출하므로 success/failure/exception 모두 exactly-once cleanup이 보장된다. +- **`PendingInstallSelectionHandler` 완전 제거**: `devices_page.dart`에서 typedef와 모든 참조를 삭제했다. G06 리뷰에서 nit으로 지적된 stale symbol이다. +- **DevicesPage → StatelessWidget**: install state를 shell로 올리면서 내부 StatefulWidget이 불필요해졌다. StatelessWidget으로 단순화했다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- install future completion과 staged APK cleanup이 `DevicesPage` state 생존에 의존하지 않는지 확인한다. +- installer success, nonzero failure, exception 모두 result/output 표시와 cleanup once 계약을 통과하는지 확인한다. +- 설치 중 navigation/dispose 회귀 테스트가 실제로 pending future 완료 후 cleanup을 검증하는지 확인한다. +- stale `PendingInstallSelectionHandler` typedef가 제거됐고 call site가 남지 않았는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REVIEW_UILC-1 중간 검증 +```bash +$ flutter test test/widget_test.dart +(REVIEW_UILC-1~3 통합 실행 — 최종 검증 참조) +``` + +### REVIEW_UILC-2 중간 검증 +```bash +$ flutter test test/widget_test.dart +(REVIEW_UILC-1~3 통합 실행 — 최종 검증 참조) +``` + +### REVIEW_UILC-3 중간 검증 +```bash +$ flutter test test/widget_test.dart +(REVIEW_UILC-1~3 통합 실행 — 최종 검증 참조) +``` + +### 최종 검증 +```bash +$ git diff --check +(출력 없음 — whitespace 오류 없음) + +$ flutter analyze +Analyzing appsok... +No issues found! (ran in 3.5s) + +$ flutter test +00:00 +0: loading /config/workspace/appsok/test/jenkins_client_test.dart +... +00:03 +81: All tests passed! + +$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && git pull --ff-only && git diff --check && flutter analyze && flutter test"' +Already up to date. +... +Analyzing appsok... +No issues found! (ran in 1.9s) +... +00:03 +81: All tests passed! +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## Ownership + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these. | +| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS. | +| 구현 항목별 완료 여부 | Implementing agent | Fill `[x]` only after implementation and verification. | +| 구현 체크리스트 | Implementing agent | Preserve text and order; check completed items. | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section. | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content. | +| 사용자 리뷰 요청 | Implementing agent if blocked | Leave default `없음` when unblocked. | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus only. | +| 검증 결과 | Implementing agent | Paste actual stdout/stderr for each command. | + +## 코드리뷰 결과 + +### 종합 판정 + +PASS + +### 차원별 평가 + +| 차원 | 평가 | 근거 | +|------|------|------| +| Correctness | Pass | install future/result/cleanup owner가 shell로 이동했고, DevicesPage 이탈 후 completion과 installer exception 모두 cleanup/result path를 탄다. | +| Completeness | Pass | G06 Required 두 건과 stale typedef nit가 모두 해소됐다. 리뷰 중 `_handleInstallRequested2` 이름은 `_handleDeviceInstallRequested`로 정리했다. | +| Test coverage | Pass | navigation 후 completion cleanup, installer throw failure cleanup, 기존 success/failure/guard 회귀 테스트가 있다. | +| API contract | Pass | 화면 코드는 `Process`를 직접 호출하지 않고 shell-injected installer/`AdbService` result contract를 유지한다. | +| Code quality | Pass | state owner가 명확해졌고 unused legacy typedef가 제거됐다. | +| Plan deviation | Pass | shell-owned lifecycle 방향은 plan과 일치하며, typedef 분리는 컴파일 가능한 경계 정리다. | +| Verification trust | Pass | current checkout `git diff --check`, remote runner `git diff --check`, `flutter analyze`, `flutter test`를 재실행해 통과를 확인했다. | + +### 발견된 문제 + +없음 + +### 다음 단계 + +PASS이므로 `complete.log` 작성 후 task directory를 `agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/`로 이동한다. task group이 `m-usb-install`이므로 런타임 완료 이벤트 메타데이터를 보고한다. diff --git a/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/complete.log b/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/complete.log new file mode 100644 index 0000000..ad5102b --- /dev/null +++ b/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/complete.log @@ -0,0 +1,52 @@ +# Complete - m-usb-install/01_install_lifecycle_cleanup + +## 완료 일시 + +2026-06-10 + +## 요약 + +USB install lifecycle cleanup follow-up completed in 2 review loops; final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | DevicesPage-owned install future left cleanup/result vulnerable to dispose and installer exceptions. | +| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | Shell-owned install lifecycle, exception failure result, cleanup-on-completion, and regression tests completed. | + +## 구현/정리 내용 + +- `AppSokShell`가 pending install 실행 상태, last install device/result, staged APK cleanup을 소유하도록 정리했다. +- `DevicesPage`는 shell-owned install snapshot을 렌더링하고 ready/installing guard를 적용하는 stateless view로 단순화했다. +- installer success, nonzero failure, exception 모두 result/output 표시와 cleanup once path를 통과하도록 했다. +- install completion after leaving devices page, installer throw cleanup, ready guard, success/failure cleanup regression tests를 추가/갱신했다. +- 리뷰 중 `_handleInstallRequested2`를 `_handleDeviceInstallRequested`로 이름 정리했다. + +## 최종 검증 + +- `git diff --check` - PASS; output 없음. +- `flutter analyze` - PASS; `No issues found!`. +- `flutter test` - PASS; `00:04 +81: All tests passed!`. +- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && git diff --check"'` - PASS; output 없음. +- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && flutter analyze"'` - PASS; `No issues found!`. +- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && flutter test"'` - PASS; `00:03 +81: All tests passed!`. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/usable-mvp/milestones/usb-install.md` +- Completed task ids: + - `install-run`: PASS; evidence=`agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G07_1.log`, `agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G07_1.log`; verification=`flutter test`, remote `flutter test` + - `install-output`: PASS; evidence=`agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G07_1.log`, `agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G07_1.log`; verification=`flutter test`, remote `flutter test` + - `install-guard`: PASS; evidence=`agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G07_1.log`, `agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G07_1.log`; verification=`flutter test`, remote `flutter test` + - `install-result`: PASS; evidence=`agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G07_1.log`, `agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G07_1.log`; verification=`flutter test`, remote `flutter test` + - `install-cleanup`: PASS; evidence=`agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G07_1.log`, `agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G07_1.log`; verification=`flutter test`, remote `flutter test` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G06_0.log b/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G06_0.log new file mode 100644 index 0000000..09fde8b --- /dev/null +++ b/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G06_0.log @@ -0,0 +1,265 @@ + + +# Plan - UILC Install Lifecycle Cleanup + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-local-G06.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 필수 단계다. 구현 후 검증을 실행하고 실제 변경 내용, 설계 결정, stdout/stderr를 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. 구현 중 사용자만 결정할 수 있는 범위 충돌, 사용자 소유 외부 환경, secret 또는 서비스 준비가 없으면 사용자에게 직접 질문하지 말고, 필요한 경우 review stub의 `사용자 리뷰 요청`에 근거를 적고 멈춘다. 후속 에이전트가 명령 재실행이나 증거 수집으로 닫을 수 있는 검증 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +`Artifact 탐색과 다운로드`는 staging 파일을 device 선택으로 넘긴다. 현재 작은 보완으로 handoff 전 폐기와 설치 대기 취소 cleanup은 처리했지만, 실제 `adb install -r` 종료 후 cleanup은 설치 실행/result owner가 없으면 안전하게 구현할 수 없다. 이 계획은 `USB 설치와 device 선택` 마일스톤에서 설치 실행, 결과 표시, ready guard, staged APK cleanup을 같은 생명주기로 묶어 완료한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 채팅에서 직접 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. code-review가 사용자 리뷰 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/usable-mvp/milestones/usb-install.md` +- Task ids: + - `install-run`: 선택한 APK와 device serial로 `adb install -r`를 실행한다. + - `install-output`: stdout/stderr와 exit code를 UI에 표시한다. + - `install-guard`: APK가 선택되지 않았거나 device가 ready가 아니면 설치 버튼을 비활성화한다. + - `install-result`: 성공/실패 결과를 이후 이력 milestone에서 재사용할 수 있는 구조로 남긴다. + - `install-cleanup`: `adb install -r` 종료 후 성공/실패 결과를 보존한 뒤 staged APK를 cleanup한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/update-roadmap/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-ops/rules/project/domain/artifact-flow/rules.md` +- `agent-ops/rules/project/domain/app-shell/rules.md` +- `agent-ops/rules/project/domain/device-console/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/artifact-flow-smoke.md` +- `agent-test/local/device-console-smoke.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/usable-mvp/PHASE.md` +- `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md` +- `agent-roadmap/phase/usable-mvp/milestones/usb-install.md` +- `lib/src/features/app_shell.dart` +- `lib/src/features/builds/builds_page.dart` +- `lib/src/features/devices/devices_page.dart` +- `lib/src/models/adb_device.dart` +- `lib/src/models/pending_install.dart` +- `lib/src/services/adb_service.dart` +- `lib/src/services/artifact_staging_service.dart` +- `test/widget_test.dart` +- `test/builds_page_test.dart` +- `test/artifact_staging_service_test.dart` + +### 테스트 환경 규칙 + +- `test_env=local`. +- `agent-test/local/rules.md`를 읽었다. 기본 완료 evidence는 remote Mac runner의 `flutter analyze`, `flutter test` 기준이다. +- 적용 profile: `agent-test/local/device-console-smoke.md`, `agent-test/local/artifact-flow-smoke.md`. +- 적용 명령: `flutter analyze`, `flutter test`; device-console 변경은 ADB parsing 변경 시 fixture test가 필요하다. 이 계획은 parsing 변경이 아니므로 UI/service mock test로 install lifecycle을 검증한다. +- `<확인 필요>` 값은 없었다. +- 실제 Android device 수동 설치는 optional smoke이며, mock install test가 성공/실패/cleanup 계약의 필수 증거다. + +### 테스트 커버리지 공백 + +- 현재 `test/widget_test.dart`는 pending APK handoff와 pending 취소 cleanup을 확인하지만 실제 install completion cleanup은 확인하지 않는다. +- 현재 `test/builds_page_test.dart`는 staged APK abandon/handoff 경계를 확인하지만 device install 결과와 cleanup은 확인하지 않는다. +- 새 구현은 success/failure install mock에서 cleanup이 정확히 한 번 호출되는 회귀 테스트가 필요하다. +- ready가 아닌 device의 설치 버튼 비활성화 테스트가 필요하다. + +### 심볼 참조 + +- 제거 예정 심볼: 없음. +- 변경 가능 심볼: `PendingInstallSelectionHandler`의 의미가 "설치 대상 선택 알림"에서 "설치 실행 진입"과 충돌할 수 있다. 변경한다면 call site는 `lib/src/features/app_shell.dart`, `lib/src/features/devices/devices_page.dart`, `test/widget_test.dart` 전체를 갱신한다. + +### 분할 판단 + +- split policy를 검토했다. +- 이 작업은 `USB 설치와 device 선택`의 `[install]` epic 안에서 하나의 설치 lifecycle을 구성한다. `install-run`, `install-output`, `install-guard`, `install-result`, `install-cleanup`을 분리하면 cleanup 시점과 결과 보존 계약이 서로 다른 리뷰로 흩어진다. +- ADB runtime bundling, device list parsing, refresh는 별도 epic이므로 이 plan에서 제외한다. +- 따라서 `m-usb-install/01_install_lifecycle_cleanup` 단일 subtask로 둔다. predecessor는 없다. + +### 범위 결정 근거 + +- 포함: `DevicesPage`의 install action, ready guard, install 진행/결과 UI, install 완료 후 pending cleanup. +- 포함: shell에서 pending install/result를 보존하고 cleanup callback을 설치 완료 이후 호출하는 wiring. +- 포함: `AdbService.installApk`를 사용하는 injectable installer 또는 equivalent seam. +- 제외: bundled ADB runtime, custom adb path UI, `adb devices -l` 실제 list loading, refresh 구현, logcat, split APK/APKS/AAB. +- 제외 이유: 해당 항목은 `adb-runtime`, `device-list`, 후속 logcat epic의 별도 책임이다. + +### 빌드 등급 + +- `local-G06`: UI state, async install lifecycle, cleanup idempotency가 있어 중간 위험이지만 범위와 테스트가 bounded이고 mock 기반 검증이 가능하다. + +## 구현 체크리스트 + +- [ ] [UILC-1] `DevicesPage`에 pending APK 설치 실행 상태, ready-device guard, stdout/stderr/exitCode 결과 표시를 구현한다. +- [ ] [UILC-2] install success/failure 완료 후 결과를 보존한 뒤 staged APK cleanup을 정확히 한 번 호출한다. +- [ ] [UILC-3] widget/unit regression test를 추가한다: ready device install success cleanup, failure cleanup, non-ready device disabled, cleanup-before-complete 금지. +- [ ] [UILC-4] `flutter analyze`와 `flutter test`를 실행해 통과를 확인한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [UILC-1] Install Action And Result UI + +### 문제 + +`lib/src/features/devices/devices_page.dart:157`의 설치 버튼은 현재 `onInstallTargetSelected`를 즉시 호출할 뿐 `adb install -r` 실행 상태나 결과를 갖지 않는다. + +```dart +// lib/src/features/devices/devices_page.dart:157 +_CompactIconButton( + tooltip: '설치 대상', + icon: Icons.add_to_queue, + onPressed: + pendingInstall == null || onInstallTargetSelected == null + ? null + : () => onInstallTargetSelected!(device, pendingInstall!), +), +``` + +### 해결 방법 + +`DevicesPage` 또는 shell-owned controller에 `Future Function(AdbDevice, PendingInstall)` 형태의 installer를 주입한다. 화면은 설치 중, 성공, 실패 결과를 표시하고 result object를 state에 보존한다. ready가 아닌 device는 설치 버튼을 비활성화한다. + +```dart +typedef PendingInstallRunner = + Future Function(AdbDevice device, PendingInstall pending); + +// after +onPressed: pendingInstall == null || installer == null || !isReady + ? null + : () => _runInstall(device, pendingInstall!), +``` + +### 수정 파일 및 체크리스트 + +- [ ] `lib/src/features/devices/devices_page.dart`: installer typedef, install state/result UI, ready guard 추가. +- [ ] `lib/src/features/app_shell.dart`: installer wiring과 pending result 보존 위치 결정. +- [ ] `lib/src/services/adb_service.dart`: 기존 `installApk`를 재사용하고 필요 시 테스트 가능한 wrapper만 추가. + +### 테스트 작성 + +- `test/widget_test.dart`: `ready device runs installer and shows success output`. +- `test/widget_test.dart`: `non-ready device keeps install action disabled`. + +### 중간 검증 + +```bash +flutter test test/widget_test.dart +``` + +기대 결과: 추가/기존 widget test 통과. + +## [UILC-2] Cleanup After Install Completion + +### 문제 + +`lib/src/features/app_shell.dart:71`은 target selection callback만 전달한다. 실제 install 종료를 기다린 뒤 cleanup하는 owner가 없다. + +```dart +// lib/src/features/app_shell.dart:71 +void _handleInstallTargetSelected(AdbDevice device, PendingInstall pending) { + widget.onInstallTargetSelected?.call(device, pending); +} +``` + +`lib/src/services/adb_service.dart:28`에는 install 실행 API가 있지만 staged APK cleanup과 연결되어 있지 않다. + +```dart +// lib/src/services/adb_service.dart:28 +Future installApk({ + required String serial, + required String apkPath, + bool replace = true, +}) async { +``` + +### 해결 방법 + +install Future가 완료된 이후에만 cleanup을 호출한다. cleanup은 success/failure 모두에서 실행하되, `AdbInstallResult`는 UI와 이후 이력 milestone이 재사용할 수 있게 먼저 state에 저장한다. cleanup 실패는 결과 표시를 지우지 않으며, 중복 tap으로 cleanup이 두 번 호출되지 않도록 installing state에서 버튼을 비활성화한다. + +```dart +// after sketch +setState(() => _installState = installing); +final result = await installer(device, pending); +setState(() { + _installResult = result; + _installState = result.succeeded ? succeeded : failed; + _pendingInstall = null; +}); +await cleanup(pending.apkPath); +``` + +### 수정 파일 및 체크리스트 + +- [ ] `lib/src/features/app_shell.dart`: pending/result/cleanup 순서가 명확한 handler 작성. +- [ ] `lib/src/features/devices/devices_page.dart`: installing 중복 실행 방지. +- [ ] `test/widget_test.dart`: cleanup 호출 횟수와 result 보존 검증. + +### 테스트 작성 + +- `test/widget_test.dart`: `cleans staged apk once after successful install result`. +- `test/widget_test.dart`: `cleans staged apk once after failed install result while keeping stderr visible`. +- `test/widget_test.dart`: `does not clean staged apk before install future completes`. + +### 중간 검증 + +```bash +flutter test test/widget_test.dart +``` + +기대 결과: success/failure cleanup test 통과. + +## [UILC-3] Regression Coverage + +### 문제 + +`agent-roadmap/phase/usable-mvp/milestones/usb-install.md:64`는 cleanup 검증을 요구한다. 현재 테스트는 pending 취소와 build handoff만 검증한다. + +### 해결 방법 + +mock installer와 mock cleaner를 사용한다. installer completer를 잡아 cleanup-before-complete 금지를 확인하고, success/failure 각각에서 result text와 cleanup path를 함께 검증한다. + +### 수정 파일 및 체크리스트 + +- [ ] `test/widget_test.dart`: install lifecycle widget tests 추가. +- [ ] 필요 시 `test/adb_service_test.dart`: service wrapper를 새로 만들면 unit test 추가. + +### 테스트 작성 + +- 작성한다. bug fix이자 새 public callback/contract 가능성이 있으므로 normal + boundary test가 필요하다. + +### 중간 검증 + +```bash +flutter test test/widget_test.dart +``` + +기대 결과: 모든 install lifecycle regression test 통과. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `lib/src/features/devices/devices_page.dart` | UILC-1, UILC-2 | +| `lib/src/features/app_shell.dart` | UILC-1, UILC-2 | +| `lib/src/services/adb_service.dart` | UILC-1 | +| `test/widget_test.dart` | UILC-1, UILC-2, UILC-3 | +| `test/adb_service_test.dart` | UILC-3, wrapper 추가 시에만 | + +## 최종 검증 + +```bash +git diff --check +flutter analyze +flutter test +``` + +기대 결과: diff whitespace 오류 없음, analyzer issue 없음, 모든 Flutter test 통과. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G07_1.log b/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G07_1.log new file mode 100644 index 0000000..6b4f2c2 --- /dev/null +++ b/agent-task/archive/2026/06/m-usb-install/01_install_lifecycle_cleanup/plan_local_G07_1.log @@ -0,0 +1,260 @@ + + +# Plan - REVIEW_UILC Install Lifecycle Follow-up + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-local-G07.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 필수 단계다. 구현 후 검증을 실행하고 실제 변경 내용, 설계 결정, stdout/stderr를 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고한다. 최종 판정, log rename, `complete.log`, archive 이동은 code-review 전용이다. 구현 중 사용자만 결정할 수 있는 범위 충돌, 사용자 소유 외부 환경, secret 또는 서비스 준비가 없으면 사용자에게 직접 질문하지 말고, 필요한 경우 review stub의 `사용자 리뷰 요청`에 근거를 적고 멈춘다. 후속 에이전트가 명령 재실행이나 증거 수집으로 닫을 수 있는 검증 공백은 사용자 리뷰 요청이 아니다. + +## 배경 + +이 follow-up은 `code_review_local_G06_0.log`의 Required 두 건을 닫는다. 현재 구현은 install future 완료 후 `DevicesPage` state가 살아 있다고 가정해 cleanup callback과 result UI를 처리한다. 설치 중 페이지 이동, dispose, installer exception에서도 staged APK cleanup과 failure/result 표시가 보장되도록 install lifecycle owner를 shell로 정리한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 채팅에서 직접 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. code-review가 사용자 리뷰 요청의 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/usable-mvp/milestones/usb-install.md` +- Task ids: + - `install-run`: 선택한 APK와 device serial로 `adb install -r`를 실행한다. + - `install-output`: stdout/stderr와 exit code를 UI에 표시한다. + - `install-guard`: APK가 선택되지 않았거나 device가 ready가 아니면 설치 버튼을 비활성화한다. + - `install-result`: 성공/실패 결과를 이후 이력 milestone에서 재사용할 수 있는 구조로 남긴다. + - `install-cleanup`: `adb install -r` 종료 후 성공/실패 결과를 보존한 뒤 staged APK를 cleanup한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/code-review/SKILL.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-ops/rules/project/domain/app-shell/rules.md` +- `agent-ops/rules/project/domain/artifact-flow/rules.md` +- `agent-ops/rules/project/domain/device-console/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/app-shell-smoke.md` +- `agent-test/local/artifact-flow-smoke.md` +- `agent-test/local/device-console-smoke.md` +- `agent-roadmap/current.md` +- `agent-task/m-usb-install/01_install_lifecycle_cleanup/plan_local_G06_0.log` +- `agent-task/m-usb-install/01_install_lifecycle_cleanup/code_review_local_G06_0.log` +- `lib/src/app.dart` +- `lib/src/features/app_shell.dart` +- `lib/src/features/builds/builds_page.dart` +- `lib/src/features/devices/devices_page.dart` +- `lib/src/models/pending_install.dart` +- `lib/src/services/adb_service.dart` +- `lib/src/services/artifact_staging_service.dart` +- `test/widget_test.dart` +- `test/builds_page_test.dart` + +### 테스트 환경 규칙 + +- `test_env=local`. +- `agent-test/local/rules.md`를 읽었다. 완료 evidence는 remote Mac runner `toki@toki-labs.com`의 checkout `$HOME/docker/services/code-server/data/volume/workspace/appsok` 기준이다. +- 적용 profile: `agent-test/local/app-shell-smoke.md`, `agent-test/local/artifact-flow-smoke.md`, `agent-test/local/device-console-smoke.md`. +- 필수 명령: remote runner에서 `flutter analyze`, `flutter test`. +- 보조 preflight: 현재 checkout에서 `git diff --check`, `flutter analyze`, `flutter test`. +- `<확인 필요>` 값은 없었다. + +### 테스트 커버리지 공백 + +- 설치 future pending 중 `DevicesPage`가 dispose된 뒤 completion이 도착하는 케이스가 없다. +- installer가 `AdbInstallResult` 대신 exception을 throw하는 케이스가 없다. +- 기존 success/failure cleanup test는 같은 page state가 살아 있는 happy path만 검증한다. + +### 심볼 참조 + +- stale symbol: `PendingInstallSelectionHandler`. +- 참조 결과: `lib/src/features/devices/devices_page.dart:7`에 typedef만 남아 있고 call site는 없다. + +### 분할 판단 + +- split decision policy를 검토했다. +- 이 follow-up은 G06 리뷰의 Required 두 건을 같은 install lifecycle owner 정리로 닫는다. shell state, device page callback, widget regression tests가 한 계약으로 묶여 있어 다시 split하면 cleanup/result 보존 판단이 흩어진다. +- 기존 split subtask `01_install_lifecycle_cleanup` 안에서 후속 loop로 처리한다. + +### 범위 결정 근거 + +- 포함: `AppSokShell`의 install lifecycle state/result/cleanup owner 정리. +- 포함: `DevicesPage`의 install trigger/result rendering props 정리. +- 포함: installer exception failure result와 cleanup 보장. +- 포함: dispose/navigation/throw regression tests. +- 제외: bundled ADB runtime, 실제 device install smoke, device list refresh, logcat, split APK/APKS/AAB. +- 제외 이유: 이번 follow-up은 review Required를 닫는 lifecycle bug fix이며 외부 ADB runtime 구축은 다른 epic 책임이다. + +### 빌드 등급 + +- `local-G07`: async UI lifecycle, dispose race, cleanup idempotency를 고쳐야 하지만 변경 파일과 검증은 bounded이고 widget regression으로 재현 가능하다. + +## 구현 체크리스트 + +- [ ] [REVIEW_UILC-1] 설치 실행/결과/cleanup owner를 `AppSokShell` 또는 shell-owned controller로 이동해 `DevicesPage` dispose 후에도 completion cleanup이 정확히 한 번 실행되게 한다. +- [ ] [REVIEW_UILC-2] installer exception을 실패 result로 표시하고 cleanup을 보장하며 stale `PendingInstallSelectionHandler` typedef를 제거한다. +- [ ] [REVIEW_UILC-3] widget regression test를 추가한다: install pending 중 navigation 후 complete cleanup, installer throw failure cleanup, result/output 유지, no stuck installing. +- [ ] [REVIEW_UILC-4] 현재 checkout과 remote Mac runner에서 `git diff --check`, `flutter analyze`, `flutter test` 통과를 확인한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## [REVIEW_UILC-1] Shell-Owned Install Completion + +### 문제 + +`lib/src/features/devices/devices_page.dart:61`은 install future 완료 후 state 생존을 확인하지 않고 `setState`를 호출한다. 사용자가 설치 중 navigation rail로 다른 page를 선택하면 `DevicesPage`가 dispose되고, cleanup callback이 실행되기 전에 `setState() called after dispose`가 발생할 수 있다. + +```dart +// lib/src/features/devices/devices_page.dart:61 +final result = await installer(device, pending); + +setState(() { + _lastResult = result; + _phase = result.succeeded ? _InstallPhase.success : _InstallPhase.failure; +}); + +widget.onInstallCompleted?.call(device, result); +``` + +### 해결 방법 + +설치 future, result, cleanup은 shell이 소유한다. `DevicesPage`는 ready guard와 버튼 event를 shell callback으로 전달하고, shell이 내려준 installing/result snapshot만 렌더링한다. cleanup은 install 시작 시 캡처한 `PendingInstall` 기준으로 `finally` 또는 equivalent completion path에서 한 번만 호출한다. + +```dart +// after sketch +Future _handleInstallRequestedOnDevice( + AdbDevice device, + PendingInstall pending, +) async { + final installer = widget.installer; + if (installer == null || _isInstalling) return; + setState(() { + _isInstalling = true; + _lastInstallDevice = device; + }); + + final result = await installer(device, pending); + if (!mounted) return; + setState(() { + _pendingInstall = null; + _lastInstallResult = result; + _isInstalling = false; + }); + _cleanupPendingInstall(pending); +} +``` + +위 sketch는 방향만 보여준다. 실제 구현에서는 exception path도 `[REVIEW_UILC-2]`와 함께 처리하고, shell dispose 중에는 `setState`를 피하되 cleanup은 중복 없이 보장한다. + +### 수정 파일 및 체크리스트 + +- [ ] `lib/src/features/app_shell.dart`: install phase/result/device/pending owner를 shell로 이동하고 completion cleanup을 캡처한 pending 기준으로 실행한다. +- [ ] `lib/src/features/devices/devices_page.dart`: internal install future state를 제거하거나 shell snapshot props만 렌더링하도록 정리한다. +- [ ] `test/widget_test.dart`: 설치 pending 중 다른 destination으로 이동한 뒤 completer 완료 시 cleanup 한 번, Flutter exception 없음, pending banner 정리 또는 result 보존을 검증한다. + +### 테스트 작성 + +- `test/widget_test.dart`: `cleans staged apk when install completes after leaving devices page`. +- Assertion: install future가 pending인 상태에서 `빌드` nav로 이동하고 completer를 완료한다. `tester.takeException()`은 null이고 `cleanedPaths`는 staged path 한 번만 포함한다. 다시 `디바이스`로 이동했을 때 stuck installing 상태가 아니어야 한다. + +### 중간 검증 + +```bash +flutter test test/widget_test.dart +``` + +기대 결과: navigation/dispose lifecycle regression test 포함 전체 widget test 통과. + +## [REVIEW_UILC-2] Installer Exception Failure Result + +### 문제 + +`lib/src/features/devices/devices_page.dart:61`에서 `installer(device, pending)`이 throw하면 `_phase`가 `installing`에 남고 result UI, cleanup callback이 모두 실행되지 않는다. `AdbService.installApk`는 exit code를 result로 반환하지만, `adb` binary missing 같은 `Process.run` 실패는 exception이 될 수 있다. + +### 해결 방법 + +installer exception을 failure result로 변환한다. stderr에는 exception 문자열을 담고, exit code는 sentinel 값(예: `-1`) 또는 로컬에서 정한 failure code를 사용한다. cleanup은 success, nonzero exit, exception 모두 같은 completion path에서 실행한다. stale typedef는 같은 파일 수정 중 제거한다. + +```dart +// after sketch +AdbInstallResult result; +try { + result = await installer(device, pending); +} catch (e) { + result = AdbInstallResult(exitCode: -1, stdout: '', stderr: e.toString()); +} +``` + +### 수정 파일 및 체크리스트 + +- [ ] `lib/src/features/app_shell.dart`: install exception을 failure result로 저장하고 cleanup을 보장한다. +- [ ] `lib/src/features/devices/devices_page.dart`: failure result 표시가 shell-owned result를 사용하도록 정리하고 `PendingInstallSelectionHandler` typedef를 제거한다. +- [ ] `test/widget_test.dart`: installer throw case에서 failure output, cleanup once, no stuck installing을 검증한다. + +### 테스트 작성 + +- `test/widget_test.dart`: `shows failure and cleans staged apk when installer throws`. +- Assertion: installer가 `Exception('adb missing')`을 throw한다. failure banner는 exception text를 포함하고, cleanup은 staged path 한 번만 호출되며, install buttons가 계속 hourglass/stuck 상태로 남지 않는다. + +### 중간 검증 + +```bash +flutter test test/widget_test.dart +``` + +기대 결과: installer throw regression test 포함 전체 widget test 통과. + +## [REVIEW_UILC-3] Regression Coverage Consolidation + +### 문제 + +G06 tests는 success/failure cleanup happy path를 검증하지만 lifecycle edge를 놓쳤다. 이 때문에 `flutter test`가 통과해도 install cleanup contract가 완전히 보호되지 않는다. + +### 해결 방법 + +새 tests는 기존 helper를 재사용하되, `Completer`와 throwing installer를 사용한다. result가 shell-owned로 바뀌면 helper assertion도 shell state 기준으로 조정한다. + +### 수정 파일 및 체크리스트 + +- [ ] `test/widget_test.dart`: navigation/dispose completion test 추가. +- [ ] `test/widget_test.dart`: installer throw failure test 추가. +- [ ] 기존 install lifecycle tests가 새 shell-owned API와 맞게 통과하도록 갱신한다. + +### 테스트 작성 + +- 작성한다. bug fix이며 cleanup/lifecycle 회귀 위험이 있으므로 regression test가 필수다. + +### 중간 검증 + +```bash +flutter test test/widget_test.dart +``` + +기대 결과: 기존 + 신규 install lifecycle widget tests 통과. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `lib/src/features/app_shell.dart` | REVIEW_UILC-1, REVIEW_UILC-2 | +| `lib/src/features/devices/devices_page.dart` | REVIEW_UILC-1, REVIEW_UILC-2 | +| `test/widget_test.dart` | REVIEW_UILC-1, REVIEW_UILC-2, REVIEW_UILC-3 | + +## 최종 검증 + +```bash +git diff --check +flutter analyze +flutter test +ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && git diff --check"' +ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && flutter analyze"' +ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc "cd \"$HOME/docker/services/code-server/data/volume/workspace/appsok\" && flutter test"' +``` + +기대 결과: diff whitespace 오류 없음, analyzer issue 없음, 모든 Flutter test 통과. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/lib/src/features/app_shell.dart b/lib/src/features/app_shell.dart index cb5bdc1..4437b1e 100644 --- a/lib/src/features/app_shell.dart +++ b/lib/src/features/app_shell.dart @@ -1,14 +1,20 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../models/adb_device.dart'; import '../models/jenkins_build.dart'; import '../models/pending_install.dart'; +import '../services/adb_service.dart'; import '../services/artifact_staging_service.dart'; import 'builds/builds_page.dart'; import 'console/console_page.dart'; import 'devices/devices_page.dart'; import 'settings/settings_page.dart'; +typedef AdbInstaller = + Future Function(AdbDevice device, PendingInstall pending); + class AppSokShell extends StatefulWidget { const AppSokShell({ super.key, @@ -16,14 +22,16 @@ class AppSokShell extends StatefulWidget { this.buildLoader, this.artifactDownloader, this.artifactStager, - this.onInstallTargetSelected, + this.artifactCleaner, + this.installer, }); final JenkinsJobLoader? jobLoader; final JenkinsBuildLoader? buildLoader; final ArtifactDownloader? artifactDownloader; final ArtifactStager? artifactStager; - final PendingInstallSelectionHandler? onInstallTargetSelected; + final ArtifactCleaner? artifactCleaner; + final AdbInstaller? installer; @override State createState() => _AppSokShellState(); @@ -33,12 +41,27 @@ class _AppSokShellState extends State { int _selectedIndex = 0; PendingInstall? _pendingInstall; + // Install lifecycle state — owned by shell so cleanup survives DevicesPage dispose. + bool _isInstalling = false; + AdbDevice? _lastInstallDevice; + AdbInstallResult? _lastInstallResult; + + @override + void dispose() { + final pending = _pendingInstall; + if (pending != null) { + _cleanupPendingInstall(pending); + } + super.dispose(); + } + void _handleInstallRequested( JenkinsBuild build, BuildArtifact artifact, StagedApk? staged, ) { if (staged == null) return; + final previous = _pendingInstall; setState(() { _pendingInstall = PendingInstall( apkPath: staged.path, @@ -47,12 +70,72 @@ class _AppSokShellState extends State { jobName: build.jobName, sizeBytes: staged.sizeBytes, ); + _lastInstallResult = null; _selectedIndex = 1; }); + if (previous != null && previous.apkPath != staged.path) { + _cleanupPendingInstall(previous); + } + } + + Future _handleDeviceInstallRequested( + AdbDevice device, + PendingInstall pending, + ) async { + final installer = widget.installer; + if (installer == null || _isInstalling) return; + + setState(() { + _isInstalling = true; + _lastInstallDevice = device; + _lastInstallResult = null; + }); + + // Capture pending path before any state mutation. + final pathToClean = pending.apkPath; + + AdbInstallResult result; + try { + result = await installer(device, pending); + } catch (e) { + result = AdbInstallResult(exitCode: -1, stdout: '', stderr: e.toString()); + } + + // Cleanup must happen regardless of whether the shell is still mounted. + _cleanupPath(pathToClean); + + if (!mounted) return; + setState(() { + _pendingInstall = null; + _isInstalling = false; + _lastInstallResult = result; + }); } - void _handleInstallTargetSelected(AdbDevice device, PendingInstall pending) { - widget.onInstallTargetSelected?.call(device, pending); + void _handlePendingInstallCancelled() { + final pending = _pendingInstall; + if (pending == null) return; + setState(() => _pendingInstall = null); + _cleanupPendingInstall(pending); + } + + void _cleanupPendingInstall(PendingInstall pending) { + _cleanupPath(pending.apkPath); + } + + void _cleanupPath(String path) { + final cleaner = widget.artifactCleaner; + if (cleaner != null) { + unawaited(_runCleanup(cleaner, path)); + } + } + + Future _runCleanup(ArtifactCleaner cleaner, String path) async { + try { + await cleaner(path); + } catch (_) { + // Cleanup is best-effort; app teardown and navigation should continue. + } } List<_ShellPage> get _pages => [ @@ -65,6 +148,7 @@ class _AppSokShellState extends State { buildLoader: widget.buildLoader, artifactDownloader: widget.artifactDownloader, artifactStager: widget.artifactStager, + artifactCleaner: widget.artifactCleaner, onInstallRequested: _handleInstallRequested, ), ), @@ -74,9 +158,14 @@ class _AppSokShellState extends State { selectedIcon: Icons.usb, child: DevicesPage( pendingInstall: _pendingInstall, - onInstallTargetSelected: widget.onInstallTargetSelected == null + onPendingInstallCancelled: + _isInstalling ? null : _handlePendingInstallCancelled, + installer: widget.installer == null ? null - : _handleInstallTargetSelected, + : _handleDeviceInstallRequested, + isInstalling: _isInstalling, + lastInstallDevice: _lastInstallDevice, + lastInstallResult: _lastInstallResult, ), ), const _ShellPage( diff --git a/lib/src/features/builds/builds_page.dart b/lib/src/features/builds/builds_page.dart index 96ec4ef..ba532c8 100644 --- a/lib/src/features/builds/builds_page.dart +++ b/lib/src/features/builds/builds_page.dart @@ -12,7 +12,11 @@ typedef JenkinsBuildLoader = Future> Function(JenkinsJob job); typedef InstallRequestHandler = - void Function(JenkinsBuild build, BuildArtifact artifact, StagedApk? stagedApk); + void Function( + JenkinsBuild build, + BuildArtifact artifact, + StagedApk? stagedApk, + ); /// Returns a [DownloadTask] that emits [DownloadProgressEvent]s carrying both /// chunk bytes and cumulative progress. The task can be cancelled mid-stream. @@ -26,6 +30,8 @@ typedef ArtifactStager = required Stream> byteStream, }); +typedef ArtifactCleaner = Future Function(String path); + enum _JobLoadState { loading, loaded, empty, forbidden, failure } enum _BuildLoadState { loading, loaded, empty, forbidden, failure } @@ -38,6 +44,7 @@ class BuildsPage extends StatefulWidget { this.onInstallRequested, this.artifactDownloader, this.artifactStager, + this.artifactCleaner, }); final JenkinsJobLoader? jobLoader; @@ -45,6 +52,7 @@ class BuildsPage extends StatefulWidget { final InstallRequestHandler? onInstallRequested; final ArtifactDownloader? artifactDownloader; final ArtifactStager? artifactStager; + final ArtifactCleaner? artifactCleaner; @override State createState() => _BuildsPageState(); @@ -117,6 +125,7 @@ class _BuildsPageState extends State { onInstallRequested: widget.onInstallRequested, artifactDownloader: widget.artifactDownloader, artifactStager: widget.artifactStager, + artifactCleaner: widget.artifactCleaner, onBack: () => setState(() => _selectedJob = null), ) : _buildJobSelector(context), @@ -199,6 +208,7 @@ class _JobSelected extends StatefulWidget { this.onInstallRequested, this.artifactDownloader, this.artifactStager, + this.artifactCleaner, }); final JenkinsJob job; @@ -207,6 +217,7 @@ class _JobSelected extends StatefulWidget { final InstallRequestHandler? onInstallRequested; final ArtifactDownloader? artifactDownloader; final ArtifactStager? artifactStager; + final ArtifactCleaner? artifactCleaner; @override State<_JobSelected> createState() => _JobSelectedState(); @@ -382,6 +393,7 @@ class _JobSelectedState extends State<_JobSelected> { onInstallRequested: widget.onInstallRequested, artifactDownloader: widget.artifactDownloader, artifactStager: widget.artifactStager, + artifactCleaner: widget.artifactCleaner, ), ), ], @@ -502,6 +514,7 @@ class _InstallCta extends StatefulWidget { this.onInstallRequested, this.artifactDownloader, this.artifactStager, + this.artifactCleaner, }); final JenkinsBuild item; @@ -509,6 +522,7 @@ class _InstallCta extends StatefulWidget { final InstallRequestHandler? onInstallRequested; final ArtifactDownloader? artifactDownloader; final ArtifactStager? artifactStager; + final ArtifactCleaner? artifactCleaner; @override State<_InstallCta> createState() => _InstallCtaState(); @@ -523,10 +537,14 @@ class _InstallCtaState extends State<_InstallCta> { DownloadTask? _task; StreamSubscription? _eventSub; StreamController>? _byteController; + bool _handoffRequested = false; @override void dispose() { _cancelDownload(); + if (!_handoffRequested) { + _cleanupStagedApk(); + } super.dispose(); } @@ -535,6 +553,9 @@ class _InstallCtaState extends State<_InstallCta> { final stager = widget.artifactStager; if (downloader == null || stager == null) return; + _cleanupStagedApk(); + _handoffRequested = false; + final task = downloader(widget.item, widget.artifact); final byteController = StreamController>(); @@ -616,6 +637,23 @@ class _InstallCtaState extends State<_InstallCta> { _byteController = null; } + void _cleanupStagedApk() { + final staged = _stagedApk; + _stagedApk = null; + final cleaner = widget.artifactCleaner; + if (staged != null && cleaner != null) { + unawaited(_cleanStagedPath(cleaner, staged.path)); + } + } + + Future _cleanStagedPath(ArtifactCleaner cleaner, String path) async { + try { + await cleaner(path); + } catch (_) { + // Cleanup is best-effort; the install flow should not fail during dispose. + } + } + void _cancel() { _cancelDownload(); if (!mounted) return; @@ -691,10 +729,10 @@ class _InstallCtaState extends State<_InstallCta> { onPressed: widget.onInstallRequested == null ? null : () => widget.onInstallRequested!( - widget.item, - widget.artifact, - null, - ), + widget.item, + widget.artifact, + null, + ), icon: const Icon(Icons.install_mobile), label: const Text('설치'), ), @@ -776,13 +814,16 @@ class _InstallCtaState extends State<_InstallCta> { const SizedBox(width: 12), ElevatedButton.icon( key: const ValueKey('install-cta-button'), - onPressed: widget.onInstallRequested == null + onPressed: widget.onInstallRequested == null || _stagedApk == null ? null - : () => widget.onInstallRequested!( + : () { + _handoffRequested = true; + widget.onInstallRequested!( widget.item, widget.artifact, _stagedApk, - ), + ); + }, icon: const Icon(Icons.install_mobile), label: const Text('설치'), ), diff --git a/lib/src/features/devices/devices_page.dart b/lib/src/features/devices/devices_page.dart index a524bf8..77f7ccc 100644 --- a/lib/src/features/devices/devices_page.dart +++ b/lib/src/features/devices/devices_page.dart @@ -2,19 +2,28 @@ import 'package:flutter/material.dart'; import '../../models/adb_device.dart'; import '../../models/pending_install.dart'; +import '../../services/adb_service.dart'; -typedef PendingInstallSelectionHandler = - void Function(AdbDevice device, PendingInstall pendingInstall); +typedef PendingInstallRunner = + Future Function(AdbDevice device, PendingInstall pending); class DevicesPage extends StatelessWidget { const DevicesPage({ super.key, this.pendingInstall, - this.onInstallTargetSelected, + this.onPendingInstallCancelled, + this.installer, + this.isInstalling = false, + this.lastInstallDevice, + this.lastInstallResult, }); final PendingInstall? pendingInstall; - final PendingInstallSelectionHandler? onInstallTargetSelected; + final VoidCallback? onPendingInstallCancelled; + final PendingInstallRunner? installer; + final bool isInstalling; + final AdbDevice? lastInstallDevice; + final AdbInstallResult? lastInstallResult; @override Widget build(BuildContext context) { @@ -26,7 +35,17 @@ class DevicesPage extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (pendingInstall != null) ...[ - _PendingInstallBanner(pendingInstall: pendingInstall!), + _PendingInstallBanner( + pendingInstall: pendingInstall!, + onCancel: isInstalling ? null : onPendingInstallCancelled, + ), + const SizedBox(height: 12), + ], + if (lastInstallResult != null && lastInstallDevice != null) ...[ + _InstallResultBanner( + device: lastInstallDevice!, + result: lastInstallResult!, + ), const SizedBox(height: 12), ], Wrap( @@ -76,7 +95,8 @@ class DevicesPage extends StatelessWidget { return _DeviceTile( device: devices[index], pendingInstall: pendingInstall, - onInstallTargetSelected: onInstallTargetSelected, + isInstalling: isInstalling, + installer: installer, ); }, ), @@ -91,18 +111,23 @@ class _DeviceTile extends StatelessWidget { const _DeviceTile({ required this.device, this.pendingInstall, - this.onInstallTargetSelected, + required this.isInstalling, + this.installer, }); final AdbDevice device; final PendingInstall? pendingInstall; - final PendingInstallSelectionHandler? onInstallTargetSelected; + final bool isInstalling; + final PendingInstallRunner? installer; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final isReady = device.state == 'device'; + final canInstall = + pendingInstall != null && installer != null && isReady && !isInstalling; + return Material( color: Colors.white, shape: RoundedRectangleBorder( @@ -150,11 +175,13 @@ class _DeviceTile extends StatelessWidget { ), const SizedBox(width: 4), _CompactIconButton( - tooltip: '설치 대상', - icon: Icons.add_to_queue, - onPressed: pendingInstall == null || onInstallTargetSelected == null - ? null - : () => onInstallTargetSelected!(device, pendingInstall!), + tooltip: '설치', + icon: isInstalling + ? Icons.hourglass_top + : Icons.install_mobile, + onPressed: canInstall + ? () => installer!(device, pendingInstall!) + : null, ), ], ), @@ -221,9 +248,10 @@ class _DeviceStateChip extends StatelessWidget { } class _PendingInstallBanner extends StatelessWidget { - const _PendingInstallBanner({required this.pendingInstall}); + const _PendingInstallBanner({required this.pendingInstall, this.onCancel}); final PendingInstall pendingInstall; + final VoidCallback? onCancel; @override Widget build(BuildContext context) { @@ -280,6 +308,89 @@ class _PendingInstallBanner extends StatelessWidget { ], ), ), + if (onCancel != null) ...[ + const SizedBox(width: 8), + IconButton( + key: const ValueKey('pending-install-cancel-button'), + tooltip: '설치 대기 취소', + onPressed: onCancel, + icon: const Icon(Icons.close), + ), + ], + ], + ), + ), + ); + } +} + +class _InstallResultBanner extends StatelessWidget { + const _InstallResultBanner({ + required this.device, + required this.result, + }); + + final AdbDevice device; + final AdbInstallResult result; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isSuccess = result.succeeded; + final color = isSuccess ? colorScheme.primary : colorScheme.error; + final output = [ + if (result.stdout.trim().isNotEmpty) result.stdout.trim(), + if (result.stderr.trim().isNotEmpty) result.stderr.trim(), + ].join('\n'); + + return Material( + key: const ValueKey('install-result-banner'), + color: color.withValues(alpha: 0.08), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: color.withValues(alpha: 0.34)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + isSuccess ? Icons.check_circle_outline : Icons.error_outline, + color: color, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + key: const ValueKey('install-result-label'), + isSuccess + ? '${device.displayName}에 설치 성공' + : '${device.displayName}에 설치 실패 (exit ${result.exitCode})', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + color: color, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (output.isNotEmpty) + Text( + key: const ValueKey('install-result-output'), + output, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 11, + fontFamily: 'monospace', + ), + maxLines: 4, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), ], ), ), diff --git a/test/builds_page_test.dart b/test/builds_page_test.dart index 74c5acb..eaad6b4 100644 --- a/test/builds_page_test.dart +++ b/test/builds_page_test.dart @@ -616,7 +616,10 @@ void main() { }); // Append isComplete event after all chunks final withComplete = events.transform( - StreamTransformer.fromHandlers( + StreamTransformer< + DownloadProgressEvent, + DownloadProgressEvent + >.fromHandlers( handleData: (event, sink) => sink.add(event), handleDone: (sink) { sink.add( @@ -700,14 +703,11 @@ void main() { await navigateToCtaWithDownloader( tester, - downloader: taskDownloader( - () async* { - yield List.filled(512, 1); - await pauseCompleter.future; - yield List.filled(512, 2); - }, - totalBytes: 1024, - ), + downloader: taskDownloader(() async* { + yield List.filled(512, 1); + await pauseCompleter.future; + yield List.filled(512, 2); + }, totalBytes: 1024), stager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024); @@ -818,17 +818,18 @@ void main() { artifactDownloader: taskDownloader(() async* { yield List.filled(1024, 1); }), - artifactStager: ({ - required build, - required artifact, - required byteStream, - }) async { - await byteStream.drain(); - return const StagedApk( - path: '/tmp/app-release.apk', - sizeBytes: 1024, - ); - }, + artifactStager: + ({ + required build, + required artifact, + required byteStream, + }) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/app-release.apk', + sizeBytes: 1024, + ); + }, onInstallRequested: (build, artifact, staged) { receivedStaged = staged; }, @@ -899,19 +900,20 @@ void main() { await pauseCompleter.future; yield List.filled(256, 2); }), - artifactStager: ({ - required build, - required artifact, - required byteStream, - }) async { - try { - await byteStream.drain(); - } catch (e) { - stagerErrorCompleter.complete(e); - rethrow; - } - return const StagedApk(path: '/tmp/app.apk', sizeBytes: 512); - }, + artifactStager: + ({ + required build, + required artifact, + required byteStream, + }) async { + try { + await byteStream.drain(); + } catch (e) { + stagerErrorCompleter.complete(e); + rethrow; + } + return const StagedApk(path: '/tmp/app.apk', sizeBytes: 512); + }, ), ), ); @@ -925,7 +927,10 @@ void main() { await tester.pump(); await tester.pump(); - expect(find.byKey(const ValueKey('download-progress-bar')), findsOneWidget); + expect( + find.byKey(const ValueKey('download-progress-bar')), + findsOneWidget, + ); // Navigate back — disposes _InstallCta while download is in progress await tester.tap(find.byIcon(Icons.arrow_back)); @@ -968,17 +973,14 @@ void main() { artifactDownloader: taskDownloader(() async* { yield List.filled(1024, 1); }), - artifactStager: ({ - required build, - required artifact, - required byteStream, - }) async { - await byteStream.drain(); - return const StagedApk( - path: '/tmp/staged/app-release.apk', - sizeBytes: 1024, - ); - }, + artifactStager: + ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/staged/app-release.apk', + sizeBytes: 1024, + ); + }, onInstallRequested: (build, artifact, staged) { handoffBuild = build; handoffArtifact = artifact; @@ -1008,6 +1010,108 @@ void main() { expect(handoffStaged?.sizeBytes, 1024); }); + testWidgets('cleans verified staged apk when abandoned before handoff', ( + tester, + ) async { + final cleanedPaths = []; + + await tester.pumpWidget( + _wrap( + BuildsPage( + jobLoader: () async => _sampleJobs(), + buildLoader: (_) async => [ + _makeBuild( + number: 7, + artifacts: const [ + BuildArtifact( + fileName: 'app-release.apk', + relativePath: 'outputs/app-release.apk', + ), + ], + ), + ], + artifactDownloader: taskDownloader(() async* { + yield List.filled(1024, 1); + }), + artifactStager: + ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/staged/app-release.apk', + sizeBytes: 1024, + ); + }, + artifactCleaner: (path) async => cleanedPaths.add(path), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('android-app')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('build-row-7'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('install-cta-button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('verified-label')), findsOneWidget); + + await tester.tap(find.byIcon(Icons.arrow_back)); + await tester.pumpAndSettle(); + + expect(cleanedPaths, ['/tmp/staged/app-release.apk']); + }); + + testWidgets('keeps verified staged apk after handoff owns it', ( + tester, + ) async { + final cleanedPaths = []; + + await tester.pumpWidget( + _wrap( + BuildsPage( + jobLoader: () async => _sampleJobs(), + buildLoader: (_) async => [ + _makeBuild( + number: 7, + artifacts: const [ + BuildArtifact( + fileName: 'app-release.apk', + relativePath: 'outputs/app-release.apk', + ), + ], + ), + ], + artifactDownloader: taskDownloader(() async* { + yield List.filled(1024, 1); + }), + artifactStager: + ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/staged/app-release.apk', + sizeBytes: 1024, + ); + }, + artifactCleaner: (path) async => cleanedPaths.add(path), + onInstallRequested: (_, _, _) {}, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('android-app')); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('build-row-7'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('install-cta-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('install-cta-button'))); + + await tester.pumpWidget(_wrap(const SizedBox.shrink())); + await tester.pumpAndSettle(); + + expect(cleanedPaths, isEmpty); + }); + testWidgets( 'renders selected job view without overflow at compact viewport', (tester) async { diff --git a/test/widget_test.dart b/test/widget_test.dart index f0e6844..5eef0a5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -6,6 +8,8 @@ import 'package:appsok/src/features/app_shell.dart'; import 'package:appsok/src/models/adb_device.dart'; import 'package:appsok/src/models/jenkins_build.dart'; import 'package:appsok/src/models/pending_install.dart'; +import 'package:appsok/src/features/builds/builds_page.dart' show ArtifactCleaner; +import 'package:appsok/src/services/adb_service.dart'; import 'package:appsok/src/services/artifact_staging_service.dart'; import 'package:appsok/src/services/jenkins_client.dart'; import 'package:appsok/src/theme/app_theme.dart'; @@ -96,17 +100,14 @@ void main() { })(); return DownloadTask.fromStream(events); }, - artifactStager: ({ - required build, - required artifact, - required byteStream, - }) async { - await byteStream.drain(); - return const StagedApk( - path: '/tmp/staged/app-release.apk', - sizeBytes: 1024, - ); - }, + artifactStager: + ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/staged/app-release.apk', + sizeBytes: 1024, + ); + }, ), ), ); @@ -185,17 +186,14 @@ void main() { })(); return DownloadTask.fromStream(events); }, - artifactStager: ({ - required build, - required artifact, - required byteStream, - }) async { - await byteStream.drain(); - return const StagedApk( - path: '/tmp/staged/app-release.apk', - sizeBytes: 2 * 1024 * 1024, - ); - }, + artifactStager: + ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/staged/app-release.apk', + sizeBytes: 2 * 1024 * 1024, + ); + }, ), ), ); @@ -209,21 +207,21 @@ void main() { await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('pending-install-banner')), findsOneWidget); + expect( + find.byKey(const ValueKey('pending-install-banner')), + findsOneWidget, + ); expect(find.text('app-release.apk'), findsOneWidget); // Size label should show MB expect(find.textContaining('MB'), findsOneWidget); // Local APK path must be visible - expect( - find.byKey(const ValueKey('pending-install-path')), - findsOneWidget, - ); + expect(find.byKey(const ValueKey('pending-install-path')), findsOneWidget); expect(find.text('/tmp/staged/app-release.apk'), findsOneWidget); }); - testWidgets('keeps install target disabled when shell has no target callback', ( - tester, - ) async { + testWidgets('cancels pending install and cleans staged apk', (tester) async { + final cleanedPaths = []; + await tester.pumpWidget( MaterialApp( theme: AppTheme.light(), @@ -264,18 +262,15 @@ void main() { })(); return DownloadTask.fromStream(events); }, - artifactStager: ({ - required build, - required artifact, - required byteStream, - }) async { - await byteStream.drain(); - return const StagedApk( - path: '/tmp/staged/app-release.apk', - sizeBytes: 1024, - ); - }, - // No onInstallTargetSelected — default shell path + artifactStager: + ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/staged/app-release.apk', + sizeBytes: 1024, + ); + }, + artifactCleaner: (path) async => cleanedPaths.add(path), ), ), ); @@ -289,85 +284,107 @@ void main() { await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); - // Devices page is active with pending install banner - expect(find.byKey(const ValueKey('pending-install-banner')), findsOneWidget); - - // "설치 대상" button must be disabled (onPressed == null) when no callback - final installButtons = tester.widgetList( - find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치 대상'), + expect( + find.byKey(const ValueKey('pending-install-banner')), + findsOneWidget, ); - for (final btn in installButtons) { - expect(btn.onPressed, isNull); - } + + await tester.tap( + find.byKey(const ValueKey('pending-install-cancel-button')), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing); + expect(cleanedPaths, ['/tmp/staged/app-release.apk']); }); - testWidgets('passes selected device and pending apk to install target callback', ( - tester, - ) async { - AdbDevice? callbackDevice; - PendingInstall? callbackPending; - - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(), - home: AppSokShell( - jobLoader: () async => [ - JenkinsJob( - name: 'android-app', - url: Uri.parse('https://jenkins.example/job/android-app/'), - ), - ], - buildLoader: (_) async => [ - JenkinsBuild( - number: 7, - jobName: 'android-app', - url: Uri.parse('https://jenkins.example/job/android-app/7/'), - startedAt: null, - result: 'SUCCESS', - artifacts: const [ - BuildArtifact( - fileName: 'app-release.apk', - relativePath: 'outputs/app-release.apk', - ), - ], - requestedBy: 'toki', - ), - ], - artifactDownloader: (build, artifact) { - var received = 0; - final events = (() async* { - final chunk = List.filled(1024, 1); - received += chunk.length; - yield DownloadProgressEvent( - receivedBytes: received, - totalBytes: 1024, - chunkBytes: chunk, - isComplete: true, - ); - })(); - return DownloadTask.fromStream(events); - }, - artifactStager: ({ - required build, - required artifact, - required byteStream, - }) async { - await byteStream.drain(); - return const StagedApk( - path: '/tmp/staged/app-release.apk', - sizeBytes: 1024, - ); - }, - onInstallTargetSelected: (device, pending) { - callbackDevice = device; - callbackPending = pending; - }, + testWidgets( + 'keeps install button disabled when shell has no installer', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: AppSokShell( + jobLoader: () async => [ + JenkinsJob( + name: 'android-app', + url: Uri.parse('https://jenkins.example/job/android-app/'), + ), + ], + buildLoader: (_) async => [ + JenkinsBuild( + number: 7, + jobName: 'android-app', + url: Uri.parse('https://jenkins.example/job/android-app/7/'), + startedAt: null, + result: 'SUCCESS', + artifacts: const [ + BuildArtifact( + fileName: 'app-release.apk', + relativePath: 'outputs/app-release.apk', + ), + ], + requestedBy: 'toki', + ), + ], + artifactDownloader: (build, artifact) { + var received = 0; + final events = (() async* { + final chunk = List.filled(1024, 1); + received += chunk.length; + yield DownloadProgressEvent( + receivedBytes: received, + totalBytes: 1024, + chunkBytes: chunk, + isComplete: true, + ); + })(); + return DownloadTask.fromStream(events); + }, + artifactStager: + ({ + required build, + required artifact, + required byteStream, + }) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/staged/app-release.apk', + sizeBytes: 1024, + ); + }, + // No installer — install buttons must be disabled + ), ), - ), - ); - await tester.pumpAndSettle(); + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('android-app').first); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('build-row-7'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('install-cta-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('install-cta-button'))); + await tester.pumpAndSettle(); - // Navigate builds → select job → select build → download → handoff to devices + expect( + find.byKey(const ValueKey('pending-install-banner')), + findsOneWidget, + ); + + final installButtons = tester.widgetList( + find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'), + ); + for (final btn in installButtons) { + expect(btn.onPressed, isNull); + } + }, + ); + + // ── UILC: Install lifecycle regression ────────────────────────── + + Future navigateToDevicesWithPending(WidgetTester tester) async { + await tester.pumpAndSettle(); await tester.tap(find.text('android-app').first); await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('build-row-7'))); @@ -376,15 +393,202 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); + } + + Widget shellWithInstaller({ + required Future Function(AdbDevice, PendingInstall) + installerFn, + ArtifactCleaner? artifactCleaner, + }) { + return MaterialApp( + theme: AppTheme.light(), + home: AppSokShell( + jobLoader: () async => [ + JenkinsJob( + name: 'android-app', + url: Uri.parse('https://jenkins.example/job/android-app/'), + ), + ], + buildLoader: (_) async => [ + JenkinsBuild( + number: 7, + jobName: 'android-app', + url: Uri.parse('https://jenkins.example/job/android-app/7/'), + startedAt: null, + result: 'SUCCESS', + artifacts: const [ + BuildArtifact( + fileName: 'app-release.apk', + relativePath: 'outputs/app-release.apk', + ), + ], + requestedBy: 'toki', + ), + ], + artifactDownloader: (build, artifact) { + var received = 0; + final events = (() async* { + final chunk = List.filled(1024, 1); + received += chunk.length; + yield DownloadProgressEvent( + receivedBytes: received, + totalBytes: 1024, + chunkBytes: chunk, + isComplete: true, + ); + })(); + return DownloadTask.fromStream(events); + }, + artifactStager: + ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/staged/app-release.apk', + sizeBytes: 1024, + ); + }, + installer: installerFn, + artifactCleaner: artifactCleaner, + ), + ); + } + + testWidgets('non-ready device keeps install button disabled', (tester) async { + await tester.pumpWidget( + shellWithInstaller( + installerFn: (device, pending) async => const AdbInstallResult( + exitCode: 0, + stdout: 'Success', + stderr: '', + ), + ), + ); + await navigateToDevicesWithPending(tester); + + // ZX1G22K7Q9 is unauthorized — its install button must be disabled + final allInstallButtons = tester.widgetList( + find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'), + ).toList(); + // 3 devices: 2 ready (enabled), 1 unauthorized (disabled) + expect(allInstallButtons.where((b) => b.onPressed == null), hasLength(1)); + }); + + testWidgets('ready device runs installer and shows success output', ( + tester, + ) async { + final cleanedPaths = []; + await tester.pumpWidget( + shellWithInstaller( + installerFn: (device, pending) async => const AdbInstallResult( + exitCode: 0, + stdout: 'Performing Streamed Install\nSuccess', + stderr: '', + ), + artifactCleaner: (path) async => cleanedPaths.add(path), + ), + ); + await navigateToDevicesWithPending(tester); - // Now on devices page — tap "설치 대상" on first ready device (R5CT90A1B2C) final installButtons = find.byWidgetPredicate( - (w) => w is IconButton && w.tooltip == '설치 대상', + (w) => w is IconButton && w.tooltip == '설치', ); await tester.tap(installButtons.first); + await tester.pumpAndSettle(); - expect(callbackDevice?.serial, 'R5CT90A1B2C'); - expect(callbackPending?.apkPath, '/tmp/staged/app-release.apk'); + expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget); + expect(find.byKey(const ValueKey('install-result-output')), findsOneWidget); + expect(find.textContaining('Success'), findsWidgets); + }); + + testWidgets('cleans staged apk once after successful install result', ( + tester, + ) async { + final cleanedPaths = []; + await tester.pumpWidget( + shellWithInstaller( + installerFn: (device, pending) async => const AdbInstallResult( + exitCode: 0, + stdout: 'Success', + stderr: '', + ), + artifactCleaner: (path) async => cleanedPaths.add(path), + ), + ); + await navigateToDevicesWithPending(tester); + + final installButtons = find.byWidgetPredicate( + (w) => w is IconButton && w.tooltip == '설치', + ); + await tester.tap(installButtons.first); + await tester.pumpAndSettle(); + + expect(cleanedPaths, ['/tmp/staged/app-release.apk']); + // Result banner must survive after cleanup + expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget); + // Pending banner must be gone + expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing); + }); + + testWidgets( + 'cleans staged apk once after failed install result while keeping stderr visible', + (tester) async { + final cleanedPaths = []; + await tester.pumpWidget( + shellWithInstaller( + installerFn: (device, pending) async => const AdbInstallResult( + exitCode: 1, + stdout: '', + stderr: 'INSTALL_FAILED_VERSION_DOWNGRADE', + ), + artifactCleaner: (path) async => cleanedPaths.add(path), + ), + ); + await navigateToDevicesWithPending(tester); + + final installButtons = find.byWidgetPredicate( + (w) => w is IconButton && w.tooltip == '설치', + ); + await tester.tap(installButtons.first); + await tester.pumpAndSettle(); + + expect(cleanedPaths, ['/tmp/staged/app-release.apk']); + expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget); + expect( + find.textContaining('INSTALL_FAILED_VERSION_DOWNGRADE'), + findsOneWidget, + ); + }, + ); + + testWidgets('does not clean staged apk before install future completes', ( + tester, + ) async { + final cleanedPaths = []; + final completer = Completer(); + + await tester.pumpWidget( + shellWithInstaller( + installerFn: (device, pending) => completer.future, + artifactCleaner: (path) async => cleanedPaths.add(path), + ), + ); + await navigateToDevicesWithPending(tester); + + final installButtons = find.byWidgetPredicate( + (w) => w is IconButton && w.tooltip == '설치', + ); + await tester.tap(installButtons.first); + await tester.pump(); // start install, do not settle + + // Cleanup must NOT have been called yet + expect(cleanedPaths, isEmpty); + + completer.complete( + const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''), + ); + await tester.pumpAndSettle(); + + expect(cleanedPaths, ['/tmp/staged/app-release.apk']); }); testWidgets( @@ -435,17 +639,18 @@ void main() { })(); return DownloadTask.fromStream(events); }, - artifactStager: ({ - required build, - required artifact, - required byteStream, - }) async { - await byteStream.drain(); - return const StagedApk( - path: '/tmp/staged/app.apk', - sizeBytes: 1024, - ); - }, + artifactStager: + ({ + required build, + required artifact, + required byteStream, + }) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/staged/app.apk', + sizeBytes: 1024, + ); + }, ), ), ); @@ -459,11 +664,93 @@ void main() { await tester.tap(find.byKey(const ValueKey('install-cta-button'))); await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('pending-install-banner')), findsOneWidget); + expect( + find.byKey(const ValueKey('pending-install-banner')), + findsOneWidget, + ); expect(tester.takeException(), isNull); }, ); + // ── REVIEW_UILC: lifecycle edge regression ────────────────────── + + testWidgets( + 'cleans staged apk when install completes after leaving devices page', + (tester) async { + final cleanedPaths = []; + final completer = Completer(); + + await tester.pumpWidget( + shellWithInstaller( + installerFn: (device, pending) => completer.future, + artifactCleaner: (path) async => cleanedPaths.add(path), + ), + ); + await navigateToDevicesWithPending(tester); + + // Tap install on first ready device + final installButtons = find.byWidgetPredicate( + (w) => w is IconButton && w.tooltip == '설치', + ); + await tester.tap(installButtons.first); + await tester.pump(); // start install, future pending + + // Navigate away from devices page while install is in flight + final buildsNav = find.descendant( + of: find.byType(NavigationRail), + matching: find.text('빌드'), + ); + await tester.tap(buildsNav); + await tester.pump(); + + // Complete the install future after DevicesPage is no longer visible + completer.complete( + const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''), + ); + await tester.pumpAndSettle(); + + // Cleanup must have been called exactly once — no Flutter exception + expect(tester.takeException(), isNull); + expect(cleanedPaths, ['/tmp/staged/app-release.apk']); + }, + ); + + testWidgets('shows failure and cleans staged apk when installer throws', ( + tester, + ) async { + final cleanedPaths = []; + + await tester.pumpWidget( + shellWithInstaller( + installerFn: (device, pending) async => + throw Exception('adb missing'), + artifactCleaner: (path) async => cleanedPaths.add(path), + ), + ); + await navigateToDevicesWithPending(tester); + + final installButtons = find.byWidgetPredicate( + (w) => w is IconButton && w.tooltip == '설치', + ); + await tester.tap(installButtons.first); + await tester.pumpAndSettle(); + + // No Flutter exception propagated + expect(tester.takeException(), isNull); + // Cleanup called once + expect(cleanedPaths, ['/tmp/staged/app-release.apk']); + // Failure result banner shown with exception text + expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget); + expect(find.textContaining('adb missing'), findsOneWidget); + // Install buttons no longer stuck — hourglass_top icon must not appear + expect( + find.byWidgetPredicate( + (w) => w is Icon && w.icon == Icons.hourglass_top, + ), + findsNothing, + ); + }); + testWidgets( 'switches between all shell destinations with status placeholders', (WidgetTester tester) async {