feat(artifact-browser): 빌드 브라우저 변경사항을 반영한다
Jenkins 빌드 리스트/클라이언트/모델 변경 및 테스트를 함께 반영하고, 로드맵 산출물도 갱신한다.
This commit is contained in:
parent
74d1f40465
commit
f99a089324
14 changed files with 2643 additions and 42 deletions
|
|
@ -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`로 설치한다.
|
||||
|
|
|
|||
|
|
@ -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 선택
|
||||
- 확인 필요: 없음
|
||||
|
|
|
|||
|
|
@ -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로 이동한다.
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue