feat(install): 검증된 APK 설치 대상을 전달한다

다운로드 검증 후 디바이스 화면에서 대기 중인 APK와 선택 장치를 연결할 수 있어야 한다.
This commit is contained in:
toki 2026-06-10 18:21:00 +09:00
parent c081096ca2
commit c98f70737e
12 changed files with 1575 additions and 42 deletions

View file

@ -42,43 +42,48 @@ task=m-artifact-browser/05+04_install_handoff, plan=0, tag=INSTALL_HANDOFF
| 항목 | 완료 여부 |
|------|---------|
| [INSTALL_HANDOFF-1] `04+03_download_staging` 완료 산출물의 verified APK path를 받을 handoff model/callback을 정의한다. | [ ] |
| [INSTALL_HANDOFF-2] `AppSokShell`에서 handoff state를 보관하고 device page로 전달하거나 이동한다. | [ ] |
| [INSTALL_HANDOFF-3] `DevicesPage`가 pending APK 요약과 USB 설치 흐름 진입 상태를 표시하도록 widget test를 추가한다. | [ ] |
| [INSTALL_HANDOFF-1] `04+03_download_staging` 완료 산출물의 verified APK path를 받을 handoff model/callback을 정의한다. | [x] |
| [INSTALL_HANDOFF-2] `AppSokShell`에서 handoff state를 보관하고 device page로 전달하거나 이동한다. | [x] |
| [INSTALL_HANDOFF-3] `DevicesPage`가 pending APK 요약과 USB 설치 흐름 진입 상태를 표시하도록 widget test를 추가한다. | [x] |
## 구현 체크리스트
- [ ] [INSTALL_HANDOFF-1] `04+03_download_staging` 완료 산출물의 verified APK path를 받을 handoff model/callback을 정의한다.
- [ ] [INSTALL_HANDOFF-2] `AppSokShell`에서 handoff state를 보관하고 device page로 전달하거나 이동한다.
- [ ] [INSTALL_HANDOFF-3] `DevicesPage`가 pending APK 요약과 USB 설치 흐름 진입 상태를 표시하도록 widget test를 추가한다.
- [ ] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] [INSTALL_HANDOFF-1] `04+03_download_staging` 완료 산출물의 verified APK path를 받을 handoff model/callback을 정의한다.
- [x] [INSTALL_HANDOFF-2] `AppSokShell`에서 handoff state를 보관하고 device page로 전달하거나 이동한다.
- [x] [INSTALL_HANDOFF-3] `DevicesPage`가 pending APK 요약과 USB 설치 흐름 진입 상태를 표시하도록 widget test를 추가한다.
- [x] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_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-artifact-browser/05+04_install_handoff/`를 `agent-task/archive/YYYY/MM/m-artifact-browser/05+04_install_handoff/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-artifact-browser/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
- **INSTALL_HANDOFF-1 재사용**: plan은 verified APK handoff 계약을 새로 정의하거나 재사용한다고 했으나, `04+03_download_staging`이 이미 `InstallRequestHandler` 시그니처에 `StagedApk?`를 포함시키고 `PendingInstall` 모델과 `AppSokShell._handleInstallRequested`를 구현한 상태였다. 새로 정의 없이 기존 계약 재사용으로 확정했다.
- **INSTALL_HANDOFF-2 재사용**: `app_shell.dart`의 `_pages` getter, `_pendingInstall` 상태, `_handleInstallRequested`도 `04+03`에서 이미 구현되어 있었다. 추가 수정 없이 INSTALL_HANDOFF-2 범위를 완료로 처리했다.
- **INSTALL_HANDOFF-3 주력 범위**: 실제 구현 대상은 `DevicesPage` UI와 테스트였다. `_CompactIconButton.onPressed`를 `VoidCallback?`으로 변경하여 pending install 없을 때 "설치 대상" 버튼을 비활성화했다.
- **widget_test.dart import 정리**: `builds_page.dart` import가 불필요하여 제거했다. `DownloadTask`/`DownloadProgressEvent`는 `jenkins_client.dart`, `StagedApk`는 `artifact_staging_service.dart`에서 직접 import.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- **`_PendingInstallBanner`**: `DevicesPage` 상단에 독립 위젯으로 분리. `pending-install-banner`/`pending-install-filename` ValueKey 부여로 widget test에서 직접 검증 가능.
- **사이즈 레이블**: 1 MB 미만이면 KB, 이상이면 MB 단위로 표시. 소수점 1자리.
- **"설치 대상" 버튼 활성화 조건**: `pendingInstall != null`일 때만 onPressed 제공, 없으면 `null`로 disabled 처리. 실제 `AdbService.installApk` 호출은 포함하지 않아 USB 설치 Milestone 경계를 유지했다.
## 사용자 리뷰 요청
@ -111,39 +116,40 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### INSTALL_HANDOFF-1 중간 검증
```bash
```
$ flutter test test/builds_page_test.dart
(output)
00:02 +37: All tests passed!
```
### INSTALL_HANDOFF-2 중간 검증
```bash
```
$ flutter test test/widget_test.dart
(output)
00:01 +7: All tests passed!
```
### INSTALL_HANDOFF-3 중간 검증
```bash
```
$ flutter test test/widget_test.dart
(output)
00:01 +7: All tests passed!
```
### 최종 검증
```bash
```
$ git diff --check
(output)
(no output — whitespace clean)
$ flutter test test/builds_page_test.dart
(output)
00:02 +37: All tests passed!
$ flutter test test/widget_test.dart
(output)
00:01 +7: 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" && flutter analyze'\'''
(output)
$ ssh ... flutter analyze
Analyzing appsok...
No issues found! (ran in 1.7s)
$ 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'\'''
(output)
$ ssh ... flutter test
00:02 +70: All tests passed!
```
---
@ -165,3 +171,29 @@ $ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Implementing agent must not modify |
| 검증 결과 | Implementing agent | Fill command output only |
## 코드리뷰 결과
- 종합 판정: FAIL
- 리뷰 일시: 2026-06-10
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Fail | pending APK의 verified local path가 `PendingInstall`에 저장된 뒤 device UI/다음 액션에서 사용되지 않는다. |
| Completeness | Fail | 계획의 local path 요약 표시와 device 선택 handoff callback 요구가 미완료다. |
| Test coverage | Fail | shell/device 테스트가 filename/size만 확인하고 `apkPath` 표시나 device 선택 callback을 검증하지 않는다. |
| API contract | Fail | `DevicesPage`가 pending install을 받지만 선택된 `AdbDevice`와 APK path를 다음 USB 설치 흐름으로 넘기는 계약이 없다. |
| Code quality | Pass | debug print, dead code, 직접 ADB 호출, unrelated refactor는 보이지 않는다. |
| Plan deviation | Fail | 구현 메모는 INSTALL_HANDOFF-3을 UI 표시 중심으로 축소했지만, 계획의 callback 및 local path 표시 요구를 대체할 근거가 없다. |
| Verification trust | Pass | `git diff --check`, `flutter test test/widget_test.dart`, `flutter test test/builds_page_test.dart`를 재실행했고 모두 통과했다. remote evidence는 review stub 기록과 모순되지 않는다. |
### 발견된 문제
- Required: `lib/src/features/devices/devices_page.dart:245`의 pending install banner가 filename, build number, job name, size만 표시하고 `PendingInstall.apkPath`를 표시하지 않는다. 계획은 device page 상단에 APK filename, build number, local path 요약을 표시하도록 요구했고, 현재 `apkPath`는 `lib/src/models/pending_install.dart:10`에 저장된 뒤 UI와 테스트에서 사용되지 않는다. Fix: banner에 local path 요약을 추가하고 `test/widget_test.dart`에서 `/tmp/staged/...` path가 표시되거나 의도한 축약 표시로 노출되는지 검증한다.
- Required: `lib/src/features/devices/devices_page.dart:141`에서 pending install이 있을 때 "설치 대상" 버튼이 활성화되지만 `() {}` no-op이다. 계획은 pending install이 있을 때 다음 USB 설치 Milestone이 받을 수 있는 callback을 호출하도록 요구했고, 현재는 선택한 `AdbDevice`와 `PendingInstall.apkPath`가 다음 흐름으로 전달되지 않는다. Fix: `DevicesPage`에 선택 callback 계약을 추가하고 `_DeviceTile` 버튼이 ready device와 pending install을 함께 전달하도록 연결한 뒤, widget test에서 버튼 tap이 device serial과 APK path를 전달하는지 검증한다. 실제 `AdbService.installApk` 호출은 여전히 제외한다.
### 다음 단계
- FAIL follow-up: active plan/review를 아카이브한 뒤 `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성한다.

View file

@ -0,0 +1,189 @@
<!-- task=m-artifact-browser/05+04_install_handoff plan=1 tag=REVIEW_INSTALL_HANDOFF -->
# Code Review Reference - REVIEW_INSTALL_HANDOFF
> **[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-artifact-browser/05+04_install_handoff, plan=1, tag=REVIEW_INSTALL_HANDOFF
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `handoff`: 다운로드된 APK 경로를 device 선택과 USB 설치 흐름으로 넘긴다.
- 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-artifact-browser/05+04_install_handoff/`로 이동한다. 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_INSTALL_HANDOFF-1] pending install banner에 verified local APK path 요약을 표시하고 widget test로 고정한다. | [x] |
| [REVIEW_INSTALL_HANDOFF-2] device별 설치 대상 선택 callback이 `AdbDevice`와 `PendingInstall`을 전달하도록 연결하고 widget test로 고정한다. | [x] |
## 구현 체크리스트
- [x] [REVIEW_INSTALL_HANDOFF-1] pending install banner에 verified local APK path 요약을 표시하고 widget test로 고정한다.
- [x] [REVIEW_INSTALL_HANDOFF-2] device별 설치 대상 선택 callback이 `AdbDevice`와 `PendingInstall`을 전달하도록 연결하고 widget test로 고정한다.
- [x] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [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_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_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-artifact-browser/05+04_install_handoff/`를 `agent-task/archive/YYYY/MM/m-artifact-browser/05+04_install_handoff/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-artifact-browser/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다.
## 계획 대비 변경 사항
- 계획 대비 추가 변경 없음. plan After 코드 그대로 구현했다.
- `AppSokShell`에 `onInstallTargetSelected` 파라미터를 추가했다. plan은 "다음 USB 설치 Milestone이 받을 수 있는 state 또는 handler 경계"라고 명시했으므로, shell이 외부 주입 callback을 포워딩하는 방식으로 경계를 만들었다.
## 주요 설계 결정
- **`pending-install-path` key**: `Text` 위젯에 `ValueKey('pending-install-path')`를 부여해 widget test에서 직접 lookup 가능하게 했다. 긴 path는 `maxLines: 1` + `TextOverflow.ellipsis`로 처리.
- **`PendingInstallSelectionHandler` typedef**: `devices_page.dart`에 정의하고 `app_shell.dart`에서 import해 재사용. 타입 정의가 DevicesPage 계약에 속하므로 해당 파일에 둔다.
- **`AppSokShell.onInstallTargetSelected`**: 내부 `_handleInstallTargetSelected`가 `widget.onInstallTargetSelected?.call(device, pending)`으로 포워딩. 실제 ADB 실행 없이 다음 Milestone 경계만 제공한다.
- **테스트 device 선택**: sample devices 중 첫 번째 `R5CT90A1B2C`(state: 'device', ready)의 "설치 대상" 버튼을 tap. `find.byWidgetPredicate`로 `IconButton(tooltip: '설치 대상')`을 탐색.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- pending install banner가 `PendingInstall.apkPath`를 표시하고 compact viewport에서 overflow exception이 없는지 확인한다.
- "설치 대상" 버튼이 no-op이 아니라 selected `AdbDevice`와 `PendingInstall`을 callback으로 전달하는지 확인한다.
- 실제 `AdbService.installApk` 호출로 범위가 새지 않았는지 확인한다.
- test가 path 표시와 device selection callback을 둘 다 검증하는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_INSTALL_HANDOFF-1 중간 검증
```
$ flutter test test/widget_test.dart
00:01 +8: All tests passed!
```
### REVIEW_INSTALL_HANDOFF-2 중간 검증
```
$ flutter test test/widget_test.dart
00:01 +8: All tests passed!
```
### 최종 검증
```
$ git diff --check
(no output — whitespace clean)
$ flutter test test/widget_test.dart
00:01 +8: All tests passed!
$ flutter test test/builds_page_test.dart
00:02 +37: All tests passed!
$ ssh ... flutter analyze
Analyzing appsok...
No issues found! (ran in 1.8s)
$ ssh ... flutter test
00:03 +71: 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.
## 섹션 소유권
| 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 |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only |
| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Implementing agent must not modify |
| 검증 결과 | Implementing agent | Fill command output only |
## 코드리뷰 결과
- 종합 판정: FAIL
- 리뷰 일시: 2026-06-10
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Fail | `AppSokShell` 기본 경로에서 외부 install-target callback이 없어도 non-null wrapper를 `DevicesPage`에 넘겨 "설치 대상" 버튼이 활성화된 no-op으로 남는다. |
| Completeness | Fail | G06 Required 중 path 표시와 injected callback 경로는 닫혔지만, default shell no-op 버튼 경로가 닫히지 않았다. |
| Test coverage | Fail | callback 주입 케이스는 검증하지만, `AppSokApp`/default `AppSokShell`에서 callback이 없을 때 버튼이 disabled인지 검증하지 않는다. |
| API contract | Fail | `DevicesPage`의 `onInstallTargetSelected == null` disable 계약을 shell이 wrapper로 우회한다. |
| Code quality | Pass | 직접 ADB 호출, debug print, unrelated refactor는 보이지 않는다. |
| Plan deviation | Fail | plan의 `pendingInstall == null || onInstallTargetSelected == null ? null` 조건은 `DevicesPage` 내부에서만 성립하고 shell wiring에서 실제 null 상태가 보존되지 않는다. |
| Verification trust | Pass | `git diff --check`, `flutter test test/widget_test.dart`, `flutter test test/builds_page_test.dart`를 재실행했고 모두 통과했다. |
### 발견된 문제
- Required: `lib/src/features/app_shell.dart:77`은 `widget.onInstallTargetSelected`가 null이어도 `_handleInstallTargetSelected`를 `DevicesPage`에 항상 넘긴다. 그 결과 `lib/src/features/devices/devices_page.dart:155`의 disable 조건은 shell 사용 시 항상 callback이 있는 것으로 판단하고 버튼을 활성화한다. 기본 앱인 `lib/src/app.dart`는 `const AppSokShell()`만 생성하므로 버튼 tap은 `widget.onInstallTargetSelected?.call(...)`에서 아무 일도 하지 않는 no-op이다. Fix: shell에서 외부 callback이 없으면 `DevicesPage.onInstallTargetSelected`에 null을 그대로 전달하거나, shell 내부에서 선택 상태를 실제 다음 흐름 state로 보관한다. 회귀 테스트로 callback 없는 shell handoff 후 "설치 대상" 버튼이 disabled인지 검증하고, callback 주입 케이스는 계속 device serial과 APK path를 전달하는지 유지한다.
### 다음 단계
- FAIL follow-up: active plan/review를 아카이브한 뒤 `PLAN-local-G08.md`와 `CODE_REVIEW-local-G08.md`를 작성한다.

View file

@ -0,0 +1,185 @@
<!-- task=m-artifact-browser/05+04_install_handoff plan=2 tag=REVIEW2_INSTALL_HANDOFF -->
# Code Review Reference - REVIEW2_INSTALL_HANDOFF
> **[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, review-only checklist) is code-review-skill 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-artifact-browser/05+04_install_handoff, plan=2, tag=REVIEW2_INSTALL_HANDOFF
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `handoff`: 다운로드된 APK 경로를 device 선택과 USB 설치 흐름으로 넘긴다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G08.md` -> `code_review_local_G08_N.log`, `PLAN-local-G08.md` -> `plan_local_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-artifact-browser/05+04_install_handoff/`로 이동한다. 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` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW2_INSTALL_HANDOFF-1] `AppSokShell`이 외부 install target callback이 없을 때 `DevicesPage`에도 null callback을 전달하도록 수정한다. | [x] |
| [REVIEW2_INSTALL_HANDOFF-2] callback 없는 shell handoff 후 "설치 대상" 버튼이 disabled이고, callback 주입 경로는 계속 전달되는지 widget test로 고정한다. | [x] |
## 구현 체크리스트
- [x] [REVIEW2_INSTALL_HANDOFF-1] `AppSokShell`이 외부 install target callback이 없을 때 `DevicesPage`에도 null callback을 전달하도록 수정한다.
- [x] [REVIEW2_INSTALL_HANDOFF-2] callback 없는 shell handoff 후 "설치 대상" 버튼이 disabled이고, callback 주입 경로는 계속 전달되는지 widget test로 고정한다.
- [x] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [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_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_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-artifact-browser/05+04_install_handoff/`를 `agent-task/archive/YYYY/MM/m-artifact-browser/05+04_install_handoff/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-artifact-browser/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.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로 이동한다.
## 계획 대비 변경 사항
- 계획 대비 추가 변경 없음. plan After 코드 그대로 적용했다.
## 주요 설계 결정
- **null 조건부 wiring**: `widget.onInstallTargetSelected == null ? null : _handleInstallTargetSelected` 단일 표현식으로 처리. `_handleInstallTargetSelected` 메서드 자체는 유지하고 조건만 wiring 지점에 둔다.
- **regression test 검증 방식**: `tester.widgetList<IconButton>`으로 모든 "설치 대상" 버튼을 순회해 `onPressed == null`을 검사. sample devices 3개 모두 null이어야 한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- default `AppSokShell`에서 외부 callback이 없을 때 `DevicesPage`의 "설치 대상" 버튼이 disabled인지 확인한다.
- callback 주입 shell에서는 기존 test처럼 selected `AdbDevice`와 `PendingInstall.apkPath`가 전달되는지 확인한다.
- 실제 `AdbService.installApk` 호출로 범위가 새지 않았는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW2_INSTALL_HANDOFF-1 중간 검증
```
$ flutter test test/widget_test.dart
00:02 +9: All tests passed!
```
### REVIEW2_INSTALL_HANDOFF-2 중간 검증
```
$ flutter test test/widget_test.dart
00:02 +9: All tests passed!
```
### 최종 검증
```
$ git diff --check
(no output — whitespace clean)
$ flutter test test/widget_test.dart
00:02 +9: All tests passed!
$ flutter test test/builds_page_test.dart
00:03 +37: All tests passed!
$ ssh ... flutter analyze
Analyzing appsok...
No issues found! (ran in 1.9s)
$ ssh ... flutter test
00:02 +72: 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.
## 섹션 소유권
| 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 |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only |
| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Implementing agent must not modify |
| 검증 결과 | Implementing agent | Fill command output only |
## 코드리뷰 결과
- 종합 판정: PASS
- 리뷰 일시: 2026-06-10
### 차원별 평가
| 차원 | 평가 | 근거 |
|------|------|------|
| Correctness | Pass | shell이 외부 callback 부재를 null로 보존해 default no-op 버튼 경로가 사라졌고, callback 주입 경로는 그대로 전달된다. |
| Completeness | Pass | G06/G07 Required 사항인 path 표시, device/pending callback 전달, null callback disabled 경로가 모두 구현됐다. |
| Test coverage | Pass | widget tests가 path 표시, callback 주입 전달, callback 없는 disabled 경로를 검증한다. |
| API contract | Pass | `DevicesPage.onInstallTargetSelected`의 null-disable 계약과 shell wiring이 일치한다. |
| Code quality | Pass | 직접 ADB 실행, debug print, unrelated refactor가 없고 변경 범위가 plan에 맞게 제한됐다. |
| Plan deviation | Pass | G08 plan After 코드와 테스트 목표를 그대로 충족했다. |
| Verification trust | Pass | `git diff --check`, `flutter test test/widget_test.dart`, `flutter test test/builds_page_test.dart`를 재실행했고 모두 통과했다. review stub의 remote 결과 기록과 모순되는 증거가 없다. |
### 발견된 문제
- 없음
### 다음 단계
- PASS: active plan/review를 아카이브하고 `complete.log` 작성 후 task directory를 `agent-task/archive/2026/06/m-artifact-browser/05+04_install_handoff/`로 이동한다. task group이 `m-artifact-browser`이므로 runtime completion metadata를 보고하고, roadmap 수정이나 `update-roadmap` 호출은 하지 않는다.

View file

@ -0,0 +1,47 @@
# Complete - m-artifact-browser/05+04_install_handoff
## 완료 일시
2026-06-10
## 요약
Install handoff loop completed after 3 review passes; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | Required: pending APK path was not shown and device install-target button was enabled as a no-op. |
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | FAIL | Required: default shell still passed a wrapper callback, leaving the no-op button enabled without an external target handler. |
| `plan_local_G08_2.log` | `code_review_local_G08_2.log` | PASS | Required follow-up closed with null callback preservation and regression widget coverage. |
## 구현/정리 내용
- Verified APK handoff is represented as `PendingInstall` and routed from Builds to Devices through `AppSokShell`.
- `DevicesPage` shows pending APK filename, build/job/size, and local staged APK path.
- Device install-target buttons pass selected `AdbDevice` and `PendingInstall` only when an install target callback is supplied; default shell paths keep the buttons disabled instead of no-op.
- Widget tests cover path display, callback delivery, null-callback disabled behavior, and compact viewport rendering.
## 최종 검증
- `git diff --check` - PASS; no whitespace errors.
- `flutter test test/widget_test.dart` - PASS; `00:02 +9: All tests passed!`.
- `flutter test test/builds_page_test.dart` - PASS; `00:02 +37: 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" && 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:02 +72: All tests passed!`.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Completed task ids:
- `handoff`: PASS; evidence=`plan_local_G08_2.log`, `code_review_local_G08_2.log`; verification=`flutter test test/widget_test.dart`, remote `flutter test`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,233 @@
<!-- task=m-artifact-browser/05+04_install_handoff plan=1 tag=REVIEW_INSTALL_HANDOFF -->
# Plan - REVIEW_INSTALL_HANDOFF
## 이 파일을 읽는 구현 에이전트에게
구현의 마지막 단계는 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이다. 검증 명령을 실행하고 실제 출력, 계획 대비 변경, 설계 결정을 기록한 뒤 active 파일을 유지한 상태로 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌 없이는 진행할 수 없을 때만 review stub의 `사용자 리뷰 요청` 섹션에 근거를 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행으로 메울 수 있는 증거 공백은 사용자 리뷰 요청 사유가 아니다. finalization, `complete.log`, archive 이동은 code-review-skill 전용이다.
## 배경
이 follow-up은 첫 리뷰의 Required 2건을 닫는다. 현재 shell은 verified `StagedApk.path`를 `PendingInstall.apkPath`에 저장하지만 device 화면이 그 path를 표시하지 않고, device별 "설치 대상" 버튼도 다음 USB 설치 흐름으로 선택 정보를 넘기지 않는다. 실제 `adb install` 실행은 여전히 USB 설치 Milestone 범위로 남긴다.
## 사용자 리뷰 요청 흐름
구현 중 차단은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`에서 복사한 형식이며, 구현 에이전트는 직접 사용자 프롬프트를 만들지 않는다. code-review가 차단의 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `handoff`: 다운로드된 APK 경로를 device 선택과 USB 설치 흐름으로 넘긴다.
- 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/device-console/rules.md`
- `agent-ops/rules/project/domain/artifact-flow/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/app-shell-smoke.md`
- `agent-test/local/device-console-smoke.md`
- `agent-test/local/artifact-flow-smoke.md`
- `agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/complete.log`
- `agent-task/m-artifact-browser/05+04_install_handoff/plan_local_G06_0.log`
- `agent-task/m-artifact-browser/05+04_install_handoff/code_review_local_G06_0.log`
- `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/models/jenkins_build.dart`
- `lib/src/services/artifact_staging_service.dart`
- `test/builds_page_test.dart`
- `test/widget_test.dart`
### 테스트 환경 규칙
- 선택 환경: `local`.
- `agent-test/local/rules.md`가 존재하며 읽었다.
- matched profiles: `app-shell-smoke`, `device-console-smoke`, `artifact-flow-smoke`.
- 적용 명령:
- local preflight: `git diff --check`, `flutter test test/widget_test.dart`, `flutter test test/builds_page_test.dart`
- remote required: `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'\'''`
- remote required: `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'\'''`
- 구조적 blank/skeleton 또는 `<확인 필요>` 값은 없었다.
### 테스트 커버리지 공백
- `PendingInstall.apkPath`는 [lib/src/models/pending_install.dart](/config/workspace/appsok/lib/src/models/pending_install.dart:10)에 있지만 device banner나 test assertion에서 사용되지 않는다.
- `DevicesPage`는 pending install 버튼 활성화만 검증하고, 선택한 `AdbDevice`와 `PendingInstall`을 callback으로 넘기는 테스트가 없다.
### 심볼 참조
- 제거 예정 심볼: 없음.
- 확장 대상:
- `PendingInstall.apkPath`: `rg --sort path -n "apkPath" lib test` 기준 shell 저장과 model 필드 외 사용처가 없다.
- `DevicesPage`: call site는 `lib/src/features/app_shell.dart`와 direct widget tests다.
### 분할 판단
split decision policy를 재평가했다. 이 follow-up은 기존 split subtask `05+04_install_handoff` 내부의 리뷰 보완이며 새 subtask를 만들지 않는다.
- predecessor `04`: `agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/complete.log`로 충족.
- 변경 범위는 `DevicesPage` callback 계약, `AppSokShell` wiring, widget tests로 작고 서로 같은 handoff 결함을 닫는다.
- 실제 ADB install, device discovery, staging cleanup은 범위 밖이므로 별도 split이 필요하지 않다.
### 범위 결정 근거
- 포함: pending install banner의 local APK path 표시, device tile install-target callback 계약, shell wiring, widget regression tests.
- 제외: `AdbService.installApk` 호출, 실제 USB 설치 실행, ADB device discovery, logcat, Jenkins credential 또는 download/staging 로직 변경.
- device selection callback은 다음 USB 설치 Milestone이 받을 계약만 만든다. 이 plan에서 APK 설치를 수행하지 않는다.
### 빌드 등급
- `local-G07`: 첫 리뷰 Required를 닫는 bounded UI/API follow-up이며, 파일과 테스트 범위가 작고 deterministic widget tests로 검증 가능하다.
## 구현 체크리스트
- [ ] [REVIEW_INSTALL_HANDOFF-1] pending install banner에 verified local APK path 요약을 표시하고 widget test로 고정한다.
- [ ] [REVIEW_INSTALL_HANDOFF-2] device별 설치 대상 선택 callback이 `AdbDevice`와 `PendingInstall`을 전달하도록 연결하고 widget test로 고정한다.
- [ ] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_INSTALL_HANDOFF-1] Pending Path Summary
### 문제
[lib/src/features/devices/devices_page.dart](/config/workspace/appsok/lib/src/features/devices/devices_page.dart:245)는 pending banner 보조 텍스트로 build number, job name, size만 표시한다. [lib/src/models/pending_install.dart](/config/workspace/appsok/lib/src/models/pending_install.dart:10)의 `apkPath`는 shell에서 저장된 뒤 화면과 테스트에서 사용되지 않는다.
Before:
```dart
// lib/src/features/devices/devices_page.dart:245
Text(
'#${pendingInstall.buildNumber} · ${pendingInstall.jobName} · $sizeLabel',
```
### 해결 방법
- `_PendingInstallBanner`에 local path 요약 텍스트를 추가한다.
- 긴 path는 `maxLines: 1`과 `TextOverflow.ellipsis`로 처리하고, test lookup을 위해 안정적인 key를 둔다.
- filename/build/job/size 표시는 유지한다.
After:
```dart
Text(
key: const ValueKey('pending-install-path'),
pendingInstall.apkPath,
maxLines: 1,
overflow: TextOverflow.ellipsis,
)
```
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/devices/devices_page.dart`: banner에 `pendingInstall.apkPath` 표시를 추가한다.
- [ ] `test/widget_test.dart`: pending install summary test가 `/tmp/staged/app-release.apk` 표시를 검증한다.
### 테스트 작성
- 작성: `test/widget_test.dart`
- test name: `shows pending apk install summary on devices page`
- assertion goal: filename, size, local path가 모두 표시된다.
### 중간 검증
```bash
flutter test test/widget_test.dart
```
예상: widget test 통과.
## [REVIEW_INSTALL_HANDOFF-2] Device Selection Callback
### 문제
[lib/src/features/devices/devices_page.dart](/config/workspace/appsok/lib/src/features/devices/devices_page.dart:141)는 pending install이 있으면 "설치 대상" 버튼을 활성화하지만 `() {}` no-op이다. 선택된 device와 `PendingInstall.apkPath`가 다음 USB 설치 흐름으로 넘어가지 않아 roadmap `handoff` Task를 완료했다고 보기 어렵다.
Before:
```dart
// lib/src/features/devices/devices_page.dart:138
_CompactIconButton(
tooltip: '설치 대상',
icon: Icons.add_to_queue,
onPressed: pendingInstall != null ? () {} : null,
),
```
### 해결 방법
- `DevicesPage`에 public callback 계약을 추가한다.
- `_DeviceTile`은 `pendingInstall != null`이고 install-target callback이 있을 때 선택한 `AdbDevice`와 `PendingInstall`을 전달한다.
- `AppSokShell`은 callback을 받아 다음 Milestone이 이어받을 수 있는 state 또는 handler 경계를 만든다. 실제 `AdbService.installApk`는 호출하지 않는다.
After:
```dart
typedef PendingInstallSelectionHandler =
void Function(AdbDevice device, PendingInstall pendingInstall);
onPressed: pendingInstall == null || onInstallTargetSelected == null
? null
: () => onInstallTargetSelected!(device, pendingInstall!),
```
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/devices/devices_page.dart`: callback typedef/constructor field/wiring을 추가한다.
- [ ] `lib/src/features/app_shell.dart`: `DevicesPage`에 callback을 연결하고, 다음 USB 설치 흐름이 받을 수 있는 state 또는 handler 경계를 유지한다.
- [ ] `test/widget_test.dart`: "설치 대상" tap이 device serial과 APK path를 callback으로 전달하는지 검증한다.
### 테스트 작성
- 작성: `test/widget_test.dart`
- test name: `passes selected device and pending apk to install target callback`
- assertion goal: button tap 후 callback이 `R5CT90A1B2C`와 `/tmp/staged/app-release.apk`를 받는다.
### 중간 검증
```bash
flutter test test/widget_test.dart
```
예상: widget test 통과.
## 의존 관계 및 구현 순서
1. `REVIEW_INSTALL_HANDOFF-1`
2. `REVIEW_INSTALL_HANDOFF-2`
3. focused local tests
4. remote required verification
5. review stub 작성
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `lib/src/features/devices/devices_page.dart` | REVIEW_INSTALL_HANDOFF-1, REVIEW_INSTALL_HANDOFF-2 |
| `lib/src/features/app_shell.dart` | REVIEW_INSTALL_HANDOFF-2 |
| `test/widget_test.dart` | REVIEW_INSTALL_HANDOFF-1, REVIEW_INSTALL_HANDOFF-2 |
## 최종 검증
```bash
git diff --check
flutter test test/widget_test.dart
flutter test test/builds_page_test.dart
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 문제 없음, focused widget/build tests 통과, remote `flutter analyze`에서 `No issues found!`, remote `flutter test`에서 `All tests passed!`.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,203 @@
<!-- task=m-artifact-browser/05+04_install_handoff plan=2 tag=REVIEW2_INSTALL_HANDOFF -->
# Plan - REVIEW2_INSTALL_HANDOFF
## 이 파일을 읽는 구현 에이전트에게
구현의 마지막 단계는 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이다. 검증 명령을 실행하고 실제 출력, 계획 대비 변경, 설계 결정을 기록한 뒤 active 파일을 유지한 상태로 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌 없이는 진행할 수 없을 때만 review stub의 `사용자 리뷰 요청` 섹션에 근거를 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행으로 메울 수 있는 증거 공백은 사용자 리뷰 요청 사유가 아니다. finalization, `complete.log`, archive 이동은 code-review-skill 전용이다.
## 배경
G07은 path 표시와 callback 주입 케이스를 닫았지만, default `AppSokShell` 경로에서 외부 callback이 없어도 wrapper가 `DevicesPage`에 전달되어 버튼이 활성화된 no-op으로 남는다. 이 follow-up은 null callback 상태를 보존하고 그 회귀 테스트만 추가한다. 실제 `adb install` 실행은 여전히 USB 설치 Milestone 범위 밖이다.
## 사용자 리뷰 요청 흐름
구현 중 차단은 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`에서 복사한 형식이며, 구현 에이전트는 직접 사용자 프롬프트를 만들지 않는다. code-review가 차단의 정당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `handoff`: 다운로드된 APK 경로를 device 선택과 USB 설치 흐름으로 넘긴다.
- 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/rules/project/domain/app-shell/rules.md`
- `agent-ops/rules/project/domain/device-console/rules.md`
- `agent-ops/rules/project/domain/artifact-flow/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/app-shell-smoke.md`
- `agent-test/local/device-console-smoke.md`
- `agent-test/local/artifact-flow-smoke.md`
- `agent-task/m-artifact-browser/05+04_install_handoff/plan_local_G07_1.log`
- `agent-task/m-artifact-browser/05+04_install_handoff/code_review_local_G07_1.log`
- `lib/src/app.dart`
- `lib/src/features/app_shell.dart`
- `lib/src/features/devices/devices_page.dart`
- `lib/src/models/pending_install.dart`
- `test/widget_test.dart`
### 테스트 환경 규칙
- 선택 환경: `local`.
- `agent-test/local/rules.md`가 존재하며 읽었다.
- matched profiles: `app-shell-smoke`, `device-console-smoke`, `artifact-flow-smoke`.
- 적용 명령:
- local preflight: `git diff --check`, `flutter test test/widget_test.dart`, `flutter test test/builds_page_test.dart`
- remote required: `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'\'''`
- remote required: `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'\'''`
- 구조적 blank/skeleton 또는 `<확인 필요>` 값은 없었다.
### 테스트 커버리지 공백
- `AppSokShell`에 `onInstallTargetSelected`가 없을 때 handoff 이후 "설치 대상" 버튼이 disabled인지 검증하는 테스트가 없다.
- callback 주입 경로는 `passes selected device and pending apk to install target callback`으로 이미 검증된다.
### 심볼 참조
- 제거 예정 심볼: 없음.
- 확장 대상:
- `AppSokShell.onInstallTargetSelected`: call site는 `lib/src/app.dart`의 default `const AppSokShell()`과 `test/widget_test.dart`의 injected shell이다.
- `DevicesPage.onInstallTargetSelected`: shell wiring과 direct constructor 계약이다.
### 분할 판단
split decision policy를 재평가했다. 이 follow-up은 기존 split subtask `05+04_install_handoff` 안의 single-line wiring 보완과 regression test이며 새 subtask를 만들지 않는다.
- predecessor `04`: `agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/complete.log`로 이미 충족된 상태에서 이어진 subtask다.
- 실제 ADB install, device discovery, logcat은 범위 밖이다.
### 범위 결정 근거
- 포함: shell이 null external callback을 `DevicesPage`에 null로 전달하도록 보존, default no-op 버튼 방지 test.
- 제외: USB 설치 실행, install target state 화면, device filtering, unauthorized/offline state 정책 변경.
- ready/offline device별 enable 정책은 후속 USB 설치 Milestone의 세부 UX로 남긴다. 이번 plan은 callback 부재 no-op만 닫는다.
### 빌드 등급
- `local-G08`: 두 번째 review failure지만 원인은 명시적이고 deterministic widget test로 닫을 수 있어 local lane을 유지하고 grade만 올린다.
## 구현 체크리스트
- [ ] [REVIEW2_INSTALL_HANDOFF-1] `AppSokShell`이 외부 install target callback이 없을 때 `DevicesPage`에도 null callback을 전달하도록 수정한다.
- [ ] [REVIEW2_INSTALL_HANDOFF-2] callback 없는 shell handoff 후 "설치 대상" 버튼이 disabled이고, callback 주입 경로는 계속 전달되는지 widget test로 고정한다.
- [ ] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW2_INSTALL_HANDOFF-1] Preserve Null Callback
### 문제
[lib/src/features/app_shell.dart](/config/workspace/appsok/lib/src/features/app_shell.dart:77)는 `widget.onInstallTargetSelected`가 null이어도 `_handleInstallTargetSelected`를 `DevicesPage`에 넘긴다. [lib/src/app.dart](/config/workspace/appsok/lib/src/app.dart:12)의 default `const AppSokShell()`에서는 외부 callback이 없으므로 button tap이 no-op이다.
Before:
```dart
// lib/src/features/app_shell.dart:75
child: DevicesPage(
pendingInstall: _pendingInstall,
onInstallTargetSelected: _handleInstallTargetSelected,
),
```
### 해결 방법
- 외부 callback이 없으면 `DevicesPage.onInstallTargetSelected`에 null을 전달한다.
- 외부 callback이 있으면 기존 `_handleInstallTargetSelected` 포워딩을 유지한다.
After:
```dart
child: DevicesPage(
pendingInstall: _pendingInstall,
onInstallTargetSelected: widget.onInstallTargetSelected == null
? null
: _handleInstallTargetSelected,
),
```
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/app_shell.dart`: null callback 보존 wiring을 적용한다.
### 테스트 작성
- 작성: `test/widget_test.dart`
- test name: `keeps install target disabled when shell has no target callback`
- assertion goal: default shell handoff 후 첫 "설치 대상" `IconButton.onPressed`가 null이다.
### 중간 검증
```bash
flutter test test/widget_test.dart
```
예상: widget test 통과.
## [REVIEW2_INSTALL_HANDOFF-2] Regression Coverage
### 문제
[test/widget_test.dart](/config/workspace/appsok/test/widget_test.dart:224)는 callback 주입 케이스만 검증한다. null callback 경로가 없어서 shell wrapper가 no-op을 다시 만들 수 있었다.
### 해결 방법
- 기존 handoff helper 중복은 과도하게 리팩터링하지 말고, 현재 test style 안에서 작은 regression test를 추가한다.
- callback 주입 테스트는 유지해서 enabled + callback 전달 경로를 계속 고정한다.
### 수정 파일 및 체크리스트
- [ ] `test/widget_test.dart`: callback 없는 shell handoff regression test를 추가한다.
- [ ] 기존 `passes selected device and pending apk to install target callback` test가 계속 통과하는지 확인한다.
### 테스트 작성
- 작성: `test/widget_test.dart`
- test names:
- `keeps install target disabled when shell has no target callback`
- existing `passes selected device and pending apk to install target callback`
### 중간 검증
```bash
flutter test test/widget_test.dart
```
예상: widget test 통과.
## 의존 관계 및 구현 순서
1. `REVIEW2_INSTALL_HANDOFF-1`
2. `REVIEW2_INSTALL_HANDOFF-2`
3. focused local tests
4. remote required verification
5. review stub 작성
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `lib/src/features/app_shell.dart` | REVIEW2_INSTALL_HANDOFF-1 |
| `test/widget_test.dart` | REVIEW2_INSTALL_HANDOFF-2 |
## 최종 검증
```bash
git diff --check
flutter test test/widget_test.dart
flutter test test/builds_page_test.dart
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 문제 없음, focused widget/build tests 통과, remote `flutter analyze`에서 `No issues found!`, remote `flutter test`에서 `All tests passed!`.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -1,12 +1,29 @@
import 'package:flutter/material.dart';
import '../models/adb_device.dart';
import '../models/jenkins_build.dart';
import '../models/pending_install.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';
class AppSokShell extends StatefulWidget {
const AppSokShell({super.key});
const AppSokShell({
super.key,
this.jobLoader,
this.buildLoader,
this.artifactDownloader,
this.artifactStager,
this.onInstallTargetSelected,
});
final JenkinsJobLoader? jobLoader;
final JenkinsBuildLoader? buildLoader;
final ArtifactDownloader? artifactDownloader;
final ArtifactStager? artifactStager;
final PendingInstallSelectionHandler? onInstallTargetSelected;
@override
State<AppSokShell> createState() => _AppSokShellState();
@ -14,27 +31,61 @@ class AppSokShell extends StatefulWidget {
class _AppSokShellState extends State<AppSokShell> {
int _selectedIndex = 0;
PendingInstall? _pendingInstall;
final _pages = const [
void _handleInstallRequested(
JenkinsBuild build,
BuildArtifact artifact,
StagedApk? staged,
) {
if (staged == null) return;
setState(() {
_pendingInstall = PendingInstall(
apkPath: staged.path,
fileName: artifact.fileName,
buildNumber: build.number,
jobName: build.jobName,
sizeBytes: staged.sizeBytes,
);
_selectedIndex = 1;
});
}
void _handleInstallTargetSelected(AdbDevice device, PendingInstall pending) {
widget.onInstallTargetSelected?.call(device, pending);
}
List<_ShellPage> get _pages => [
_ShellPage(
label: '빌드',
icon: Icons.inventory_2_outlined,
selectedIcon: Icons.inventory_2,
child: BuildsPage(),
child: BuildsPage(
jobLoader: widget.jobLoader,
buildLoader: widget.buildLoader,
artifactDownloader: widget.artifactDownloader,
artifactStager: widget.artifactStager,
onInstallRequested: _handleInstallRequested,
),
),
_ShellPage(
label: '디바이스',
icon: Icons.usb_outlined,
selectedIcon: Icons.usb,
child: DevicesPage(),
child: DevicesPage(
pendingInstall: _pendingInstall,
onInstallTargetSelected: widget.onInstallTargetSelected == null
? null
: _handleInstallTargetSelected,
),
),
_ShellPage(
const _ShellPage(
label: '콘솔',
icon: Icons.terminal_outlined,
selectedIcon: Icons.terminal,
child: ConsolePage(),
),
_ShellPage(
const _ShellPage(
label: '설정',
icon: Icons.tune_outlined,
selectedIcon: Icons.tune,

View file

@ -1,9 +1,20 @@
import 'package:flutter/material.dart';
import '../../models/adb_device.dart';
import '../../models/pending_install.dart';
typedef PendingInstallSelectionHandler =
void Function(AdbDevice device, PendingInstall pendingInstall);
class DevicesPage extends StatelessWidget {
const DevicesPage({super.key});
const DevicesPage({
super.key,
this.pendingInstall,
this.onInstallTargetSelected,
});
final PendingInstall? pendingInstall;
final PendingInstallSelectionHandler? onInstallTargetSelected;
@override
Widget build(BuildContext context) {
@ -14,6 +25,10 @@ class DevicesPage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (pendingInstall != null) ...[
_PendingInstallBanner(pendingInstall: pendingInstall!),
const SizedBox(height: 12),
],
Wrap(
spacing: 12,
runSpacing: 8,
@ -58,7 +73,11 @@ class DevicesPage extends StatelessWidget {
),
itemCount: devices.length,
itemBuilder: (context, index) {
return _DeviceTile(device: devices[index]);
return _DeviceTile(
device: devices[index],
pendingInstall: pendingInstall,
onInstallTargetSelected: onInstallTargetSelected,
);
},
),
),
@ -69,9 +88,15 @@ class DevicesPage extends StatelessWidget {
}
class _DeviceTile extends StatelessWidget {
const _DeviceTile({required this.device});
const _DeviceTile({
required this.device,
this.pendingInstall,
this.onInstallTargetSelected,
});
final AdbDevice device;
final PendingInstall? pendingInstall;
final PendingInstallSelectionHandler? onInstallTargetSelected;
@override
Widget build(BuildContext context) {
@ -127,7 +152,9 @@ class _DeviceTile extends StatelessWidget {
_CompactIconButton(
tooltip: '설치 대상',
icon: Icons.add_to_queue,
onPressed: () {},
onPressed: pendingInstall == null || onInstallTargetSelected == null
? null
: () => onInstallTargetSelected!(device, pendingInstall!),
),
],
),
@ -142,12 +169,12 @@ class _CompactIconButton extends StatelessWidget {
const _CompactIconButton({
required this.tooltip,
required this.icon,
required this.onPressed,
this.onPressed,
});
final String tooltip;
final IconData icon;
final VoidCallback onPressed;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
@ -193,6 +220,73 @@ class _DeviceStateChip extends StatelessWidget {
}
}
class _PendingInstallBanner extends StatelessWidget {
const _PendingInstallBanner({required this.pendingInstall});
final PendingInstall pendingInstall;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final sizeLabel = pendingInstall.sizeBytes < 1024 * 1024
? '${(pendingInstall.sizeBytes / 1024).toStringAsFixed(1)} KB'
: '${(pendingInstall.sizeBytes / (1024 * 1024)).toStringAsFixed(1)} MB';
return Material(
key: const ValueKey('pending-install-banner'),
color: colorScheme.primaryContainer.withValues(alpha: 0.42),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: colorScheme.primary.withValues(alpha: 0.34)),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Row(
children: [
Icon(Icons.install_mobile, color: colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
key: const ValueKey('pending-install-filename'),
pendingInstall.fileName,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'#${pendingInstall.buildNumber} · ${pendingInstall.jobName} · $sizeLabel',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
key: const ValueKey('pending-install-path'),
pendingInstall.apkPath,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
}
}
const _sampleDevices = [
AdbDevice(
serial: 'R5CT90A1B2C',

View file

@ -0,0 +1,15 @@
class PendingInstall {
const PendingInstall({
required this.apkPath,
required this.fileName,
required this.buildNumber,
required this.jobName,
required this.sizeBytes,
});
final String apkPath;
final String fileName;
final int buildNumber;
final String jobName;
final int sizeBytes;
}

View file

@ -941,6 +941,73 @@ void main() {
},
);
// INSTALL_HANDOFF-1: Verified APK handoff
testWidgets('notifies verified apk handoff after download is verified', (
tester,
) async {
StagedApk? handoffStaged;
JenkinsBuild? handoffBuild;
BuildArtifact? handoffArtifact;
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,
);
},
onInstallRequested: (build, artifact, staged) {
handoffBuild = build;
handoffArtifact = artifact;
handoffStaged = staged;
},
),
),
);
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();
// Start download
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verified-label')), findsOneWidget);
// Tap install button in verified state must fire callback with StagedApk
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
expect(handoffBuild?.number, 7);
expect(handoffArtifact?.fileName, 'app-release.apk');
expect(handoffStaged?.path, '/tmp/staged/app-release.apk');
expect(handoffStaged?.sizeBytes, 1024);
});
testWidgets(
'renders selected job view without overflow at compact viewport',
(tester) async {

View file

@ -2,6 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:appsok/src/app.dart';
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/services/artifact_staging_service.dart';
import 'package:appsok/src/services/jenkins_client.dart';
import 'package:appsok/src/theme/app_theme.dart';
void main() {
@ -47,6 +53,417 @@ void main() {
}
});
// INSTALL_HANDOFF-2: Shell handoff routing
testWidgets('routes verified apk handoff to devices page', (tester) async {
const apk = BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
);
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 [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,
);
},
),
),
);
await tester.pumpAndSettle();
// Navigate to builds, select job, select build
await tester.tap(find.text('android-app').first);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
// Start download and wait for verified state
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verified-label')), findsOneWidget);
// Tap install in verified state shell should switch to devices page
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
// Devices page is now active and shows pending install banner
expect(
find.byKey(const ValueKey('pending-install-banner')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('pending-install-filename')),
findsOneWidget,
);
expect(find.text('app-release.apk'), findsOneWidget);
});
// INSTALL_HANDOFF-3: Device page pending install UI
testWidgets('shows pending apk install summary on devices page', (
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(2 * 1024 * 1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 2 * 1024 * 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: 2 * 1024 * 1024,
);
},
),
),
);
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();
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.text('/tmp/staged/app-release.apk'), findsOneWidget);
});
testWidgets('keeps install target disabled when shell has no target callback', (
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 onInstallTargetSelected default shell path
),
),
);
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();
// 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 == '설치 대상'),
);
for (final btn in installButtons) {
expect(btn.onPressed, isNull);
}
});
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;
},
),
),
);
await tester.pumpAndSettle();
// Navigate builds select job select build download handoff to devices
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();
// Now on devices page tap "설치 대상" on first ready device (R5CT90A1B2C)
final installButtons = find.byWidgetPredicate(
(w) => w is IconButton && w.tooltip == '설치 대상',
);
await tester.tap(installButtons.first);
expect(callbackDevice?.serial, 'R5CT90A1B2C');
expect(callbackPending?.apkPath, '/tmp/staged/app-release.apk');
});
testWidgets(
'keeps device page compact viewport free of overflow with pending apk',
(tester) async {
tester.view.physicalSize = const Size(600, 400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
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: 'very-long-artifact-name-for-overflow-test.apk',
relativePath:
'outputs/very-long-artifact-name-for-overflow-test.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.apk',
sizeBytes: 1024,
);
},
),
),
);
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();
expect(find.byKey(const ValueKey('pending-install-banner')), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
testWidgets(
'switches between all shell destinations with status placeholders',
(WidgetTester tester) async {