feat(artifact-browser): 빌드 브라우저 변경사항을 반영한다

Jenkins 빌드 리스트/클라이언트/모델 변경 및 테스트를 함께 반영하고, 로드맵 산출물도 갱신한다.
This commit is contained in:
toki 2026-06-09 21:54:46 +09:00
parent 74d1f40465
commit f99a089324
14 changed files with 2643 additions and 42 deletions

View file

@ -17,12 +17,12 @@ Jenkins에서 Android artifact를 찾고, Mac에 내려받고, USB로 연결된
- [완료] 제품 골격 안정화
- 경로: `agent-roadmap/archive/phase/usable-mvp/milestones/product-baseline.md`
- 요약: Flutter macOS scaffold, 앱 shell, domain/test/roadmap 기반 작업 환경을 실제 구현 가능한 상태로 정리한다.
- [진행중] Artifact 탐색과 다운로드
- 경로: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- 요약: Jenkins job/build/artifact를 조회하고 APK를 Mac에 캐시한다.
- [계획] Jenkins 로그인과 credential 수명주기
- 경로: `agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md`
- 요약: 사용자별 Jenkins API token 또는 WebView 기반 token 자동화 흐름을 안전하게 저장/검증한다.
- [계획] Artifact 탐색과 다운로드
- 경로: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- 요약: Jenkins job/build/artifact를 조회하고 APK를 Mac에 캐시한다.
- [계획] USB 설치와 device 선택
- 경로: `agent-roadmap/phase/usable-mvp/milestones/usb-install.md`
- 요약: 연결된 Android device를 고르고 선택한 APK를 `adb install -r`로 설치한다.

View file

@ -11,7 +11,7 @@
## 상태
[계획]
[진행중]
## 승격 조건
@ -38,27 +38,27 @@
로그인 이후 접근 가능한 Jenkins job을 먼저 보여주고 사용자가 설치 대상 job을 고른다.
- [ ] [job-list] Jenkins job 목록을 조회하고 검색 가능한 리스트로 표시한다.
- [ ] [job-select] 사용자가 job을 선택하면 해당 job의 build 목록으로 진입한다.
- [ ] [job-empty] 접근 가능한 job이 없거나 권한이 없을 때 상태를 구분해 표시한다.
- [x] [job-list] Jenkins job 목록을 조회하고 검색 가능한 리스트로 표시한다.
- [x] [job-select] 사용자가 job을 선택하면 해당 job의 build 목록으로 진입한다.
- [x] [job-empty] 접근 가능한 job이 없거나 권한이 없을 때 상태를 구분해 표시한다.
### Epic: [build-list] build 목록
Jenkins Remote API에서 선택한 job의 최근 build와 `.apk` artifact metadata를 가져온다.
- [ ] [job-query] 선택한 Jenkins job의 build 목록을 조회한다. 검증: mock HTTP 응답 기반 parser test에서 build/requestedBy/result/artifact parsing이 통과한다.
- [ ] [apk-only] `.apk` artifact가 있는 build만 목록에 남긴다.
- [ ] [requested-by] Jenkins cause의 userName/userId를 우선하고 없으면 trigger source 또는 commit author를 fallback으로 사용해 빌드한 사람을 1차 정보로 표시한다.
- [ ] [state-badges] success/failure/running/aborted 상태를 구분해 표시한다.
- [ ] [summary-row] build number, branch, flavor, result, 시간, artifact 이름은 보조 정보로 요약 표시한다.
- [ ] [search-filter] 빌드한 사람, build number, branch, flavor, artifact 이름 기준 검색/필터를 제공한다.
- [ ] [empty-error] 권한 없음, 네트워크 실패, artifact 없음 상태를 구분해 사용자에게 보여준다.
- [x] [job-query] 선택한 Jenkins job의 build 목록을 조회한다. 검증: mock HTTP 응답 기반 parser test에서 build/requestedBy/result/artifact parsing이 통과한다.
- [x] [apk-only] `.apk` artifact가 있는 build만 목록에 남긴다.
- [x] [requested-by] Jenkins cause의 userName/userId를 우선하고 없으면 trigger source 또는 commit author를 fallback으로 사용해 빌드한 사람을 1차 정보로 표시한다.
- [x] [state-badges] success/failure/running/aborted 상태를 구분해 표시한다.
- [x] [summary-row] build number, branch, flavor, result, 시간, artifact 이름은 보조 정보로 요약 표시한다.
- [x] [search-filter] 빌드한 사람, build number, branch, flavor, artifact 이름 기준 검색/필터를 제공한다.
- [x] [empty-error] 권한 없음, 네트워크 실패, artifact 없음 상태를 구분해 사용자에게 보여준다.
### Epic: [install-ready] 설치 진입
사용자가 build를 선택하면 별도 깊은 탐색 없이 설치 버튼을 바로 노출하고, 설치 요청 시 필요한 파일만 임시로 내려받는다.
- [ ] [install-cta] build/artifact item 선택 시 설치 CTA를 바로 노출한다.
- [x] [install-cta] build/artifact item 선택 시 설치 CTA를 바로 노출한다.
- [ ] [download-progress] artifact 다운로드 진행률과 취소 상태를 표시한다.
- [ ] [temp-download] 다운로드 파일을 앱 임시/staging 경로에 저장하고 설치 완료 또는 취소 후 삭제한다.
- [ ] [artifact-verify] 다운로드 완료 후 파일 존재, 확장자, 크기 정보를 확인한다.
@ -87,6 +87,12 @@ Jenkins Remote API에서 선택한 job의 최근 build와 `.apk` artifact metada
- 표준선(선택): Jenkins Remote API JSON tree를 사용하고, parsing은 모델/서비스 테스트로 보호한다.
- 표준선(선택): 다운로드 파일은 설치를 위한 임시 staging 산출물이며, 기본 정책은 설치 완료 또는 취소 후 삭제다.
- 표준선(선택): 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` 완료 후 시작한다.
- 선행 작업: Jenkins 로그인과 credential 수명주기
- 후속 작업: USB 설치와 device 선택
- 확인 필요: 없음

View file

@ -0,0 +1,214 @@
<!-- task=m-artifact-browser/03+02_build_list_ui plan=0 tag=UI -->
# Code Review Reference - UI
> **[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-09
task=m-artifact-browser/03+02_build_list_ui, plan=0, tag=UI
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `requested-by`: Jenkins cause의 userName/userId를 우선하고 없으면 trigger source 또는 commit author를 fallback으로 사용해 빌드한 사람을 1차 정보로 표시한다.
- `state-badges`: success/failure/running/aborted 상태를 구분해 표시한다.
- `summary-row`: build number, branch, flavor, result, 시간, artifact 이름은 보조 정보로 요약 표시한다.
- `search-filter`: 빌드한 사람, build number, branch, flavor, artifact 이름 기준 검색/필터를 제공한다.
- `empty-error`: 권한 없음, 네트워크 실패, artifact 없음 상태를 구분해 사용자에게 보여준다.
- 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-artifact-browser/03+02_build_list_ui/`로 이동한다. 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-artifact-browser`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [UI-1] Build Loader State | [x] |
| [UI-2] Build Rows, Badges, And Filtering | [x] |
| [UI-3] Build Empty And Error States | [x] |
## 구현 체크리스트
- [x] `BuildsPage`에 선택된 job 기준 build loader와 build load state를 추가하고 job 선택/뒤로가기 시 stale build 결과가 남지 않게 한다.
- [x] build row에서 `requestedBy`를 1차 정보로 표시하고 success/failure/running/aborted badge와 build number, branch, flavor, 시간, APK artifact 이름 요약을 보조 정보로 표시한다.
- [x] 빌드한 사람, build number, branch, flavor, artifact 이름 기준 build 검색/필터를 추가하고 검색 결과 없음 상태를 표시한다.
- [x] build 조회의 401/403 권한 없음, 일반 네트워크/서버 실패, APK artifact 없음 상태를 구분해 표시한다.
- [x] `test/builds_page_test.dart`에 loaded row, badge/summary, build search, forbidden/failure/artifact-empty, compact viewport 회귀 테스트를 추가한다.
- [x] remote runner에서 `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하는지 확인한다.
- [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/03+02_build_list_ui/`를 `agent-task/archive/YYYY/MM/m-artifact-browser/03+02_build_list_ui/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-artifact-browser`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-artifact-browser/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.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로 이동한다.
## 계획 대비 변경 사항
- `_JobSelected`를 `StatefulWidget`으로 전환할 때 계획의 "builds/state/query를 받는" 형태 대신 상태를 `_JobSelectedState` 내부에 모두 두었다. 이유: `BuildsPage`가 `_selectedJob = null`로 설정하면 `_JobSelected`가 소멸되어 stale 상태가 자동 해소되므로, 외부에서 상태를 주입받을 필요가 없다.
- `_JobStatus`와 구조가 동일한 `_BuildStatus`를 별도로 추가했다. 두 widget의 label/description 문구가 달라 혼동을 방지하고, 향후 독립적으로 변경 가능하게 하기 위해 공유하지 않았다.
- `withOpacity` 대신 `withValues(alpha: ...)` 사용: Dart 3.11/Flutter stable(≥3.27)에서 `withOpacity`가 deprecated이므로 교체했다.
- `_BuildRow`·`_StateBadge`의 `build` 필드를 `item`으로 변경: `StatelessWidget.build` 메서드와 이름 충돌(`conflicting_field_and_method`) 발생으로 수정.
- local `flutter analyze` + `flutter test` preflight 통과 후 remote runner SSH 명령 출력은 미확보 상태로 리뷰 인계했다. 리뷰에서 remote runner `flutter analyze`, `flutter test`를 재실행해 통과를 확인했다.
- `_makeBuild` 검색 테스트에서 requestedBy 값(`'alice'`)이 TextField 입력값과 동일해 `findsOneWidget` 대신 `findsWidgets` 사용 (TextField EditableText + 행 Text 두 곳에서 일치).
## 주요 설계 결정
- **stale 결과 방지**: `_JobSelected`에 `key: ValueKey(_selectedJob!.url)` 부여. job이 바뀌면 위젯이 재생성되어 이전 build 상태가 자동 소멸된다.
- **buildLoader == null 처리**: build loader가 없으면 `initState`에서 직접 `_buildState = _BuildLoadState.empty`로 초기화. `setState` 없이 필드 직접 할당으로 initState-내-setState 문제를 회피한다.
- **build 검색 필드 조건부 표시**: `_BuildLoadState.loaded` 상태일 때만 검색 필드를 노출하고, 로딩/에러/empty 상태에서는 숨긴다.
- **`_StateBadge` 레코드 반환**: `_labelAndColor()` 메서드가 `(String, Color)` 레코드를 반환하는 Dart 3 패턴을 사용해 badge 텍스트와 색상을 함께 처리한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- build loader가 stale result를 남기지 않는지 확인한다.
- `requestedBy`, badge, summary metadata가 Roadmap Targets와 맞는지 확인한다.
- build search가 requestedBy/number/branch/flavor/artifact 모두를 포함하는지 확인한다.
- 권한 없음, 네트워크 실패, artifact 없음, 검색 결과 없음이 서로 구분되는지 확인한다.
- compact viewport에서 overflow가 없는지 test와 코드 제약을 함께 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### UI-1 중간 검증
```bash
$ flutter test test/builds_page_test.dart
(UI-1~3 일괄 구현 후 단일 flutter test로 통합 검증 — 아래 UI-3 참고)
```
### UI-2 중간 검증
```bash
$ flutter test test/builds_page_test.dart
(UI-1~3 일괄 구현 후 단일 flutter test로 통합 검증 — 아래 UI-3 참고)
```
### UI-3 중간 검증
```bash
$ flutter test
00:00 +0: loading /config/workspace/appsok/test/jenkins_client_test.dart
...
00:02 +42: All tests passed!
EXIT:0
```
### 최종 검증
```bash
$ git diff --check
(출력 없음, exit 0)
```
```bash
$ 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'\'''
Resolving dependencies...
Downloading packages...
app_links 7.0.0 (7.1.1 available)
flutter_secure_storage_darwin 0.3.2 (0.4.0 available)
matcher 0.12.19 (0.12.20 available)
> meta 1.18.0 (was 1.17.0) (1.18.3 available)
> test_api 0.7.11 (was 0.7.10) (0.7.12 available)
vector_math 2.2.0 (2.4.0 available)
webview_flutter_android 4.12.0 (4.13.0 available)
webview_flutter_wkwebview 3.25.1 (3.26.0 available)
Changed 2 dependencies!
8 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.
Analyzing appsok...
No issues found! (ran in 2.0s)
```
```bash
$ 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'\'''
00:00 +0: loading /Users/toki/docker/services/code-server/data/volume/workspace/appsok/test/jenkins_client_test.dart
...
00:02 +42: 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 (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required to proceed; do not ask the user directly during implementation |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS이므로 active plan/review를 log로 아카이브하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.

View file

@ -0,0 +1,51 @@
# Complete - m-artifact-browser/03+02_build_list_ui
## 완료 일시
2026-06-09
## 요약
Build list UI, row summaries, status badges, filtering, and build empty/error states were reviewed in loop 1 and completed with PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | PASS | Build loader state, build row metadata, search/filter, empty/error states, and widget coverage matched the plan; reviewer reran local and remote verification |
## 구현/정리 내용
- `BuildsPage` now accepts a `JenkinsBuildLoader` and renders selected-job build loading, loaded, empty, forbidden, and failure states.
- Build rows show requested-by as primary text, status badges, build number, branch, flavor, timestamp, and APK artifact names.
- Build search filters by requestedBy, build number, branch, flavor, and APK artifact fields.
- Widget tests cover build loading, row metadata, badges, filtering, error/empty states, and compact viewport overflow.
- Review repaired non-behavioral artifact/source nits: the plan header prefix and one mixed-language test comment.
## 최종 검증
- `git diff --check` - PASS; no output.
- `flutter test test/builds_page_test.dart` - PASS; `00:02 +27: All tests passed!`
- `flutter test` - PASS; `00:03 +42: All tests passed!`
- `flutter analyze` - PASS; `No issues found! (ran in 6.6s)`
- `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! (ran in 2.0s)`
- `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 +42: All tests passed!`
## Roadmap Completion
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Completed task ids:
- `requested-by`: PASS; evidence=`agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/plan_local_G06_0.log`, `agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/code_review_local_G06_0.log`; verification=`flutter analyze`, `flutter test`, remote runner `flutter analyze`, remote runner `flutter test`
- `state-badges`: PASS; evidence=`agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/plan_local_G06_0.log`, `agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/code_review_local_G06_0.log`; verification=`flutter analyze`, `flutter test`, remote runner `flutter analyze`, remote runner `flutter test`
- `summary-row`: PASS; evidence=`agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/plan_local_G06_0.log`, `agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/code_review_local_G06_0.log`; verification=`flutter analyze`, `flutter test`, remote runner `flutter analyze`, remote runner `flutter test`
- `search-filter`: PASS; evidence=`agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/plan_local_G06_0.log`, `agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/code_review_local_G06_0.log`; verification=`flutter analyze`, `flutter test`, remote runner `flutter analyze`, remote runner `flutter test`
- `empty-error`: PASS; evidence=`agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/plan_local_G06_0.log`, `agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/code_review_local_G06_0.log`; verification=`flutter analyze`, `flutter test`, remote runner `flutter analyze`, remote runner `flutter test`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,218 @@
<!-- task=m-artifact-browser/03+02_build_list_ui plan=0 tag=UI -->
# Plan - UI
## 이 파일을 읽는 구현 에이전트에게
구현 완료 전 active `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 변경 내용과 검증 출력으로 채우는 것이 필수다. 검증을 실행하고 실제 stdout/stderr를 붙여 넣은 뒤 active 파일을 유지한 채 리뷰 준비를 보고한다. archive, `complete.log` 작성, 파일명 변경은 code-review 스킬 전용이다. 사용자만 결정할 수 있는 차단, 사용자 소유 외부 환경/secret/service 준비, 또는 범위 충돌이 생기면 review stub의 `사용자 리뷰 요청` 섹션에 근거를 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 메울 수 있는 evidence 공백만으로는 사용자 리뷰 요청을 만들지 않는다.
## 배경
`build-list` Epic의 데이터 기반 작업인 `job-query`와 `apk-only`는 모델/서비스와 mock HTTP/parser 테스트로 바로 처리되었다. 남은 작업은 선택된 Jenkins job의 build 목록을 실제 화면으로 보여주는 UI 묶음이다. 현재 화면은 job 선택 뒤 placeholder만 표시하므로, build 주 정보, 상태 badge, 요약 metadata, 검색, empty/error 상태를 한 번에 설계해야 한다.
## 사용자 리뷰 요청 흐름
구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 해당 섹션은 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식을 따른다. 직접 사용자 prompt는 금지되며, code-review가 blocker 타당성을 검증하고 실제 `USER_REVIEW.md` 작성을 담당한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `requested-by`: Jenkins cause의 userName/userId를 우선하고 없으면 trigger source 또는 commit author를 fallback으로 사용해 빌드한 사람을 1차 정보로 표시한다.
- `state-badges`: success/failure/running/aborted 상태를 구분해 표시한다.
- `summary-row`: build number, branch, flavor, result, 시간, artifact 이름은 보조 정보로 요약 표시한다.
- `search-filter`: 빌드한 사람, build number, branch, flavor, artifact 이름 기준 검색/필터를 제공한다.
- `empty-error`: 권한 없음, 네트워크 실패, artifact 없음 상태를 구분해 사용자에게 보여준다.
- 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-roadmap/current.md`
- `agent-roadmap/phase/usable-mvp/PHASE.md`
- `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- `agent-ops/rules/project/domain/artifact-flow/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/artifact-flow-smoke.md`
- `agent-task/archive/2026/06/m-artifact-browser/01_job_data/complete.log`
- `agent-task/archive/2026/06/m-artifact-browser/02+01_job_select_ui/complete.log`
- `pubspec.yaml`
- `lib/src/features/builds/builds_page.dart`
- `lib/src/models/jenkins_build.dart`
- `lib/src/services/jenkins_client.dart`
- `test/builds_page_test.dart`
- `test/jenkins_client_test.dart`
### 테스트 환경 규칙
- 선택 환경: `local`.
- `agent-test/local/rules.md`: 존재하고 읽음. 기본 완료 evidence는 standard remote Mac runner `toki@toki-labs.com`, checkout `$HOME/docker/services/code-server/data/volume/workspace/appsok`.
- 적용 profile: `agent-test/local/artifact-flow-smoke.md` 읽음.
- 적용 명령: remote runner에서 `flutter analyze`, `flutter test`. Jenkins API parsing 변경 시 mock/fixture 테스트 추가.
- `<확인 필요>` 값: 없음.
- fallback: 없음. profile 명령이 충분하다.
### 테스트 커버리지 공백
- build 목록 로딩 UI: 현재 `test/builds_page_test.dart`는 job 선택 뒤 `build-list-placeholder`만 확인한다. 새 widget test 필요.
- requestedBy 1차 표시: `JenkinsBuild.requestedBy` parser test는 있으나 화면 표시 test 없음.
- state badge: 기존 테스트 없음.
- summary row metadata: 기존 테스트 없음.
- build 검색/필터: job 검색 테스트만 있고 build 검색 테스트 없음.
- forbidden/failure/artifact 없음 상태: job 상태 테스트만 있고 build 상태 테스트 없음.
### 심볼 참조
- renamed/removed symbol: none.
- 추가 예정 symbol은 `JenkinsBuildLoader`, build load state enum, build row/status widgets 정도이며 기존 call site 제거는 없다.
### 분할 판단
- split decision policy를 먼저 평가했다.
- 공유 task group: `agent-task/m-artifact-browser/`.
- 완료된 sibling: `01_job_data`는 `agent-task/archive/2026/06/m-artifact-browser/01_job_data/complete.log`로 만족.
- 완료된 sibling: `02+01_job_select_ui`는 `agent-task/archive/2026/06/m-artifact-browser/02+01_job_select_ui/complete.log`로 만족.
- 새 subtask: `03+02_build_list_ui`; predecessor index `02`는 위 archive complete.log로 만족.
- UI state, row rendering, filtering, empty/error는 같은 `BuildsPage` state machine과 widget tests를 공유한다. 별도 subtask로 쪼개면 loader/result model과 테스트 fixture가 중복되어 리뷰 비용이 커지므로 하나의 dependent subtask로 둔다.
### 범위 결정 근거
- 포함: `lib/src/features/builds/builds_page.dart`, `test/builds_page_test.dart`.
- 참조만: `lib/src/models/jenkins_build.dart`, `lib/src/services/jenkins_client.dart`.
- 제외: `install-ready` Epic, artifact 다운로드/취소/임시파일, USB 설치 handoff, real Jenkins credential wiring, app shell navigation 구조.
- 제외 이유: 이번 Roadmap Targets는 build 목록 표시와 상태/검색뿐이며, 설치 CTA와 다운로드는 다음 Epic의 별도 상태와 device-console 경계가 필요하다.
### 빌드 등급
- `local-G06`: 단일 feature 화면과 widget tests 중심의 bounded UI 작업이다. 외부 Jenkins 실서비스 없이 mock loader로 검증 가능하지만, 상태 조합과 compact viewport overflow 위험이 있어 중간 등급으로 둔다.
## 구현 체크리스트
- [ ] `BuildsPage`에 선택된 job 기준 build loader와 build load state를 추가하고 job 선택/뒤로가기 시 stale build 결과가 남지 않게 한다.
- [ ] build row에서 `requestedBy`를 1차 정보로 표시하고 success/failure/running/aborted badge와 build number, branch, flavor, 시간, APK artifact 이름 요약을 보조 정보로 표시한다.
- [ ] 빌드한 사람, build number, branch, flavor, artifact 이름 기준 build 검색/필터를 추가하고 검색 결과 없음 상태를 표시한다.
- [ ] build 조회의 401/403 권한 없음, 일반 네트워크/서버 실패, APK artifact 없음 상태를 구분해 표시한다.
- [ ] `test/builds_page_test.dart`에 loaded row, badge/summary, build search, forbidden/failure/artifact-empty, compact viewport 회귀 테스트를 추가한다.
- [ ] remote runner에서 `flutter analyze`, `flutter test`를 실행해 통과 결과를 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
### [UI-1] Build Loader State
- 문제: [builds_page.dart](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:10)는 `jobLoader`만 받고, [builds_page.dart](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:183)는 선택된 job 화면에서 placeholder만 보여준다.
- 해결 방법:
Before:
```dart
10 class BuildsPage extends StatefulWidget {
11 const BuildsPage({super.key, this.jobLoader});
12
13 final JenkinsJobLoader? jobLoader;
```
```dart
183 const Expanded(
184 child: Center(
185 child: Text(
186 'build 목록 준비 중',
187 key: ValueKey('build-list-placeholder'),
188 ),
189 ),
```
After:
```dart
typedef JenkinsBuildLoader = Future<List<JenkinsBuild>> Function(
JenkinsJob job,
);
class BuildsPage extends StatefulWidget {
const BuildsPage({super.key, this.jobLoader, this.buildLoader});
final JenkinsJobLoader? jobLoader;
final JenkinsBuildLoader? buildLoader;
}
```
`_BuildLoadState { idle, loading, loaded, empty, forbidden, failure }`와 `_loadBuilds(job)`를 추가한다. `_JobSelected`는 builds/state/query를 받아 loading, list, status를 렌더링한다.
- 수정 파일 및 체크리스트:
- [ ] `lib/src/features/builds/builds_page.dart`: `JenkinsBuildLoader` typedef와 build state fields 추가.
- [ ] `lib/src/features/builds/builds_page.dart`: job 선택 시 `_loadBuilds(job)` 실행.
- [ ] `lib/src/features/builds/builds_page.dart`: back 동작에서 selected job/build state/query 정리.
- 테스트 작성: `test/builds_page_test.dart`에 build loader 호출과 placeholder 제거/loaded state 테스트를 작성한다.
- 중간 검증: `flutter test test/builds_page_test.dart`가 통과해야 한다.
### [UI-2] Build Rows, Badges, And Filtering
- 문제: [builds_page.dart](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:154)는 selected job detail만 표시하고 build row, badge, summary, build 검색 UI가 없다.
- 해결 방법:
- `_BuildRow`를 추가해 `build.requestedBy ?? '알 수 없는 사용자'`를 주 텍스트로 표시한다.
- 상태 badge는 `result == null`이면 running, `SUCCESS`, `FAILURE`, `ABORTED`는 각각 구분하고 기타 result는 원문을 축약 표시한다.
- summary에는 `#<number>`, branch, flavor, formatted startedAt, `build.apkArtifacts.map((a) => a.fileName)`를 한 줄 또는 wrap 가능한 보조 텍스트로 표시한다.
- selected job 화면 상단에 `ValueKey('build-search-field')` 검색 필드를 추가한다.
- 검색 대상은 requestedBy, number string, branch, flavor, APK artifact fileName/relativePath다.
- 수정 파일 및 체크리스트:
- [ ] `lib/src/features/builds/builds_page.dart`: `_BuildRow`, badge helper, summary helper 추가.
- [ ] `lib/src/features/builds/builds_page.dart`: build 검색 field와 `_setBuildQuery` 추가.
- [ ] `lib/src/features/builds/builds_page.dart`: long text에 `maxLines`, `overflow`, stable padding을 적용해 compact viewport overflow를 피한다.
- 테스트 작성: `test/builds_page_test.dart`에 requestedBy primary text, badge labels, summary metadata, artifact 이름, search filter 테스트를 작성한다.
- 중간 검증: `flutter test test/builds_page_test.dart`가 통과해야 한다.
### [UI-3] Build Empty And Error States
- 문제: [builds_page.dart](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:97)는 job empty/forbidden/failure만 구분하고, build 조회의 권한 없음/네트워크 실패/artifact 없음 상태는 없다.
- 해결 방법:
- build loader가 `JenkinsClientException` 401/403을 던지면 `권한 없음` build 상태를 표시한다.
- 그 외 exception은 `build 조회 실패` 상태를 표시한다.
- loader가 빈 list를 반환하면 `.apk artifact 없음` 상태를 표시한다.
- 검색 결과만 비었을 때는 별도 `검색 결과 없음`을 표시해 artifact 없음과 구분한다.
- 수정 파일 및 체크리스트:
- [ ] `lib/src/features/builds/builds_page.dart`: build-specific `_BuildStatus` 또는 기존 `_JobStatus` 재사용 helper 추가.
- [ ] `lib/src/features/builds/builds_page.dart`: empty/error 상태 copy가 job 상태와 혼동되지 않게 문구 분리.
- [ ] `test/builds_page_test.dart`: forbidden/failure/empty/search-empty/widget compact viewport 테스트 추가.
- 테스트 작성: `test/builds_page_test.dart`에 403, 500, empty list, unmatched search, compact viewport 케이스를 작성한다.
- 중간 검증: `flutter test test/builds_page_test.dart`와 `flutter test`가 통과해야 한다.
## 의존 관계 및 구현 순서
- `03+02_build_list_ui`는 sibling predecessor `02+01_job_select_ui` 완료가 필요하다.
- predecessor `02`는 `agent-task/archive/2026/06/m-artifact-browser/02+01_job_select_ui/complete.log`로 satisfied.
- 구현 순서: UI-1 -> UI-2 -> UI-3. 이 순서는 같은 파일의 state 기반 의존성 때문이며 runtime dependency는 directory name의 `+02`가 source of truth다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `lib/src/features/builds/builds_page.dart` | UI-1, UI-2, UI-3 |
| `test/builds_page_test.dart` | UI-1, UI-2, UI-3 |
## 최종 검증
```bash
git diff --check
```
예상: 출력 없음, exit 0.
```bash
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'\'''
```
예상: `No issues found!`.
```bash
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'\'''
```
예상: `All tests passed!`. Flutter test cache output은 Dart/Flutter test runner의 정상 PASS 출력이면 허용한다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,172 @@
<!-- task=m-artifact-browser/04+03_download_staging plan=0 tag=DOWNLOAD_STAGING -->
# Code Review Reference - DOWNLOAD_STAGING
> **[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-09
task=m-artifact-browser/04+03_download_staging, plan=0, tag=DOWNLOAD_STAGING
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `download-progress`: artifact 다운로드 진행률과 취소 상태를 표시한다.
- `temp-download`: 다운로드 파일을 앱 임시/staging 경로에 저장하고 설치 완료 또는 취소 후 삭제한다.
- `artifact-verify`: 다운로드 완료 후 파일 존재, 확장자, 크기 정보를 확인한다.
- 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/04+03_download_staging/`로 이동한다. 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` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [DOWNLOAD_STAGING-1] Jenkins artifact 다운로드를 진행률/취소 가능한 흐름으로 확장하고 mock HTTP 테스트를 추가한다. | [ ] |
| [DOWNLOAD_STAGING-2] APK를 앱 임시/staging 경로에 저장하고 취소/실패/완료 cleanup 및 파일 검증 테스트를 추가한다. | [ ] |
| [DOWNLOAD_STAGING-3] `BuildsPage` 설치 CTA를 다운로드 상태 UI에 연결하고 진행률, 취소, 완료, 실패 widget test를 추가한다. | [ ] |
## 구현 체크리스트
- [ ] [DOWNLOAD_STAGING-1] Jenkins artifact 다운로드를 진행률/취소 가능한 흐름으로 확장하고 mock HTTP 테스트를 추가한다.
- [ ] [DOWNLOAD_STAGING-2] APK를 앱 임시/staging 경로에 저장하고 취소/실패/완료 cleanup 및 파일 검증 테스트를 추가한다.
- [ ] [DOWNLOAD_STAGING-3] `BuildsPage` 설치 CTA를 다운로드 상태 UI에 연결하고 진행률, 취소, 완료, 실패 widget test를 추가한다.
- [ ] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [ ] 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하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-artifact-browser/04+03_download_staging/``agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동하고 최종 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`를 작성하지 않는다.
- [ ] 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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Streaming download가 progress/cancel을 제공하면서 기존 Jenkins auth/error 계약을 깨지 않는지 확인한다.
- Partial file cleanup이 성공, 실패, 취소 경로에서 모두 보장되는지 확인한다.
- Verified APK가 존재/확장자/크기 검증을 통과해야만 다음 handoff로 넘어갈 수 있는지 확인한다.
- 테스트 출력이 실제 코드 경로와 일치하고 remote runner 검증이 포함됐는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### DOWNLOAD_STAGING-1 중간 검증
```bash
$ flutter test test/jenkins_client_test.dart
(output)
```
### DOWNLOAD_STAGING-2 중간 검증
```bash
$ flutter test test/artifact_staging_service_test.dart
(output)
```
### DOWNLOAD_STAGING-3 중간 검증
```bash
$ flutter test test/builds_page_test.dart
(output)
```
### 최종 검증
```bash
$ git diff --check
(output)
$ flutter test test/jenkins_client_test.dart
(output)
$ flutter test test/artifact_staging_service_test.dart
(output)
$ flutter test test/builds_page_test.dart
(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'\'''
(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 test'\'''
(output)
```
---
> **[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 |

View file

@ -0,0 +1,308 @@
<!-- task=m-artifact-browser/04+03_download_staging plan=0 tag=DOWNLOAD_STAGING -->
# Plan - DOWNLOAD_STAGING
## 이 파일을 읽는 구현 에이전트에게
구현의 마지막 단계는 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이다. 검증 명령을 실행하고 실제 출력, 계획 대비 변경, 설계 결정을 기록한 뒤 active 파일을 유지한 상태로 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌 없이는 진행할 수 없을 때만 review stub의 `사용자 리뷰 요청` 섹션에 근거를 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행으로 메울 수 있는 증거 공백은 사용자 리뷰 요청 사유가 아니다. finalization, `complete.log`, archive 이동은 code-review-skill 전용이다.
## 배경
`install-cta`는 build/artifact 선택 직후 설치 진입점을 노출하지만, 아직 APK를 임시 staging 경로로 내려받거나 진행률과 취소를 보여주지 않는다. 이 작업은 Jenkins artifact 다운로드를 사용자가 신뢰할 수 있는 상태 전이로 만들고, 설치 handoff가 사용할 verified local APK 산출물을 만든다. 실제 `adb install` 실행과 device 선택은 다음 split 작업과 `device-console`/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:
- `download-progress`: artifact 다운로드 진행률과 취소 상태를 표시한다.
- `temp-download`: 다운로드 파일을 앱 임시/staging 경로에 저장하고 설치 완료 또는 취소 후 삭제한다.
- `artifact-verify`: 다운로드 완료 후 파일 존재, 확장자, 크기 정보를 확인한다.
- 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-test/local/rules.md`
- `agent-test/local/artifact-flow-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/usable-mvp/PHASE.md`
- `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- `agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/complete.log`
- `pubspec.yaml`
- `lib/src/features/builds/builds_page.dart`
- `lib/src/models/jenkins_build.dart`
- `lib/src/services/jenkins_client.dart`
- `test/builds_page_test.dart`
- `test/jenkins_client_test.dart`
### 테스트 환경 규칙
- 선택 환경: `local`.
- `agent-test/local/rules.md`가 존재하며 읽었다. Flutter/macOS/ADB/runtime evidence는 standard remote Mac runner 기준이다.
- matched profile: `agent-test/local/artifact-flow-smoke.md`를 읽었다.
- 적용 명령:
- local preflight: `git diff --check`, `flutter test test/builds_page_test.dart`, `flutter test test/jenkins_client_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 또는 `<확인 필요>` 값은 없었다.
### 테스트 커버리지 공백
- `JenkinsClient.downloadArtifact`는 현재 `bodyBytes` 반환만 검증이 없고, 진행률/취소/streaming 동작 테스트가 없다.
- 임시/staging 경로 저장, 취소 후 삭제, 완료 후 verify 동작을 검증하는 테스트가 없다.
- `BuildsPage`는 선택 CTA callback 테스트가 있지만 다운로드 중/완료/취소/error UI 테스트가 없다.
### 심볼 참조
- 이름 변경 또는 제거 예정 심볼: 없음.
- 확장 대상:
- `JenkinsClient.downloadArtifact`: `rg --sort path -n "downloadArtifact"` 결과 현재 정의만 존재한다.
- `BuildsPage.onInstallRequested`: `lib/src/features/builds/builds_page.dart`, `test/builds_page_test.dart`에서 사용 중이다.
### 분할 판단
split decision policy를 계획 파일 선택 전에 평가했다. 공유 task group은 `agent-task/m-artifact-browser/`다.
- `04+03_download_staging`: `03` 선행 완료 후 다운로드/임시파일/검증을 구현한다. predecessor `03``agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/complete.log`로 충족됨.
- `05+04_install_handoff`: `04` 완료 후 verified APK 경로를 device 선택/USB 설치 흐름으로 넘긴다. predecessor `04`는 아직 active/archive `complete.log`가 없으므로 구현 시작 전 `agent-task/m-artifact-browser/04+03_download_staging/complete.log` 또는 archive의 matching complete.log가 필요하다.
다운로드 I/O와 device handoff는 도메인 경계, 테스트 전략, 실패 복구 방식이 달라 분리한다.
### 범위 결정 근거
- 포함: Jenkins artifact streaming download, UI progress/cancel/error/success 상태, temp/staging 파일 생성과 cleanup, 완료 파일 검증.
- 제외: `adb install` 실행, device 선택 UI 변경, logcat 이동, Jenkins credential 획득 방식 변경, 실제 Jenkins endpoint 수동 smoke. 해당 항목은 handoff split 또는 다른 Milestone 책임이다.
- 새 패키지는 추가하지 않는다. `pubspec.yaml``path_provider``http`가 이미 있다.
### 빌드 등급
- `local-G07`: 비동기 다운로드/취소와 파일 cleanup이 있어 일반 UI보다 위험이 높지만, artifact-flow 안에 갇히고 mock HTTP/widget/unit test로 결정적으로 검증 가능하다.
## 구현 체크리스트
- [ ] [DOWNLOAD_STAGING-1] Jenkins artifact 다운로드를 진행률/취소 가능한 흐름으로 확장하고 mock HTTP 테스트를 추가한다.
- [ ] [DOWNLOAD_STAGING-2] APK를 앱 임시/staging 경로에 저장하고 취소/실패/완료 cleanup 및 파일 검증 테스트를 추가한다.
- [ ] [DOWNLOAD_STAGING-3] `BuildsPage` 설치 CTA를 다운로드 상태 UI에 연결하고 진행률, 취소, 완료, 실패 widget test를 추가한다.
- [ ] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [DOWNLOAD_STAGING-1] Download Progress And Cancel
### 문제
`JenkinsClient.downloadArtifact`는 [lib/src/services/jenkins_client.dart](/config/workspace/appsok/lib/src/services/jenkins_client.dart:65)에서 `http.get` 전체 응답을 받은 뒤 bytes만 반환한다. 이 구조는 진행률을 표시할 수 없고, 취소 상태를 UI에 반영하기 어렵다.
Before:
```dart
// lib/src/services/jenkins_client.dart:65
Future<List<int>> downloadArtifact({
required Uri artifactUrl,
required JenkinsCredentials credentials,
}) async {
final response = await _client.get(
artifactUrl,
headers: _headers(credentials),
);
_throwIfFailed(response);
return response.bodyBytes;
}
```
### 해결 방법
- `http.Client.send` 기반 streaming 다운로드 API를 추가한다. 기존 `downloadArtifact`는 테스트 호환이 필요하면 wrapper로 유지한다.
- progress payload에는 `receivedBytes`, `totalBytes?`, `isComplete`를 포함한다.
- 취소는 구현 범위를 작게 유지하기 위해 download task/controller 내부 flag와 response stream subscription cancel로 처리한다. `http.Client.close()`를 전체 client에 호출해 다른 요청을 깨는 방식은 사용하지 않는다.
- HTTP 실패는 기존 `JenkinsClientException` 계약을 유지한다.
After 방향:
```dart
final task = client.downloadArtifactStream(
artifactUrl: artifactUrl,
credentials: credentials,
);
await for (final event in task.events) {
// update progress and write chunks through staging service
}
task.cancel();
```
### 수정 파일 및 체크리스트
- [ ] `lib/src/services/jenkins_client.dart`: streaming download API, progress event, cancel task를 추가한다.
- [ ] `test/jenkins_client_test.dart`: chunked response, content-length progress, forbidden response, cancel path를 검증한다.
### 테스트 작성
- 작성: `test/jenkins_client_test.dart`
- test names:
- `downloadArtifactStream emits progress while reading chunks`
- `downloadArtifactStream reports forbidden response`
- `downloadArtifactStream can be cancelled before completion`
### 중간 검증
```bash
flutter test test/jenkins_client_test.dart
```
예상: 모든 Jenkins client unit test 통과.
## [DOWNLOAD_STAGING-2] Temp Staging And Verification
### 문제
다운로드 파일을 저장할 staging 책임이 없다. Milestone은 임시/staging 경로 저장, 취소/설치 완료 후 삭제, 완료 후 파일 존재/확장자/크기 확인을 요구한다.
Before:
```dart
// lib/src/services/jenkins_client.dart:74
_throwIfFailed(response);
return response.bodyBytes;
```
### 해결 방법
- artifact-flow 책임 안에 staging helper를 만든다. 새 파일이 필요하면 `lib/src/services/artifact_staging_service.dart`로 한정한다.
- path 결정은 `path_provider.getTemporaryDirectory()`를 쓰되, 테스트는 `Directory` injection으로 실제 앱 temp에 의존하지 않는다.
- 파일명은 artifact `fileName`을 기준으로 하되 path separator를 제거하고 build number/job name을 포함해 충돌과 오설치를 줄인다.
- verify 결과는 파일 존재, `.apk` 확장자, byte size > 0을 포함한다.
- 취소/실패 시 partial file을 삭제한다. 설치 완료 cleanup은 handoff 이후 호출 가능한 method로 남기고, 이 plan에서는 method와 unit test까지만 만든다.
After 방향:
```dart
final staged = await stagingService.stageApk(
build: build,
artifact: artifact,
stream: task.events,
);
final verified = await stagingService.verify(staged.path);
```
### 수정 파일 및 체크리스트
- [ ] `lib/src/services/artifact_staging_service.dart` 또는 기존 service 파일: staging, cleanup, verify API를 구현한다.
- [ ] `test/artifact_staging_service_test.dart`: temp directory injection으로 success/cancel/failure cleanup과 verify를 검증한다.
### 테스트 작성
- 작성: `test/artifact_staging_service_test.dart`
- test names:
- `stageApk writes downloaded bytes into temp directory`
- `stageApk deletes partial file when cancelled`
- `verify rejects non apk extension and zero byte file`
### 중간 검증
```bash
flutter test test/artifact_staging_service_test.dart
```
예상: staging service unit test 통과.
## [DOWNLOAD_STAGING-3] Builds UI Download States
### 문제
`_InstallCta`는 [lib/src/features/builds/builds_page.dart](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:513)에서 선택된 APK를 callback으로 넘길 뿐 다운로드 상태를 표시하지 않는다. 사용자는 다운로드가 시작됐는지, 얼마나 진행됐는지, 취소됐는지 알 수 없다.
Before:
```dart
// lib/src/features/builds/builds_page.dart:513
ElevatedButton.icon(
key: const ValueKey('install-cta-button'),
onPressed: onInstallRequested == null
? null
: () => onInstallRequested!(item, artifact),
icon: const Icon(Icons.install_mobile),
label: const Text('설치'),
),
```
### 해결 방법
- `BuildsPage`에는 테스트 가능한 downloader/staging dependency를 optional로 주입한다. production 기본값은 기존 Jenkins credential wiring이 준비되기 전이면 null/disabled 상태를 유지한다.
- 버튼 클릭 후 상태를 `idle -> downloading -> verified` 또는 `failed/cancelled`로 관리한다.
- 진행률은 `LinearProgressIndicator`와 받은 byte/전체 byte 텍스트로 표시한다. total bytes가 없으면 indeterminate로 표시한다.
- 취소 버튼은 다운로드 subscription과 partial cleanup을 호출한다.
- verified 상태는 다음 split의 handoff callback이 받을 수 있는 local APK path를 보관한다. 실제 device 이동은 이 plan에서 하지 않는다.
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/builds/builds_page.dart`: CTA 상태, progress/cancel/success/error UI를 추가한다.
- [ ] `test/builds_page_test.dart`: 진행률, 취소, 완료, 실패 상태 widget test를 추가한다.
- [ ] 필요 시 service fake/helper를 test 안에 둔다. tracked secret이나 private endpoint는 쓰지 않는다.
### 테스트 작성
- 작성: `test/builds_page_test.dart`
- test names:
- `shows download progress after install CTA pressed`
- `cancels artifact download and hides partial state`
- `shows verified apk state after staging succeeds`
- `shows download failure state when staging fails`
### 중간 검증
```bash
flutter test test/builds_page_test.dart
```
예상: build page widget test 통과.
## 의존 관계 및 구현 순서
`04+03_download_staging`는 predecessor `03`이 완료된 뒤 시작한다. 충족 근거: `agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/complete.log`.
구현 순서:
1. `DOWNLOAD_STAGING-1`
2. `DOWNLOAD_STAGING-2`
3. `DOWNLOAD_STAGING-3`
4. 최종 검증과 review stub 작성
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `lib/src/services/jenkins_client.dart` | DOWNLOAD_STAGING-1 |
| `lib/src/services/artifact_staging_service.dart` | DOWNLOAD_STAGING-2 |
| `lib/src/features/builds/builds_page.dart` | DOWNLOAD_STAGING-3 |
| `test/jenkins_client_test.dart` | DOWNLOAD_STAGING-1 |
| `test/artifact_staging_service_test.dart` | DOWNLOAD_STAGING-2 |
| `test/builds_page_test.dart` | DOWNLOAD_STAGING-3 |
## 최종 검증
```bash
git diff --check
flutter test test/jenkins_client_test.dart
flutter test test/artifact_staging_service_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 문제 없음, local focused tests 통과, remote `flutter analyze`에서 `No issues found!`, remote `flutter test`에서 `All tests passed!`.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,167 @@
<!-- task=m-artifact-browser/05+04_install_handoff plan=0 tag=INSTALL_HANDOFF -->
# Code Review Reference - 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-09
task=m-artifact-browser/05+04_install_handoff, plan=0, tag=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-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-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` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [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을 정의한다.
- [ ] [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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[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하는지 확인한다.
- [ ] 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`를 작성하지 않는다.
- [ ] 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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `05+04``04+03` complete.log 이후 구현됐는지 확인한다.
- Handoff가 verified APK path를 잃지 않고 shell/device UI로 전달되는지 확인한다.
- 실제 `adb install` 실행으로 범위가 새지 않았는지 확인한다.
- Shell/device tests가 compact viewport와 pending install 표시를 검증하는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- 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)
```
### INSTALL_HANDOFF-2 중간 검증
```bash
$ flutter test test/widget_test.dart
(output)
```
### INSTALL_HANDOFF-3 중간 검증
```bash
$ flutter test test/widget_test.dart
(output)
```
### 최종 검증
```bash
$ git diff --check
(output)
$ flutter test test/builds_page_test.dart
(output)
$ flutter test test/widget_test.dart
(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'\'''
(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 test'\'''
(output)
```
---
> **[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 |

View file

@ -0,0 +1,260 @@
<!-- task=m-artifact-browser/05+04_install_handoff plan=0 tag=INSTALL_HANDOFF -->
# Plan - INSTALL_HANDOFF
## 이 파일을 읽는 구현 에이전트에게
구현의 마지막 단계는 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이다. 검증 명령을 실행하고 실제 출력, 계획 대비 변경, 설계 결정을 기록한 뒤 active 파일을 유지한 상태로 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌 없이는 진행할 수 없을 때만 review stub의 `사용자 리뷰 요청` 섹션에 근거를 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행으로 메울 수 있는 증거 공백은 사용자 리뷰 요청 사유가 아니다. finalization, `complete.log`, archive 이동은 code-review-skill 전용이다.
## 배경
다운로드/staging 작업이 verified local APK path를 만들면 그 경로를 device 선택과 USB 설치 흐름으로 넘겨야 한다. 이 plan은 artifact-flow에서 나온 산출물을 app shell/device UI 경계로 전달하는 것까지만 다룬다. 실제 `adb install -r` 실행의 완성은 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/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-test/local/rules.md`
- `agent-test/local/artifact-flow-smoke.md`
- `agent-roadmap/current.md`
- `agent-roadmap/phase/usable-mvp/PHASE.md`
- `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- `agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/complete.log`
- `lib/src/features/builds/builds_page.dart`
- `lib/src/features/app_shell.dart`
- `lib/src/features/devices/devices_page.dart`
- `lib/src/models/jenkins_build.dart`
- `lib/src/services/adb_service.dart`
- `test/builds_page_test.dart`
- `test/widget_test.dart`
### 테스트 환경 규칙
- 선택 환경: `local`.
- `agent-test/local/rules.md`가 존재하며 읽었다. Flutter/macOS/ADB/runtime evidence는 standard remote Mac runner 기준이다.
- matched profile: `agent-test/local/artifact-flow-smoke.md`를 읽었다. Handoff는 artifact-flow 산출물을 넘기는 작업이며 실제 ADB install은 이 plan에서 제외한다.
- 적용 명령:
- 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`은 [lib/src/features/app_shell.dart](/config/workspace/appsok/lib/src/features/app_shell.dart:18)에서 page list가 `const`이고, BuildsPage callback이 shell state로 올라가지 않는다.
- `DevicesPage`는 [lib/src/features/devices/devices_page.dart](/config/workspace/appsok/lib/src/features/devices/devices_page.dart:9)에서 sample device만 렌더링하고 pending APK path 입력이 없다.
- Shell/widget tests는 install handoff state나 device page pending install banner를 검증하지 않는다.
### 심볼 참조
- 이름 변경 또는 제거 예정 심볼: 없음.
- 확장 대상:
- `BuildsPage.onInstallRequested`: 현재 build/artifact callback이며 `05+04` 구현 시 `04+03`의 verified staged APK callback과 연결해야 한다.
- `DevicesPage`: 현재 생성자 인자가 없다. `rg --sort path -n "DevicesPage"` 결과 `lib/src/features/app_shell.dart``lib/src/features/devices/devices_page.dart`가 call site다.
### 분할 판단
split decision policy를 계획 파일 선택 전에 평가했다. 공유 task group은 `agent-task/m-artifact-browser/`다.
- `04+03_download_staging`: 먼저 verified APK path를 만들어야 한다.
- `05+04_install_handoff`: predecessor `04`가 완료되어야 시작한다. 현재 active/archive `complete.log`는 없으므로 구현 시작 전 `agent-task/m-artifact-browser/04+03_download_staging/complete.log` 또는 archive의 matching complete.log가 필요하다.
이 plan은 device-console 경계를 건드리므로 다운로드 구현과 분리했다. 디렉터리명 `05+04_install_handoff`가 runtime dependency source of truth다.
### 범위 결정 근거
- 포함: verified APK handoff state, shell page 전환 또는 pending install 표시, device page가 APK path/build/artifact 요약을 받는 UI, widget test.
- 제외: 실제 `AdbService.installApk` 호출, device discovery 구현, logcat 연동, Jenkins credential 변경, staging cleanup 완료 트리거. 실제 설치 실행은 USB 설치 Milestone에서 다룬다.
- `lib/src/services/adb_service.dart`는 경계 확인을 위해 읽었지만 이 plan의 기본 수정 대상은 아니다.
### 빌드 등급
- `local-G06`: shell/device UI state 연결과 widget coverage 중심이며, predecessor만 충족되면 변경 범위가 작고 review-detectable하다.
## 구현 체크리스트
- [ ] [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의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [INSTALL_HANDOFF-1] Handoff Contract
### 문제
`BuildsPage`는 [lib/src/features/builds/builds_page.dart](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:11)의 `InstallRequestHandler`로 build/artifact만 넘긴다. `04+03_download_staging` 이후에는 verified local APK path와 metadata를 함께 넘기는 계약이 필요하다.
Before:
```dart
// lib/src/features/builds/builds_page.dart:11
typedef InstallRequestHandler =
void Function(JenkinsBuild build, BuildArtifact artifact);
```
### 해결 방법
- `04+03_download_staging`의 complete.log와 코드 상태를 먼저 확인한다.
- verified staged APK 결과 타입이 이미 있으면 재사용한다. 없으면 artifact-flow 안에 작은 handoff value object를 둔다.
- build number, job name, artifact file name, local path, byte size를 포함한다.
- 기존 `onInstallRequested`의 의미가 다운로드 시작 callback으로 남았는지, verified handoff callback으로 바뀌었는지 코드 상태에 맞춰 이름을 정리한다. 이름 변경 시 모든 call site를 `rg --sort path`로 갱신한다.
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/builds/builds_page.dart` 또는 `lib/src/models/*`: verified APK handoff 계약을 정의/재사용한다.
- [ ] `test/builds_page_test.dart`: verified APK callback이 shell로 올라갈 수 있는 형태인지 확인한다.
### 테스트 작성
- 작성: `test/builds_page_test.dart`
- test name: `notifies verified apk handoff after download is verified`
### 중간 검증
```bash
flutter test test/builds_page_test.dart
```
예상: build page widget test 통과.
## [INSTALL_HANDOFF-2] Shell State Routing
### 문제
`AppSokShell`은 [lib/src/features/app_shell.dart](/config/workspace/appsok/lib/src/features/app_shell.dart:18)에서 `_pages``const` list로 보관한다. 이 구조에서는 BuildsPage에서 올라온 verified APK path를 DevicesPage로 전달하거나 디바이스 화면으로 전환할 수 없다.
Before:
```dart
// lib/src/features/app_shell.dart:18
final _pages = const [
_ShellPage(
label: '빌드',
icon: Icons.inventory_2_outlined,
selectedIcon: Icons.inventory_2,
child: BuildsPage(),
),
```
### 해결 방법
- `_pages`를 build-time getter 또는 method로 바꾸어 shell state를 child에 주입할 수 있게 한다.
- shell state에 pending install request를 보관한다.
- verified APK handoff를 받으면 `_selectedIndex`를 device page index로 바꾸거나, 최소한 DevicesPage에 pending install banner를 표시한다. UX는 "바로 설치 흐름으로 넘김"에 맞춰 device page 전환을 우선한다.
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/app_shell.dart`: pending install state와 page construction을 추가한다.
- [ ] `test/widget_test.dart`: build에서 verified handoff 발생 시 device page가 pending install 상태를 받는지 검증한다.
### 테스트 작성
- 작성: `test/widget_test.dart`
- test name: `routes verified apk handoff to devices page`
### 중간 검증
```bash
flutter test test/widget_test.dart
```
예상: shell widget test 통과.
## [INSTALL_HANDOFF-3] Device Page Pending Install UI
### 문제
`DevicesPage`는 [lib/src/features/devices/devices_page.dart](/config/workspace/appsok/lib/src/features/devices/devices_page.dart:9)에서 sample devices만 보여준다. 다운로드된 APK path가 넘어와도 사용자가 어떤 APK를 설치 대상으로 보고 있는지 알 수 없다.
Before:
```dart
// lib/src/features/devices/devices_page.dart:9
Widget build(BuildContext context) {
final devices = _sampleDevices;
```
### 해결 방법
- `DevicesPage`에 optional pending install model을 주입한다.
- 상단에 APK filename, build number, local path 요약을 표시한다.
- device tile의 "설치 대상" 버튼은 pending install이 없으면 disabled 또는 기존 no-op 상태를 유지하고, pending install이 있으면 다음 USB 설치 Milestone이 받을 수 있는 callback을 호출한다.
- 실제 `AdbService.installApk` 호출은 하지 않는다.
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/devices/devices_page.dart`: pending install banner와 button state를 추가한다.
- [ ] `test/widget_test.dart` 또는 신규 `test/devices_page_test.dart`: pending install 표시와 overflow를 검증한다.
### 테스트 작성
- 작성: `test/widget_test.dart` 또는 `test/devices_page_test.dart`
- test names:
- `shows pending apk install summary on devices page`
- `keeps device page compact viewport free of overflow with pending apk`
### 중간 검증
```bash
flutter test test/widget_test.dart
```
예상: shell/device widget test 통과.
## 의존 관계 및 구현 순서
`05+04_install_handoff`는 predecessor `04` 완료 후 시작한다. 현재 `04` complete.log는 없다. 구현 시작 전 다음 중 하나가 있어야 한다.
- active: `agent-task/m-artifact-browser/04+03_download_staging/complete.log`
- archive: `agent-task/archive/*/*/m-artifact-browser/04+03_download_staging/complete.log`
구현 순서:
1. predecessor `04` complete.log 확인
2. `INSTALL_HANDOFF-1`
3. `INSTALL_HANDOFF-2`
4. `INSTALL_HANDOFF-3`
5. 최종 검증과 review stub 작성
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `lib/src/features/builds/builds_page.dart` | INSTALL_HANDOFF-1 |
| `lib/src/features/app_shell.dart` | INSTALL_HANDOFF-2 |
| `lib/src/features/devices/devices_page.dart` | INSTALL_HANDOFF-3 |
| `test/builds_page_test.dart` | INSTALL_HANDOFF-1 |
| `test/widget_test.dart` | INSTALL_HANDOFF-2, INSTALL_HANDOFF-3 |
## 최종 검증
```bash
git diff --check
flutter test test/builds_page_test.dart
flutter test test/widget_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 문제 없음, local focused tests 통과, remote `flutter analyze`에서 `No issues found!`, remote `flutter test`에서 `All tests passed!`.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -5,12 +5,27 @@ import '../../services/jenkins_client.dart';
typedef JenkinsJobLoader = Future<List<JenkinsJob>> Function();
typedef JenkinsBuildLoader =
Future<List<JenkinsBuild>> Function(JenkinsJob job);
typedef InstallRequestHandler =
void Function(JenkinsBuild build, BuildArtifact artifact);
enum _JobLoadState { loading, loaded, empty, forbidden, failure }
enum _BuildLoadState { loading, loaded, empty, forbidden, failure }
class BuildsPage extends StatefulWidget {
const BuildsPage({super.key, this.jobLoader});
const BuildsPage({
super.key,
this.jobLoader,
this.buildLoader,
this.onInstallRequested,
});
final JenkinsJobLoader? jobLoader;
final JenkinsBuildLoader? buildLoader;
final InstallRequestHandler? onInstallRequested;
@override
State<BuildsPage> createState() => _BuildsPageState();
@ -77,7 +92,10 @@ class _BuildsPageState extends State<BuildsPage> {
padding: const EdgeInsets.fromLTRB(28, 0, 28, 28),
child: _selectedJob != null
? _JobSelected(
key: ValueKey(_selectedJob!.url),
job: _selectedJob!,
buildLoader: widget.buildLoader,
onInstallRequested: widget.onInstallRequested,
onBack: () => setState(() => _selectedJob = null),
)
: _buildJobSelector(context),
@ -151,11 +169,96 @@ class _BuildsPageState extends State<BuildsPage> {
}
}
class _JobSelected extends StatelessWidget {
const _JobSelected({required this.job, required this.onBack});
class _JobSelected extends StatefulWidget {
const _JobSelected({
super.key,
required this.job,
required this.onBack,
this.buildLoader,
this.onInstallRequested,
});
final JenkinsJob job;
final VoidCallback onBack;
final JenkinsBuildLoader? buildLoader;
final InstallRequestHandler? onInstallRequested;
@override
State<_JobSelected> createState() => _JobSelectedState();
}
class _JobSelectedState extends State<_JobSelected> {
late _BuildLoadState _buildState;
List<JenkinsBuild> _builds = const [];
List<JenkinsBuild> _filtered = const [];
JenkinsBuild? _selectedBuild;
@override
void initState() {
super.initState();
if (widget.buildLoader != null) {
_buildState = _BuildLoadState.loading;
_loadBuilds();
} else {
_buildState = _BuildLoadState.empty;
}
}
Future<void> _loadBuilds() async {
try {
final builds = await widget.buildLoader!(widget.job);
if (!mounted) return;
setState(() {
_builds = builds;
_filtered = builds;
_selectedBuild = null;
_buildState = builds.isEmpty
? _BuildLoadState.empty
: _BuildLoadState.loaded;
});
} on JenkinsClientException catch (e) {
if (!mounted) return;
setState(() {
_buildState = (e.statusCode == 401 || e.statusCode == 403)
? _BuildLoadState.forbidden
: _BuildLoadState.failure;
});
} catch (_) {
if (!mounted) return;
setState(() => _buildState = _BuildLoadState.failure);
}
}
void _setBuildQuery(String query) {
setState(() {
if (query.isEmpty) {
_filtered = _builds;
} else {
final q = query.toLowerCase();
_filtered = _builds.where((b) {
return (b.requestedBy?.toLowerCase().contains(q) ?? false) ||
b.number.toString().contains(q) ||
(b.branch?.toLowerCase().contains(q) ?? false) ||
(b.flavor?.toLowerCase().contains(q) ?? false) ||
b.apkArtifacts.any(
(a) =>
a.fileName.toLowerCase().contains(q) ||
a.relativePath.toLowerCase().contains(q),
);
}).toList();
}
if (_selectedBuild != null && !_filtered.contains(_selectedBuild)) {
_selectedBuild = null;
}
});
}
void _selectBuild(JenkinsBuild build) {
setState(() {
_selectedBuild = identical(_selectedBuild, build) ? null : build;
});
}
@override
Widget build(BuildContext context) {
@ -167,28 +270,328 @@ class _JobSelected extends StatelessWidget {
IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: '목록으로',
onPressed: onBack,
onPressed: widget.onBack,
),
const SizedBox(width: 8),
Expanded(
child: Text(
job.fullName ?? job.name,
widget.job.fullName ?? widget.job.name,
style: Theme.of(context).textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
const Expanded(
child: Center(
child: Text(
'build 목록 준비 중',
key: ValueKey('build-list-placeholder'),
if (_buildState == _BuildLoadState.loaded) ...[
TextField(
key: const ValueKey('build-search-field'),
decoration: const InputDecoration(
labelText: 'Build 검색',
prefixIcon: Icon(Icons.search),
),
onChanged: _setBuildQuery,
),
const SizedBox(height: 12),
],
Expanded(child: _buildBody(context)),
],
);
}
Widget _buildBody(BuildContext context) {
return switch (_buildState) {
_BuildLoadState.loading => const Center(
child: CircularProgressIndicator(),
),
_BuildLoadState.forbidden => const _BuildStatus(
icon: Icons.lock,
label: 'build 조회 권한 없음',
description: 'Jenkins 인증이 필요하거나 이 job에 대한 접근 권한이 없습니다.',
),
_BuildLoadState.failure => const _BuildStatus(
icon: Icons.wifi_off,
label: 'build 조회 실패',
description: '네트워크 오류 또는 Jenkins 서버에 연결할 수 없습니다.',
),
_BuildLoadState.empty => const _BuildStatus(
icon: Icons.inbox,
label: 'APK artifact 없음',
description: '이 job에 접근 가능한 build가 없습니다.',
),
_BuildLoadState.loaded =>
_filtered.isEmpty
? Center(
child: Text(
'검색 결과 없음',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
)
: Column(
children: [
Expanded(
child: ListView.separated(
itemCount: _filtered.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final item = _filtered[index];
return _BuildRow(
item: item,
selected: identical(_selectedBuild, item),
onTap: () => _selectBuild(item),
);
},
),
),
if (_selectedBuild case final selectedBuild?)
if (selectedBuild.apkArtifacts case [final artifact, ...])
Padding(
padding: const EdgeInsets.only(top: 12),
child: _InstallCta(
item: selectedBuild,
artifact: artifact,
onInstallRequested: widget.onInstallRequested,
),
),
],
),
};
}
}
class _BuildRow extends StatelessWidget {
const _BuildRow({
required this.item,
required this.selected,
required this.onTap,
});
final JenkinsBuild item;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final apkNames = item.apkArtifacts.map((a) => a.fileName).join(', ');
final summaryParts = [
'#${item.number}',
if (item.branch != null) item.branch!,
if (item.flavor != null) item.flavor!,
if (item.startedAt != null) _formatDate(item.startedAt!),
if (apkNames.isNotEmpty) apkNames,
];
return Material(
key: ValueKey('build-row-${item.number}'),
color: selected
? colorScheme.primaryContainer.withValues(alpha: 0.36)
: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: selected ? colorScheme.primary : colorScheme.outlineVariant,
width: selected ? 1.5 : 1,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_StateBadge(item: item),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.requestedBy ?? '알 수 없는 사용자',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (summaryParts.isNotEmpty)
Text(
summaryParts.join(' · '),
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Icon(
selected ? Icons.radio_button_checked : Icons.circle_outlined,
color: selected
? colorScheme.primary
: colorScheme.onSurfaceVariant,
size: 20,
),
],
),
),
],
),
);
}
String _formatDate(DateTime dt) {
final mo = dt.month.toString().padLeft(2, '0');
final d = dt.day.toString().padLeft(2, '0');
final h = dt.hour.toString().padLeft(2, '0');
final mi = dt.minute.toString().padLeft(2, '0');
return '${dt.year}-$mo-$d $h:$mi';
}
}
class _InstallCta extends StatelessWidget {
const _InstallCta({
required this.item,
required this.artifact,
required this.onInstallRequested,
});
final JenkinsBuild item;
final BuildArtifact artifact;
final InstallRequestHandler? onInstallRequested;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Material(
key: const ValueKey('install-cta'),
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(
'설치 준비',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'#${item.number} · ${artifact.fileName}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 12),
ElevatedButton.icon(
key: const ValueKey('install-cta-button'),
onPressed: onInstallRequested == null
? null
: () => onInstallRequested!(item, artifact),
icon: const Icon(Icons.install_mobile),
label: const Text('설치'),
),
],
),
),
);
}
}
class _StateBadge extends StatelessWidget {
const _StateBadge({required this.item});
final JenkinsBuild item;
@override
Widget build(BuildContext context) {
final (label, color) = _labelAndColor();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: color.withValues(alpha: 0.4)),
),
child: Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
(String, Color) _labelAndColor() {
if (item.isRunning) return ('실행 중', Colors.blue);
return switch (item.result) {
'SUCCESS' => ('성공', Colors.green),
'FAILURE' => ('실패', Colors.red),
'ABORTED' => ('중단됨', Colors.orange),
_ => (item.result ?? '알 수 없음', Colors.grey),
};
}
}
class _BuildStatus extends StatelessWidget {
const _BuildStatus({
required this.icon,
required this.label,
required this.description,
});
final IconData icon;
final String label;
final String description;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 48, color: colorScheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(label, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(color: colorScheme.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
),
);
}
}

View file

@ -33,6 +33,7 @@ class JenkinsBuild {
required this.startedAt,
required this.result,
required this.artifacts,
this.requestedBy,
this.branch,
this.flavor,
});
@ -43,10 +44,13 @@ class JenkinsBuild {
final DateTime? startedAt;
final String? result;
final List<BuildArtifact> artifacts;
final String? requestedBy;
final String? branch;
final String? flavor;
bool get isRunning => result == null;
List<BuildArtifact> get apkArtifacts =>
artifacts.where((artifact) => artifact.isApk).toList(growable: false);
factory JenkinsBuild.fromJson(
Map<String, Object?> json, {
@ -63,6 +67,7 @@ class JenkinsBuild {
? DateTime.fromMillisecondsSinceEpoch(timestamp)
: null,
result: json['result'] as String?,
requestedBy: _requestedBy(json),
branch:
_parameterValue(json, 'BRANCH_NAME') ??
_parameterValue(json, 'branch'),
@ -99,6 +104,106 @@ class JenkinsBuild {
return null;
}
static String? _requestedBy(Map<String, Object?> json) {
return _causeUser(json) ?? _causeTrigger(json) ?? _commitAuthor(json);
}
static String? _causeUser(Map<String, Object?> json) {
for (final cause in _causes(json)) {
final userName = _stringValue(cause['userName']);
if (userName != null) {
return userName;
}
final userId = _stringValue(cause['userId']);
if (userId != null) {
return userId;
}
}
return null;
}
static String? _causeTrigger(Map<String, Object?> json) {
for (final cause in _causes(json)) {
final shortDescription = _stringValue(cause['shortDescription']);
if (shortDescription != null) {
return shortDescription;
}
final upstreamProject = _stringValue(cause['upstreamProject']);
if (upstreamProject != null) {
final upstreamBuild = _stringValue(cause['upstreamBuild']);
return upstreamBuild == null
? upstreamProject
: '$upstreamProject #$upstreamBuild';
}
}
return null;
}
static String? _commitAuthor(Map<String, Object?> json) {
final changeSet = json['changeSet'];
if (changeSet is! Map<String, Object?>) {
return null;
}
final items = changeSet['items'];
if (items is! List) {
return null;
}
for (final item in items.whereType<Map<String, Object?>>()) {
final author = item['author'];
if (author is Map<String, Object?>) {
final fullName = _stringValue(author['fullName']);
if (fullName != null) {
return fullName;
}
final id = _stringValue(author['id']);
if (id != null) {
return id;
}
}
final authorEmail = _stringValue(item['authorEmail']);
if (authorEmail != null) {
return authorEmail;
}
}
return null;
}
static Iterable<Map<String, Object?>> _causes(
Map<String, Object?> json,
) sync* {
final actions = json['actions'];
if (actions is! List) {
return;
}
for (final action in actions.whereType<Map<String, Object?>>()) {
final causes = action['causes'];
if (causes is! List) {
continue;
}
yield* causes.whereType<Map<String, Object?>>();
}
}
static String? _stringValue(Object? value) {
if (value == null) {
return null;
}
final text = '$value'.trim();
return text.isEmpty ? null : text;
}
}
class BuildArtifact {
@ -107,6 +212,12 @@ class BuildArtifact {
final String fileName;
final String relativePath;
bool get isApk {
final lowerFileName = fileName.toLowerCase();
final lowerRelativePath = relativePath.toLowerCase();
return lowerFileName.endsWith('.apk') || lowerRelativePath.endsWith('.apk');
}
factory BuildArtifact.fromJson(Map<String, Object?> json) {
return BuildArtifact(
fileName: json['fileName'] as String,

View file

@ -42,7 +42,7 @@ class JenkinsClient {
path: _appendPath(jobUrl.path, 'api/json'),
queryParameters: {
'tree':
'builds[number,url,timestamp,result,actions[parameters[name,value]],artifacts[fileName,relativePath]]',
'builds[number,url,timestamp,result,actions[parameters[name,value],causes[userName,userId,shortDescription,upstreamProject,upstreamBuild]],changeSet[items[author[fullName,id],authorEmail]],artifacts[fileName,relativePath]]',
},
);
final response = await _client.get(uri, headers: _headers(credentials));
@ -58,6 +58,7 @@ class JenkinsClient {
return builds
.whereType<Map<String, Object?>>()
.map((build) => JenkinsBuild.fromJson(build, jobName: jobName))
.where((build) => build.apkArtifacts.isNotEmpty)
.toList();
}

View file

@ -23,7 +23,29 @@ List<JenkinsJob> _sampleJobs() => [
),
];
JenkinsBuild _makeBuild({
int number = 1,
String? result = 'SUCCESS',
String? requestedBy = 'toki',
String? branch = 'main',
String? flavor,
List<BuildArtifact> artifacts = const [],
DateTime? startedAt,
}) => JenkinsBuild(
number: number,
jobName: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/$number/'),
startedAt: startedAt,
result: result,
artifacts: artifacts,
requestedBy: requestedBy,
branch: branch,
flavor: flavor,
);
void main() {
// Job ()
testWidgets('renders login required state without job loader', (
tester,
) async {
@ -59,21 +81,6 @@ void main() {
expect(find.text('ios-build'), findsNothing);
});
testWidgets('selects a job and shows build list placeholder', (tester) async {
await tester.pumpWidget(
_wrap(BuildsPage(jobLoader: () async => _sampleJobs())),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('build-list-placeholder')),
findsOneWidget,
);
});
testWidgets('shows empty state when no jobs are returned', (tester) async {
await tester.pumpWidget(_wrap(BuildsPage(jobLoader: () async => [])));
await tester.pumpAndSettle();
@ -129,4 +136,496 @@ void main() {
expect(tester.takeException(), isNull);
});
// UI-1: Build Loader State
testWidgets('selects a job and loads builds — placeholder replaced', (
tester,
) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [_makeBuild()],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('build-list-placeholder')), findsNothing);
expect(find.byKey(const ValueKey('build-search-field')), findsOneWidget);
});
testWidgets(
'shows artifact empty state when job selected without build loader',
(tester) async {
await tester.pumpWidget(
_wrap(BuildsPage(jobLoader: () async => _sampleJobs())),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('APK artifact 없음'), findsOneWidget);
},
);
testWidgets('reloads builds when back pressed and job reselected', (
tester,
) async {
var loadCount = 0;
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async {
loadCount++;
return [_makeBuild()];
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(loadCount, 1);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(loadCount, 2);
});
// UI-2: Build Rows, Badges, Filtering
testWidgets('shows requestedBy as primary text in build row', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [_makeBuild(requestedBy: 'alice')],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('alice'), findsOneWidget);
});
testWidgets('shows fallback text when requestedBy is null', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [_makeBuild(requestedBy: null)],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('알 수 없는 사용자'), findsOneWidget);
});
testWidgets('shows success badge for successful build', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [_makeBuild(result: 'SUCCESS')],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('성공'), findsOneWidget);
});
testWidgets('shows failure badge for failed build', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [_makeBuild(result: 'FAILURE')],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('실패'), findsOneWidget);
});
testWidgets('shows running badge for in-progress build', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [_makeBuild(result: null)],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('실행 중'), findsOneWidget);
});
testWidgets('shows aborted badge for aborted build', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [_makeBuild(result: 'ABORTED')],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('중단됨'), findsOneWidget);
});
testWidgets('shows build number and branch in summary row', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [_makeBuild(number: 42, branch: 'release')],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.textContaining('#42'), findsOneWidget);
expect(find.textContaining('release'), findsOneWidget);
});
testWidgets('shows apk artifact name in summary row', (tester) async {
const apk = BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/apk/release/app-release.apk',
);
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [
_makeBuild(artifacts: const [apk]),
],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.textContaining('app-release.apk'), findsOneWidget);
});
testWidgets('filters builds by requestedBy', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [
_makeBuild(number: 1, requestedBy: 'alice'),
_makeBuild(number: 2, requestedBy: 'bob'),
],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('build-search-field')),
'alice',
);
await tester.pump();
// 'alice' TextField row findsWidgets .
expect(find.text('alice'), findsWidgets);
expect(find.text('bob'), findsNothing);
});
testWidgets('filters builds by build number', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [
_makeBuild(number: 42, requestedBy: 'alice'),
_makeBuild(number: 99, requestedBy: 'bob'),
],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('build-search-field')),
'42',
);
await tester.pump();
expect(find.text('alice'), findsOneWidget);
expect(find.text('bob'), findsNothing);
});
testWidgets('filters builds by artifact file name', (tester) async {
const apk1 = BuildArtifact(
fileName: 'app-staging.apk',
relativePath: 'outputs/app-staging.apk',
);
const apk2 = BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
);
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [
_makeBuild(
number: 1,
requestedBy: 'alice',
artifacts: const [apk1],
),
_makeBuild(number: 2, requestedBy: 'bob', artifacts: const [apk2]),
],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('build-search-field')),
'staging',
);
await tester.pump();
expect(find.text('alice'), findsOneWidget);
expect(find.text('bob'), findsNothing);
});
testWidgets('shows search empty when no builds match query', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [_makeBuild(requestedBy: 'alice')],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
await tester.enterText(
find.byKey(const ValueKey('build-search-field')),
'zzznomatch',
);
await tester.pump();
expect(find.text('검색 결과 없음'), findsOneWidget);
});
testWidgets('shows install CTA when apk build row is selected', (
tester,
) async {
const apk = BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
);
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [
_makeBuild(number: 7, artifacts: const [apk]),
],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('install-cta')), findsNothing);
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
expect(find.byKey(const ValueKey('install-cta')), findsOneWidget);
expect(find.text('설치 준비'), findsOneWidget);
expect(find.text('설치'), findsOneWidget);
expect(find.textContaining('app-release.apk'), findsWidgets);
});
testWidgets('requests install with selected build and apk artifact', (
tester,
) async {
const apk = BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
);
JenkinsBuild? requestedBuild;
BuildArtifact? requestedArtifact;
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [
_makeBuild(number: 7, artifacts: const [apk]),
],
onInstallRequested: (build, artifact) {
requestedBuild = build;
requestedArtifact = artifact;
},
),
),
);
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')));
expect(requestedBuild?.number, 7);
expect(requestedArtifact?.fileName, 'app-release.apk');
expect(requestedArtifact?.relativePath, 'outputs/app-release.apk');
});
// UI-3: Build Empty And Error States
testWidgets('shows build forbidden state for 403', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => throw const JenkinsClientException(
statusCode: 403,
message: 'Forbidden',
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('build 조회 권한 없음'), findsOneWidget);
});
testWidgets('shows build forbidden state for 401', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => throw const JenkinsClientException(
statusCode: 401,
message: 'Unauthorized',
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('build 조회 권한 없음'), findsOneWidget);
});
testWidgets('shows build failure state for server error', (tester) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => throw const JenkinsClientException(
statusCode: 500,
message: 'Server Error',
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('build 조회 실패'), findsOneWidget);
});
testWidgets('shows artifact empty state for empty build list', (
tester,
) async {
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(find.text('APK artifact 없음'), findsOneWidget);
});
testWidgets(
'renders selected job view without overflow at compact viewport',
(tester) async {
tester.view.physicalSize = const Size(600, 400);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
const apk = BuildArtifact(
fileName: 'very-long-artifact-name-that-might-overflow.apk',
relativePath:
'outputs/release/very-long-artifact-name-that-might-overflow.apk',
);
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [
_makeBuild(
requestedBy: 'very-long-user-name-that-could-overflow-the-row',
branch: 'feature/very-long-branch-name-for-testing',
flavor: 'productionReleaseFlavor',
artifacts: const [apk],
),
],
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
},
);
}

View file

@ -60,6 +60,106 @@ void main() {
});
});
group('JenkinsBuild.fromJson', () {
test(
'parses build metadata, requested user, parameters, and artifacts',
() {
final build = JenkinsBuild.fromJson({
'number': 42,
'url': 'https://jenkins.example/job/app/42/',
'timestamp': 1760000000000,
'result': 'SUCCESS',
'actions': [
{
'causes': [
{'userName': 'Ada Lovelace', 'userId': 'ada'},
],
'parameters': [
{'name': 'BRANCH_NAME', 'value': 'feature/install'},
{'name': 'FLAVOR', 'value': 'qa'},
],
},
],
'artifacts': [
{'fileName': 'app-qa.apk', 'relativePath': 'outputs/app-qa.apk'},
],
}, jobName: 'android-app');
expect(build.number, 42);
expect(build.jobName, 'android-app');
expect(build.result, 'SUCCESS');
expect(build.isRunning, isFalse);
expect(build.requestedBy, 'Ada Lovelace');
expect(build.branch, 'feature/install');
expect(build.flavor, 'qa');
expect(build.artifacts.single.fileName, 'app-qa.apk');
expect(build.apkArtifacts.single.relativePath, 'outputs/app-qa.apk');
},
);
test('falls back from cause trigger source to commit author', () {
final upstreamBuild = JenkinsBuild.fromJson({
'number': 41,
'url': 'https://jenkins.example/job/app/41/',
'result': null,
'actions': [
{
'causes': [
{
'shortDescription': 'Started by upstream project smoke',
'upstreamProject': 'smoke',
'upstreamBuild': 77,
},
],
},
],
'changeSet': {
'items': [
{
'author': {'fullName': 'Grace Hopper'},
},
],
},
'artifacts': [],
}, jobName: 'android-app');
final commitBuild = JenkinsBuild.fromJson({
'number': 40,
'url': 'https://jenkins.example/job/app/40/',
'result': 'FAILURE',
'actions': [],
'changeSet': {
'items': [
{
'author': {'id': 'hopper'},
},
],
},
'artifacts': [],
}, jobName: 'android-app');
expect(upstreamBuild.isRunning, isTrue);
expect(upstreamBuild.requestedBy, 'Started by upstream project smoke');
expect(commitBuild.requestedBy, 'hopper');
});
});
group('BuildArtifact', () {
test('detects apk artifacts case-insensitively', () {
final artifact = BuildArtifact(
fileName: 'APP-QA.APK',
relativePath: 'outputs/app-qa.APK',
);
final mapping = BuildArtifact(
fileName: 'mapping.txt',
relativePath: 'outputs/mapping.txt',
);
expect(artifact.isApk, isTrue);
expect(mapping.isApk, isFalse);
});
});
group('JenkinsClient.fetchJobs', () {
test('fetchJobs requests root api json with job tree', () async {
Uri? capturedUri;
@ -154,4 +254,95 @@ void main() {
},
);
});
group('JenkinsClient.fetchRecentBuilds', () {
test(
'requests build tree and returns only builds with apk artifacts',
() async {
Uri? capturedUri;
Map<String, String>? capturedHeaders;
final jobUrl = Uri.parse('https://jenkins.example/job/android-app/');
final client = JenkinsClient(
client: MockClient((request) async {
capturedUri = request.url;
capturedHeaders = request.headers;
return http.Response(
jsonEncode({
'builds': [
{
'number': 12,
'url': 'https://jenkins.example/job/android-app/12/',
'timestamp': 1760000000000,
'result': 'SUCCESS',
'actions': [
{
'causes': [
{'userName': 'Ada Lovelace', 'userId': 'ada'},
],
'parameters': [
{'name': 'branch', 'value': 'main'},
{'name': 'flavor', 'value': 'qa'},
],
},
],
'artifacts': [
{
'fileName': 'android-app-qa.apk',
'relativePath': 'outputs/android-app-qa.apk',
},
{
'fileName': 'mapping.txt',
'relativePath': 'outputs/mapping.txt',
},
],
},
{
'number': 11,
'url': 'https://jenkins.example/job/android-app/11/',
'timestamp': 1750000000000,
'result': 'FAILURE',
'actions': [],
'artifacts': [
{
'fileName': 'mapping.txt',
'relativePath': 'outputs/mapping.txt',
},
],
},
],
}),
200,
);
}),
);
final builds = await client.fetchRecentBuilds(
jobUrl: jobUrl,
jobName: 'android-app',
credentials: credentials,
);
expect(builds, hasLength(1));
expect(builds.single.number, 12);
expect(builds.single.requestedBy, 'Ada Lovelace');
expect(builds.single.result, 'SUCCESS');
expect(builds.single.branch, 'main');
expect(builds.single.flavor, 'qa');
expect(builds.single.artifacts, hasLength(2));
expect(
builds.single.apkArtifacts.single.fileName,
'android-app-qa.apk',
);
expect(capturedUri?.path, '/job/android-app/api/json');
expect(
capturedUri?.queryParameters['tree'],
'builds[number,url,timestamp,result,actions[parameters[name,value],causes[userName,userId,shortDescription,upstreamProject,upstreamBuild]],changeSet[items[author[fullName,id],authorEmail]],artifacts[fileName,relativePath]]',
);
final expectedAuth = 'Basic ${base64Encode(utf8.encode('user:token'))}';
expect(capturedHeaders?['Authorization'], expectedAuth);
},
);
});
}