feat(install): 설치 lifecycle cleanup을 처리한다
ADB 설치 종료 후 staged APK를 정리하고 결과 표시를 유지해야 한다. Artifact handoff 완료 상태와 USB 설치 cleanup 책임도 로드맵에 반영한다.
This commit is contained in:
parent
c98f70737e
commit
64f9fb3403
13 changed files with 1841 additions and 218 deletions
|
|
@ -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 수명주기
|
||||
|
|
|
|||
|
|
@ -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 선택
|
||||
- 확인 필요: 없음
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
<!-- task=m-usb-install/01_install_lifecycle_cleanup plan=1 tag=UILC -->
|
||||
|
||||
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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`를 작성한다.
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
<!-- task=m-usb-install/01_install_lifecycle_cleanup plan=1 tag=REVIEW_UILC -->
|
||||
|
||||
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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<void>`)에서 새 `AdbInstaller`(= `Future<AdbInstallResult>`) typedef로 분리했다. `DevicesPage.installer`는 shell wrapper(`Future<void>`)를 받는 `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`이므로 런타임 완료 이벤트 메타데이터를 보고한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,265 @@
|
|||
<!-- task=m-usb-install/01_install_lifecycle_cleanup plan=1 tag=UILC -->
|
||||
|
||||
# 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<AdbInstallResult> Function(AdbDevice, PendingInstall)` 형태의 installer를 주입한다. 화면은 설치 중, 성공, 실패 결과를 표시하고 result object를 state에 보존한다. ready가 아닌 device는 설치 버튼을 비활성화한다.
|
||||
|
||||
```dart
|
||||
typedef PendingInstallRunner =
|
||||
Future<AdbInstallResult> 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<AdbInstallResult> 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
<!-- task=m-usb-install/01_install_lifecycle_cleanup plan=1 tag=REVIEW_UILC -->
|
||||
|
||||
# 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<void> _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<AdbInstallResult>`와 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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<AdbInstallResult> 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<AppSokShell> createState() => _AppSokShellState();
|
||||
|
|
@ -33,12 +41,27 @@ class _AppSokShellState extends State<AppSokShell> {
|
|||
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<AppSokShell> {
|
|||
jobName: build.jobName,
|
||||
sizeBytes: staged.sizeBytes,
|
||||
);
|
||||
_lastInstallResult = null;
|
||||
_selectedIndex = 1;
|
||||
});
|
||||
if (previous != null && previous.apkPath != staged.path) {
|
||||
_cleanupPendingInstall(previous);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<AppSokShell> {
|
|||
buildLoader: widget.buildLoader,
|
||||
artifactDownloader: widget.artifactDownloader,
|
||||
artifactStager: widget.artifactStager,
|
||||
artifactCleaner: widget.artifactCleaner,
|
||||
onInstallRequested: _handleInstallRequested,
|
||||
),
|
||||
),
|
||||
|
|
@ -74,9 +158,14 @@ class _AppSokShellState extends State<AppSokShell> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,11 @@ typedef JenkinsBuildLoader =
|
|||
Future<List<JenkinsBuild>> 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<List<int>> byteStream,
|
||||
});
|
||||
|
||||
typedef ArtifactCleaner = Future<void> 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<BuildsPage> createState() => _BuildsPageState();
|
||||
|
|
@ -117,6 +125,7 @@ class _BuildsPageState extends State<BuildsPage> {
|
|||
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<DownloadProgressEvent>? _eventSub;
|
||||
StreamController<List<int>>? _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<List<int>>();
|
||||
|
||||
|
|
@ -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<void> _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('설치'),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<void> 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -616,7 +616,10 @@ void main() {
|
|||
});
|
||||
// Append isComplete event after all chunks
|
||||
final withComplete = events.transform(
|
||||
StreamTransformer<DownloadProgressEvent, DownloadProgressEvent>.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<int>.filled(512, 1);
|
||||
await pauseCompleter.future;
|
||||
yield List<int>.filled(512, 2);
|
||||
},
|
||||
totalBytes: 1024,
|
||||
),
|
||||
downloader: taskDownloader(() async* {
|
||||
yield List<int>.filled(512, 1);
|
||||
await pauseCompleter.future;
|
||||
yield List<int>.filled(512, 2);
|
||||
}, totalBytes: 1024),
|
||||
stager: ({required build, required artifact, required byteStream}) async {
|
||||
await byteStream.drain<void>();
|
||||
return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024);
|
||||
|
|
@ -818,17 +818,18 @@ void main() {
|
|||
artifactDownloader: taskDownloader(() async* {
|
||||
yield List<int>.filled(1024, 1);
|
||||
}),
|
||||
artifactStager: ({
|
||||
required build,
|
||||
required artifact,
|
||||
required byteStream,
|
||||
}) async {
|
||||
await byteStream.drain<void>();
|
||||
return const StagedApk(
|
||||
path: '/tmp/app-release.apk',
|
||||
sizeBytes: 1024,
|
||||
);
|
||||
},
|
||||
artifactStager:
|
||||
({
|
||||
required build,
|
||||
required artifact,
|
||||
required byteStream,
|
||||
}) async {
|
||||
await byteStream.drain<void>();
|
||||
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<int>.filled(256, 2);
|
||||
}),
|
||||
artifactStager: ({
|
||||
required build,
|
||||
required artifact,
|
||||
required byteStream,
|
||||
}) async {
|
||||
try {
|
||||
await byteStream.drain<void>();
|
||||
} 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<void>();
|
||||
} 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<int>.filled(1024, 1);
|
||||
}),
|
||||
artifactStager: ({
|
||||
required build,
|
||||
required artifact,
|
||||
required byteStream,
|
||||
}) async {
|
||||
await byteStream.drain<void>();
|
||||
return const StagedApk(
|
||||
path: '/tmp/staged/app-release.apk',
|
||||
sizeBytes: 1024,
|
||||
);
|
||||
},
|
||||
artifactStager:
|
||||
({required build, required artifact, required byteStream}) async {
|
||||
await byteStream.drain<void>();
|
||||
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 = <String>[];
|
||||
|
||||
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<int>.filled(1024, 1);
|
||||
}),
|
||||
artifactStager:
|
||||
({required build, required artifact, required byteStream}) async {
|
||||
await byteStream.drain<void>();
|
||||
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 = <String>[];
|
||||
|
||||
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<int>.filled(1024, 1);
|
||||
}),
|
||||
artifactStager:
|
||||
({required build, required artifact, required byteStream}) async {
|
||||
await byteStream.drain<void>();
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<void>();
|
||||
return const StagedApk(
|
||||
path: '/tmp/staged/app-release.apk',
|
||||
sizeBytes: 1024,
|
||||
);
|
||||
},
|
||||
artifactStager:
|
||||
({required build, required artifact, required byteStream}) async {
|
||||
await byteStream.drain<void>();
|
||||
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<void>();
|
||||
return const StagedApk(
|
||||
path: '/tmp/staged/app-release.apk',
|
||||
sizeBytes: 2 * 1024 * 1024,
|
||||
);
|
||||
},
|
||||
artifactStager:
|
||||
({required build, required artifact, required byteStream}) async {
|
||||
await byteStream.drain<void>();
|
||||
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 = <String>[];
|
||||
|
||||
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<void>();
|
||||
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<void>();
|
||||
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<IconButton>(
|
||||
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<int>.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<void>();
|
||||
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<int>.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<void>();
|
||||
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<IconButton>(
|
||||
find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'),
|
||||
);
|
||||
for (final btn in installButtons) {
|
||||
expect(btn.onPressed, isNull);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── UILC: Install lifecycle regression ──────────────────────────
|
||||
|
||||
Future<void> 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<AdbInstallResult> 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<int>.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<void>();
|
||||
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<IconButton>(
|
||||
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 = <String>[];
|
||||
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 = <String>[];
|
||||
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 = <String>[];
|
||||
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 = <String>[];
|
||||
final completer = Completer<AdbInstallResult>();
|
||||
|
||||
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<void>();
|
||||
return const StagedApk(
|
||||
path: '/tmp/staged/app.apk',
|
||||
sizeBytes: 1024,
|
||||
);
|
||||
},
|
||||
artifactStager:
|
||||
({
|
||||
required build,
|
||||
required artifact,
|
||||
required byteStream,
|
||||
}) async {
|
||||
await byteStream.drain<void>();
|
||||
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 = <String>[];
|
||||
final completer = Completer<AdbInstallResult>();
|
||||
|
||||
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 = <String>[];
|
||||
|
||||
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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue