From f99a0893241c894df7bee798b2427f50c81847b7 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 9 Jun 2026 21:54:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(artifact-browser):=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=ED=95=AD=EC=9D=84=20=EB=B0=98=EC=98=81=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jenkins 빌드 리스트/클라이언트/모델 변경 및 테스트를 함께 반영하고, 로드맵 산출물도 갱신한다. --- agent-roadmap/phase/usable-mvp/PHASE.md | 6 +- .../usable-mvp/milestones/artifact-browser.md | 30 +- .../code_review_local_G06_0.log | 214 +++++++ .../03+02_build_list_ui/complete.log | 51 ++ .../03+02_build_list_ui/plan_local_G06_0.log | 218 ++++++++ .../CODE_REVIEW-local-G07.md | 172 ++++++ .../04+03_download_staging/PLAN-local-G07.md | 308 ++++++++++ .../CODE_REVIEW-local-G06.md | 167 ++++++ .../05+04_install_handoff/PLAN-local-G06.md | 260 +++++++++ lib/src/features/builds/builds_page.dart | 425 +++++++++++++- lib/src/models/jenkins_build.dart | 111 ++++ lib/src/services/jenkins_client.dart | 3 +- test/builds_page_test.dart | 529 +++++++++++++++++- test/jenkins_client_test.dart | 191 +++++++ 14 files changed, 2643 insertions(+), 42 deletions(-) create mode 100644 agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/code_review_local_G06_0.log create mode 100644 agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/complete.log create mode 100644 agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/plan_local_G06_0.log create mode 100644 agent-task/m-artifact-browser/04+03_download_staging/CODE_REVIEW-local-G07.md create mode 100644 agent-task/m-artifact-browser/04+03_download_staging/PLAN-local-G07.md create mode 100644 agent-task/m-artifact-browser/05+04_install_handoff/CODE_REVIEW-local-G06.md create mode 100644 agent-task/m-artifact-browser/05+04_install_handoff/PLAN-local-G06.md diff --git a/agent-roadmap/phase/usable-mvp/PHASE.md b/agent-roadmap/phase/usable-mvp/PHASE.md index c049028..0a8ce26 100644 --- a/agent-roadmap/phase/usable-mvp/PHASE.md +++ b/agent-roadmap/phase/usable-mvp/PHASE.md @@ -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`로 설치한다. diff --git a/agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md b/agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md index f0d8a90..8ce165d 100644 --- a/agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md +++ b/agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md @@ -11,7 +11,7 @@ ## 상태 -[계획] +[진행중] ## 승격 조건 @@ -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 선택 - 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/code_review_local_G06_0.log b/agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/code_review_local_G06_0.log new file mode 100644 index 0000000..af2f8d3 --- /dev/null +++ b/agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/code_review_local_G06_0.log @@ -0,0 +1,214 @@ + + +# 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/complete.log b/agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/complete.log new file mode 100644 index 0000000..06e43bf --- /dev/null +++ b/agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/plan_local_G06_0.log b/agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/plan_local_G06_0.log new file mode 100644 index 0000000..274d314 --- /dev/null +++ b/agent-task/archive/2026/06/m-artifact-browser/03+02_build_list_ui/plan_local_G06_0.log @@ -0,0 +1,218 @@ + + +# 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> 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에는 `#`, 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-artifact-browser/04+03_download_staging/CODE_REVIEW-local-G07.md b/agent-task/m-artifact-browser/04+03_download_staging/CODE_REVIEW-local-G07.md new file mode 100644 index 0000000..da2c994 --- /dev/null +++ b/agent-task/m-artifact-browser/04+03_download_staging/CODE_REVIEW-local-G07.md @@ -0,0 +1,172 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 | diff --git a/agent-task/m-artifact-browser/04+03_download_staging/PLAN-local-G07.md b/agent-task/m-artifact-browser/04+03_download_staging/PLAN-local-G07.md new file mode 100644 index 0000000..4d3d9b3 --- /dev/null +++ b/agent-task/m-artifact-browser/04+03_download_staging/PLAN-local-G07.md @@ -0,0 +1,308 @@ + + +# 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> 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-artifact-browser/05+04_install_handoff/CODE_REVIEW-local-G06.md b/agent-task/m-artifact-browser/05+04_install_handoff/CODE_REVIEW-local-G06.md new file mode 100644 index 0000000..bd87d02 --- /dev/null +++ b/agent-task/m-artifact-browser/05+04_install_handoff/CODE_REVIEW-local-G06.md @@ -0,0 +1,167 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 | diff --git a/agent-task/m-artifact-browser/05+04_install_handoff/PLAN-local-G06.md b/agent-task/m-artifact-browser/05+04_install_handoff/PLAN-local-G06.md new file mode 100644 index 0000000..527e620 --- /dev/null +++ b/agent-task/m-artifact-browser/05+04_install_handoff/PLAN-local-G06.md @@ -0,0 +1,260 @@ + + +# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/lib/src/features/builds/builds_page.dart b/lib/src/features/builds/builds_page.dart index a02117c..9a89c09 100644 --- a/lib/src/features/builds/builds_page.dart +++ b/lib/src/features/builds/builds_page.dart @@ -5,12 +5,27 @@ import '../../services/jenkins_client.dart'; typedef JenkinsJobLoader = Future> Function(); +typedef JenkinsBuildLoader = + Future> 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 createState() => _BuildsPageState(); @@ -77,7 +92,10 @@ class _BuildsPageState extends State { 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 { } } -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 _builds = const []; + List _filtered = const []; + JenkinsBuild? _selectedBuild; + + @override + void initState() { + super.initState(); + if (widget.buildLoader != null) { + _buildState = _BuildLoadState.loading; + _loadBuilds(); + } else { + _buildState = _BuildLoadState.empty; + } + } + + Future _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, + ), + ], + ), ); } } diff --git a/lib/src/models/jenkins_build.dart b/lib/src/models/jenkins_build.dart index 0336647..35d7507 100644 --- a/lib/src/models/jenkins_build.dart +++ b/lib/src/models/jenkins_build.dart @@ -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 artifacts; + final String? requestedBy; final String? branch; final String? flavor; bool get isRunning => result == null; + List get apkArtifacts => + artifacts.where((artifact) => artifact.isApk).toList(growable: false); factory JenkinsBuild.fromJson( Map 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 json) { + return _causeUser(json) ?? _causeTrigger(json) ?? _commitAuthor(json); + } + + static String? _causeUser(Map 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 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 json) { + final changeSet = json['changeSet']; + if (changeSet is! Map) { + return null; + } + + final items = changeSet['items']; + if (items is! List) { + return null; + } + + for (final item in items.whereType>()) { + final author = item['author']; + if (author is Map) { + 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> _causes( + Map json, + ) sync* { + final actions = json['actions']; + if (actions is! List) { + return; + } + + for (final action in actions.whereType>()) { + final causes = action['causes']; + if (causes is! List) { + continue; + } + + yield* causes.whereType>(); + } + } + + 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 json) { return BuildArtifact( fileName: json['fileName'] as String, diff --git a/lib/src/services/jenkins_client.dart b/lib/src/services/jenkins_client.dart index 9e7eb52..184a610 100644 --- a/lib/src/services/jenkins_client.dart +++ b/lib/src/services/jenkins_client.dart @@ -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((build) => JenkinsBuild.fromJson(build, jobName: jobName)) + .where((build) => build.apkArtifacts.isNotEmpty) .toList(); } diff --git a/test/builds_page_test.dart b/test/builds_page_test.dart index 0336717..c18a571 100644 --- a/test/builds_page_test.dart +++ b/test/builds_page_test.dart @@ -23,7 +23,29 @@ List _sampleJobs() => [ ), ]; +JenkinsBuild _makeBuild({ + int number = 1, + String? result = 'SUCCESS', + String? requestedBy = 'toki', + String? branch = 'main', + String? flavor, + List 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); + }, + ); } diff --git a/test/jenkins_client_test.dart b/test/jenkins_client_test.dart index 72b2591..9a2c357 100644 --- a/test/jenkins_client_test.dart +++ b/test/jenkins_client_test.dart @@ -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? 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); + }, + ); + }); }