From ce859e1cc0cd71109feb10e242b5c004db27f62f Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 13 Jun 2026 09:44:43 +0900 Subject: [PATCH] feat: jenkins credential integration and UI updates - Add jenkins credential web login entry implementation - Update roadmap for jenkins credential phase - Archive usb-install milestone - Update app shell and devices page for jenkins integration - Add jenkins client service and update models - Update tests for changed services --- .../usable-mvp/milestones/usb-install.md | 24 +- agent-roadmap/phase/usable-mvp/PHASE.md | 8 +- .../milestones/jenkins-credential.md | 4 +- .../CODE_REVIEW-local-G05.md | 153 +++++ .../01_web_login_entry/PLAN-local-G05.md | 226 +++++++ .../CODE_REVIEW-cloud-G06.md | 156 +++++ .../02+01_crumb_token_store/PLAN-cloud-G06.md | 227 +++++++ lib/src/app.dart | 12 + lib/src/features/app_shell.dart | 12 +- lib/src/features/devices/devices_page.dart | 375 +++++++++-- lib/src/models/adb_device.dart | 4 + lib/src/services/adb_service.dart | 24 +- lib/src/services/jenkins_client.dart | 40 +- test/adb_service_test.dart | 63 +- test/builds_page_test.dart | 30 + test/jenkins_client_test.dart | 147 +++-- test/widget_test.dart | 580 +++++++++++++----- 17 files changed, 1761 insertions(+), 324 deletions(-) rename agent-roadmap/{ => archive}/phase/usable-mvp/milestones/usb-install.md (79%) create mode 100644 agent-task/m-jenkins-credential/01_web_login_entry/CODE_REVIEW-local-G05.md create mode 100644 agent-task/m-jenkins-credential/01_web_login_entry/PLAN-local-G05.md create mode 100644 agent-task/m-jenkins-credential/02+01_crumb_token_store/CODE_REVIEW-cloud-G06.md create mode 100644 agent-task/m-jenkins-credential/02+01_crumb_token_store/PLAN-cloud-G06.md diff --git a/agent-roadmap/phase/usable-mvp/milestones/usb-install.md b/agent-roadmap/archive/phase/usable-mvp/milestones/usb-install.md similarity index 79% rename from agent-roadmap/phase/usable-mvp/milestones/usb-install.md rename to agent-roadmap/archive/phase/usable-mvp/milestones/usb-install.md index 027b1ce..e99fc6c 100644 --- a/agent-roadmap/phase/usable-mvp/milestones/usb-install.md +++ b/agent-roadmap/archive/phase/usable-mvp/milestones/usb-install.md @@ -11,7 +11,7 @@ USB로 연결된 Android device를 표시하고, 선택한 단일 APK를 `adb in ## 상태 -[진행중] +[완료] ## 승격 조건 @@ -49,9 +49,9 @@ USB로 연결된 Android device를 표시하고, 선택한 단일 APK를 `adb in 연결된 Android device를 사용자가 이해할 수 있는 형태로 보여준다. -- [ ] [parse-devices] `adb devices -l` 결과를 serial/state/model/product로 parsing한다. 검증: `device`, `unauthorized`, `offline` fixture test가 통과한다. -- [ ] [device-state] USB debugging 승인 필요, offline, ready 상태를 UI에서 구분한다. -- [ ] [refresh] 사용자가 device 목록을 새로고침할 수 있다. +- [x] [parse-devices] `adb devices -l` 결과를 serial/state/model/product로 parsing한다. 검증: `device`, `unauthorized`, `offline` fixture test가 통과한다. +- [x] [device-state] USB debugging 승인 필요, offline, ready 상태를 UI에서 구분한다. +- [x] [refresh] 사용자가 device 목록을 새로고침할 수 있다. ### Epic: [install] APK 설치 @@ -65,13 +65,17 @@ USB로 연결된 Android device를 표시하고, 선택한 단일 APK를 `adb in ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증 충족 후 기록한다. +- 상태: 승인됨 +- 요청일: 2026-06-13 +- 승인일: 2026-06-13 +- 완료 근거: + - `device-list` Task는 `AdbService.listDevices` fixture test, 실제 loader 기반 UI 상태 표시, 새로고침 재조회 widget test로 검증했다. + - remote Mac runner에서 `flutter analyze`, `flutter test`, `adb devices -l`, `flutter devices`를 확인했다. + - remote Mac runner에서 `flutter build macos`, bundled `adb version`, `otool -L` 확인이 통과했다. - 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 - - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: 없음 + - [x] 사용자가 코드 레벨 완료 검토와 archive 이동을 요청했다 + - [x] 코드 레벨 보완 이슈를 수정하고 검증했다 +- 리뷰 코멘트: 코드 레벨 검토 중 실제 `AppSokApp` 경로에서 `adb install` wiring이 빠진 작은 이슈를 수정했다. local/remote `flutter analyze`, `flutter test`, remote ADB/device smoke, remote macOS build와 bundled `adb` 확인이 통과해 완료 처리한다. ## 범위 제외 diff --git a/agent-roadmap/phase/usable-mvp/PHASE.md b/agent-roadmap/phase/usable-mvp/PHASE.md index f1409c5..98b2480 100644 --- a/agent-roadmap/phase/usable-mvp/PHASE.md +++ b/agent-roadmap/phase/usable-mvp/PHASE.md @@ -20,12 +20,12 @@ Jenkins에서 Android artifact를 찾고, Mac에 내려받고, USB로 연결된 - [완료] Artifact 탐색과 다운로드 - 경로: `agent-roadmap/archive/phase/usable-mvp/milestones/artifact-browser.md` - 요약: Jenkins job/build/artifact를 조회하고 APK를 Mac에 캐시한다. -- [계획] Jenkins 로그인과 credential 수명주기 +- [완료] USB 설치와 device 선택 + - 경로: `agent-roadmap/archive/phase/usable-mvp/milestones/usb-install.md` + - 요약: 연결된 Android device를 고르고 선택한 APK를 `adb install -r`로 설치한다. +- [진행중] Jenkins 로그인과 credential 수명주기 - 경로: `agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md` - 요약: 사용자별 Jenkins API token 또는 WebView 기반 token 자동화 흐름을 안전하게 저장/검증한다. -- [진행중] USB 설치와 device 선택 - - 경로: `agent-roadmap/phase/usable-mvp/milestones/usb-install.md` - - 요약: 연결된 Android device를 고르고 선택한 APK를 `adb install -r`로 설치한다. - [계획] ADB 콘솔과 logcat - 경로: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md` - 요약: 설치 후 바로 문제를 볼 수 있도록 logcat stream, filter, pause/clear 기능을 제공한다. diff --git a/agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md b/agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md index f1f5f7d..1350123 100644 --- a/agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md +++ b/agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md @@ -11,7 +11,7 @@ AppSok이 Jenkins에 접근할 수 있는 사용자인지 확인하고, 사용 ## 상태 -[계획] +[진행중] ## 승격 조건 @@ -40,7 +40,7 @@ AppSok이 Jenkins에 접근할 수 있는 사용자인지 확인하고, 사용 - [ ] [login-webview] Jenkins 로그인 URL을 WebView로 열고 로그인 완료를 감지한다. - [ ] [crumb-token] WebView same-origin 요청으로 crumb를 받고 사용자별 API token을 자동 발급한다. - [ ] [keychain-store] 자동 발급한 username/API token을 `TokenStore`를 통해 macOS Keychain에 저장하고 삭제할 수 있다. 검증: mock storage 기반 unit test에서 save/read/clear가 통과한다. -- [ ] [redaction] API token이 화면 오류, debug log, test fixture에 원문으로 남지 않도록 처리한다. +- [x] [redaction] API token이 화면 오류, debug log, test fixture에 원문으로 남지 않도록 처리한다. ### Epic: [session-restore] 로그인 상태 복원 diff --git a/agent-task/m-jenkins-credential/01_web_login_entry/CODE_REVIEW-local-G05.md b/agent-task/m-jenkins-credential/01_web_login_entry/CODE_REVIEW-local-G05.md new file mode 100644 index 0000000..f78ee6c --- /dev/null +++ b/agent-task/m-jenkins-credential/01_web_login_entry/CODE_REVIEW-local-G05.md @@ -0,0 +1,153 @@ + + +# Code Review Reference - WEB_LOGIN + +> **[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`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-13 +task=m-jenkins-credential/01_web_login_entry, plan=0, tag=WEB_LOGIN + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md` +- Task ids: + - `login-webview`: Jenkins 로그인 URL을 WebView로 열고 로그인 완료를 감지한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-{review_lane}-GNN.md` → `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` → `plan_{build_lane}_GNN_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [WEB_LOGIN-1] Settings Web Login Entry | [ ] | +| [WEB_LOGIN-2] WebView Login Completion Probe | [ ] | + +## 구현 체크리스트 + +- [ ] Settings Jenkins 영역에 base URL controller/validation과 Web Login 실행 버튼을 추가하고, invalid URL에서는 실행하지 않는다. +- [ ] WebView login page 또는 launcher adapter를 추가해 Jenkins login URL을 열고 same-origin `/whoAmI/api/json` probe 성공 시 `login-webview` 완료 결과를 반환한다. +- [ ] SettingsPage focused widget test와 completion probe unit test를 추가한다. +- [ ] `flutter test test/settings_page_test.dart` 또는 추가한 focused test 명령을 통과시킨다. +- [ ] 최종 검증 명령을 실행한다. +- [ ] 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/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 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`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- SettingsPage가 invalid Jenkins URL에서 Web Login을 실행하지 않는지 확인한다. +- WebView completion probe가 token/crumb 발급 없이 `/whoAmI` 성공만 로그인 완료로 판단하는지 확인한다. +- 실제 WebView platform view 검증은 macOS build evidence로 보완되었는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### WEB_LOGIN-1 중간 검증 +``` +$ flutter test test/settings_page_test.dart +(output) +``` + +### WEB_LOGIN-2 중간 검증 +``` +$ flutter test test/settings_page_test.dart test/jenkins_web_login_page_test.dart +(output) +``` + +### 최종 검증 +``` +$ zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze' +(output) +$ zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test' +(output) +$ zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter build macos' +(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. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify | +| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only | +| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 | Implementing agent | Fill command output only | +| 코드리뷰 결과 | Review agent appends | Not included in stub | diff --git a/agent-task/m-jenkins-credential/01_web_login_entry/PLAN-local-G05.md b/agent-task/m-jenkins-credential/01_web_login_entry/PLAN-local-G05.md new file mode 100644 index 0000000..118f4f7 --- /dev/null +++ b/agent-task/m-jenkins-credential/01_web_login_entry/PLAN-local-G05.md @@ -0,0 +1,226 @@ + + +# Plan - WEB_LOGIN + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 구현 후 검증을 실행하고 실제 변경 내용, 설계 결정, 명령 출력은 review stub에 남긴다. active 파일은 그대로 두고 리뷰 준비 상태를 보고하며, archive/log rename/complete.log 작성은 code-review 전용이다. 사용자만 결정할 수 있는 blocker가 생기면 review stub의 `사용자 리뷰 요청`에 근거를 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 `USER_REVIEW.md`, `complete.log`를 직접 만들지 않는다. + +## 배경 + +현재 Settings의 `Web Login` segment는 선택만 되어 있고 Jenkins 로그인 WebView를 열지 않는다. `web-login` Epic의 첫 작업은 사용자가 Jenkins URL을 입력하고 WebView에서 로그인 완료를 감지하는 얇은 진입점을 만드는 것이다. API token 발급과 Keychain 저장은 같은 세션이 필요하지만 인증/저장 위험이 커서 다음 종속 plan으로 분리한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자 입력을 요청하지 않고, code-review가 사용자 리뷰 필요 여부와 `USER_REVIEW.md` 작성을 판단한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md` +- Task ids: + - `login-webview`: Jenkins 로그인 URL을 WebView로 열고 로그인 완료를 감지한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/usable-mvp/PHASE.md` +- `agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md` +- `agent-ops/rules/project/domain/artifact-flow/rules.md` +- `agent-ops/rules/project/domain/macos-platform/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/artifact-flow-smoke.md` +- `agent-test/local/macos-platform-smoke.md` +- `pubspec.yaml` +- `/config/.pub-cache/hosted/pub.dev/webview_flutter-4.13.1/pubspec.yaml` +- `/config/.pub-cache/hosted/pub.dev/webview_flutter-4.13.1/README.md` +- `/config/.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.25.1/pubspec.yaml` +- `lib/src/app.dart` +- `lib/src/features/app_shell.dart` +- `lib/src/features/settings/settings_page.dart` +- `lib/src/services/jenkins_client.dart` +- `lib/src/services/token_store.dart` +- `test/builds_page_test.dart` +- `test/jenkins_client_test.dart` +- `test/token_store_test.dart` +- `test/widget_test.dart` + +### 테스트 환경 규칙 + +- 선택 환경: `local`. +- `agent-test/local/rules.md`를 읽었고, artifact-flow smoke와 macos-platform smoke profile을 읽었다. +- 적용 명령: remote runner 기준 `zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze'`, `zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test'`. +- WebView macOS plugin 사용을 컴파일로 확인해야 하므로 `zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter build macos'`를 최종 검증에 포함한다. +- 현재 checkout에서는 `flutter test test/jenkins_client_test.dart test/builds_page_test.dart`가 redaction 변경 후 통과했다. remote runner evidence는 이 plan 구현 완료 때 새로 수집한다. + +### 테스트 커버리지 공백 + +- Settings의 Jenkins base URL 입력, Web Login 버튼 활성화, launcher 호출은 기존 테스트가 없다. 새 focused widget test를 작성한다. +- 실제 `WebViewWidget`은 widget test에서 플랫폼 view 의존성이 있으므로, SettingsPage는 injectable launcher로 테스트하고 WebView completion 감지는 pure probe/adapter 단위 테스트로 검증한다. +- macOS WKWebView plugin 등록과 빌드는 기존 unit test가 보장하지 않으므로 `flutter build macos`로 보완한다. + +### 심볼 참조 + +- renamed/removed symbol: none. + +### 분할 판단 + +- split decision policy를 먼저 평가했다. +- 공유 task group: `m-jenkins-credential`. +- `01_web_login_entry`: 독립 작업. WebView 진입, URL validation, 로그인 완료 감지만 담당한다. +- `02+01_crumb_token_store`: `01_web_login_entry`의 complete.log가 필요한 종속 작업. crumb/token 발급과 Keychain 저장은 auth/security 위험이 커서 분리한다. +- 이 plan은 predecessor가 없다. + +### 범위 결정 근거 + +- 포함: Settings Jenkins 영역, WebView login page/adapter, login completion result model, focused tests. +- 제외: crumb fetch, API token generation, TokenStore 저장 wiring, logout/reauth flow, Jenkins endpoint별 실제 수동 로그인 검증. +- macOS entitlement 파일은 수정하지 않는다. 현재 Jenkins network client entitlement가 필요하지만 이 plan은 새 entitlement를 추가하지 않는다. + +### 빌드 등급 + +- `local-G05`: UI entry와 adapter 경계는 명확하고 테스트를 injection으로 고정할 수 있으나 WebView/macOS build 확인이 필요하다. + +## 구현 체크리스트 + +- [ ] Settings Jenkins 영역에 base URL controller/validation과 Web Login 실행 버튼을 추가하고, invalid URL에서는 실행하지 않는다. +- [ ] WebView login page 또는 launcher adapter를 추가해 Jenkins login URL을 열고 same-origin `/whoAmI/api/json` probe 성공 시 `login-webview` 완료 결과를 반환한다. +- [ ] SettingsPage focused widget test와 completion probe unit test를 추가한다. +- [ ] `flutter test test/settings_page_test.dart` 또는 추가한 focused test 명령을 통과시킨다. +- [ ] 최종 검증 명령을 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [WEB_LOGIN-1] Settings Web Login Entry + +#### 문제 + +[settings_page.dart](/config/workspace/appsok/lib/src/features/settings/settings_page.dart:5)의 `SettingsPage`는 `adbDiagnosticsLoader`만 받는다. Jenkins 영역은 [settings_page.dart](/config/workspace/appsok/lib/src/features/settings/settings_page.dart:67)의 base URL field와 [settings_page.dart](/config/workspace/appsok/lib/src/features/settings/settings_page.dart:89)의 `SegmentedButton`만 있고, [settings_page.dart](/config/workspace/appsok/lib/src/features/settings/settings_page.dart:103)의 handler는 비어 있다. + +Before: + +```dart +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key, this.adbDiagnosticsLoader}); + + final AdbDiagnosticsLoader? adbDiagnosticsLoader; +``` + +```dart +SegmentedButton( + segments: const [ + ButtonSegment( + value: 'manual', + label: Text('Token'), + icon: Icon(Icons.key), + ), + ButtonSegment( + value: 'webview', + label: Text('Web Login'), + icon: Icon(Icons.web_asset), + ), + ], + selected: const {'webview'}, + onSelectionChanged: (_) {}, +), +``` + +#### 해결 방법 + +- `JenkinsWebLoginLauncher` typedef와 `JenkinsWebLoginResult` model을 Settings feature에 둔다. +- `SettingsPage`에 `webLoginLauncher`와 `onWebLoginCompleted`를 주입한다. +- `_baseUrlController`, `_webLoginError`, `_webLoginLoading`, `_lastLoginResult` state를 추가한다. +- `https?` scheme과 host가 있는 URL만 허용하고, Web Login 버튼은 `ValueKey('jenkins-web-login-button')`를 둔다. +- launcher가 null이면 default launcher가 WebView page를 `showDialog` 또는 route로 연다. + +#### 수정 파일 및 체크리스트 + +- [ ] `lib/src/features/settings/settings_page.dart`: controller lifecycle, URL validation, Web Login button, result/error UI. +- [ ] `lib/src/features/app_shell.dart`: optional web login callback을 SettingsPage로 전달. +- [ ] `lib/src/app.dart`: 후속 plan에서 쓸 수 있도록 optional callback surface를 보존하되 token 저장은 하지 않는다. +- [ ] `test/settings_page_test.dart`: invalid URL, valid URL launcher 호출, result 표시 테스트. + +#### 테스트 작성 + +- 작성: `test/settings_page_test.dart`. +- 테스트 이름: + - `SettingsPage disables web login for invalid Jenkins URL` + - `SettingsPage calls web login launcher with normalized base URL` + - `SettingsPage shows completed login user without token text` + +#### 중간 검증 + +```bash +flutter test test/settings_page_test.dart +``` + +예상: SettingsPage focused tests pass. + +### [WEB_LOGIN-2] WebView Login Completion Probe + +#### 문제 + +현재 코드에는 `webview_flutter` dependency가 있지만 WebView UI나 login completion probe가 없다. `pubspec.yaml`에는 `webview_flutter: ^4.13.1`가 있으며, 로컬 package metadata는 macOS default package가 `webview_flutter_wkwebview`임을 보여준다. [app_shell.dart](/config/workspace/appsok/lib/src/features/app_shell.dart:183)는 SettingsPage를 diagnostics loader만 전달해 생성한다. + +Before: + +```dart +_ShellPage( + label: '설정', + icon: Icons.tune_outlined, + selectedIcon: Icons.tune, + child: SettingsPage(adbDiagnosticsLoader: widget.adbDiagnosticsLoader), +), +``` + +#### 해결 방법 + +- `lib/src/features/settings/jenkins_web_login_page.dart`를 추가한다. +- `WebViewController`를 생성하고 `setJavaScriptMode(JavaScriptMode.unrestricted)`, `setNavigationDelegate(...)`, `loadRequest(loginUri)` 순서로 구성한다. +- login URL은 base URL의 path를 유지하되 기본은 `/login` 또는 Jenkins root load 후 redirect를 허용한다. 구현 중 Jenkins URL 구조가 불명확하면 root를 먼저 열고 `/whoAmI/api/json` probe 성공으로 완료를 감지한다. +- completion probe는 `runJavaScriptReturningResult`로 `fetch('/whoAmI/api/json', {credentials: 'same-origin'})`를 실행해 200이면 username/displayName을 반환한다. +- probe/parsing은 platform view 없이 테스트 가능한 pure helper로 분리한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `lib/src/features/settings/jenkins_web_login_page.dart`: WebView page, loading/error/cancel UI, completion callback. +- [ ] `lib/src/features/settings/settings_page.dart`: default launcher가 새 page를 연다. +- [ ] `test/settings_page_test.dart`: launcher injection 테스트. +- [ ] `test/jenkins_web_login_page_test.dart`: probe JS result parsing과 success/failure 판정 테스트. + +#### 테스트 작성 + +- 작성: `test/jenkins_web_login_page_test.dart`. +- 실제 WebViewWidget 렌더링은 macOS build로 확인하고, unit test는 probe/parsing과 launcher wiring만 검증한다. + +#### 중간 검증 + +```bash +flutter test test/settings_page_test.dart test/jenkins_web_login_page_test.dart +``` + +예상: focused tests pass. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `lib/src/features/settings/settings_page.dart` | WEB_LOGIN-1, WEB_LOGIN-2 | +| `lib/src/features/settings/jenkins_web_login_page.dart` | WEB_LOGIN-2 | +| `lib/src/features/app_shell.dart` | WEB_LOGIN-1 | +| `lib/src/app.dart` | WEB_LOGIN-1 | +| `test/settings_page_test.dart` | WEB_LOGIN-1 | +| `test/jenkins_web_login_page_test.dart` | WEB_LOGIN-2 | + +## 최종 검증 + +```bash +zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze' +zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test' +zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter build macos' +``` + +예상: analyzer issue 없음, 모든 테스트 통과, macOS build 성공. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-jenkins-credential/02+01_crumb_token_store/CODE_REVIEW-cloud-G06.md b/agent-task/m-jenkins-credential/02+01_crumb_token_store/CODE_REVIEW-cloud-G06.md new file mode 100644 index 0000000..d2f3fb9 --- /dev/null +++ b/agent-task/m-jenkins-credential/02+01_crumb_token_store/CODE_REVIEW-cloud-G06.md @@ -0,0 +1,156 @@ + + +# Code Review Reference - AUTH_TOKEN + +> **[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`. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-13 +task=m-jenkins-credential/02+01_crumb_token_store, plan=0, tag=AUTH_TOKEN + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md` +- Task ids: + - `crumb-token`: WebView same-origin 요청으로 crumb를 받고 사용자별 API token을 자동 발급한다. + - `keychain-store`: 자동 발급한 username/API token을 `TokenStore`를 통해 macOS Keychain에 저장하고 삭제할 수 있다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-{review_lane}-GNN.md` → `code_review_{review_lane}_GNN_N.log`, `PLAN-{build_lane}-GNN.md` → `plan_{build_lane}_GNN_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 수정이나 `update-roadmap` 직접 호출은 하지 않는다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [AUTH_TOKEN-1] Same-Origin Crumb And Token Bridge | [ ] | +| [AUTH_TOKEN-2] Keychain Store And Session Refresh Wiring | [ ] | + +## 구현 체크리스트 + +- [ ] `01_web_login_entry` predecessor complete.log를 확인하고 없으면 구현을 시작하지 않는다. +- [ ] WebView same-origin bridge로 `/whoAmI/api/json`, `/crumbIssuer/api/json`, API token generate POST를 실행하고 결과 parser를 추가한다. +- [ ] 자동 발급된 username/API token/base URL을 `TokenStore.saveSession`으로 저장하고 실패 시 partial credential을 남기지 않는다. +- [ ] AppSokApp/AppSokShell/SettingsPage wiring을 통해 저장 성공 후 Jenkins session을 restore하고 build 목록 loader가 활성화되게 한다. +- [ ] mock bridge/storage/client 기반 unit/widget test를 추가하고 `keychain-store` 검증을 포함한다. +- [ ] 최종 검증 명령을 실행한다. +- [ ] 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/{task_name}/`를 `agent-task/archive/YYYY/MM/{task_name}/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/{task_group}/`를 제거하거나, 남은 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`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- predecessor `01_web_login_entry` complete.log가 있었는지 확인한다. +- raw cookie/password/token이 Dart state, log, fixture, UI error에 남지 않는지 확인한다. +- 저장 실패/검증 실패 시 partial Keychain credential이 제거되는지 확인한다. +- `Roadmap Targets`의 `crumb-token`과 `keychain-store`가 실제 구현과 검증으로 충족되는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### AUTH_TOKEN-1 중간 검증 +``` +$ flutter test test/jenkins_web_auth_bridge_test.dart +(output) +``` + +### AUTH_TOKEN-2 중간 검증 +``` +$ flutter test test/token_store_test.dart test/jenkins_web_auth_bridge_test.dart +(output) +``` + +### 최종 검증 +``` +$ zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze' +(output) +$ zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test' +(output) +$ zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter build macos' +(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. + +Sections and their ownership: + +| Section | Owner | Note | +|---------|-------|------| +| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these | +| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify | +| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only | +| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only; final checkbox is mandatory | +| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check | +| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content | +| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required | +| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan | +| 검증 결과 | Implementing agent | Fill command output only | +| 코드리뷰 결과 | Review agent appends | Not included in stub | diff --git a/agent-task/m-jenkins-credential/02+01_crumb_token_store/PLAN-cloud-G06.md b/agent-task/m-jenkins-credential/02+01_crumb_token_store/PLAN-cloud-G06.md new file mode 100644 index 0000000..0cf1dde --- /dev/null +++ b/agent-task/m-jenkins-credential/02+01_crumb_token_store/PLAN-cloud-G06.md @@ -0,0 +1,227 @@ + + +# Plan - AUTH_TOKEN + +## 이 파일을 읽는 구현 에이전트에게 + +이 subtask는 `agent-task/m-jenkins-credential/01_web_login_entry/complete.log`가 생긴 뒤 구현한다. `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이 구현의 마지막 단계다. 사용자만 결정할 수 있는 Jenkins endpoint, credential, 조직 정책 blocker가 생기면 review stub의 `사용자 리뷰 요청`에 근거를 기록하고 멈춘다. 구현 중 사용자에게 직접 질문하거나 archive/log/complete.log를 직접 만들지 않는다. + +## 배경 + +`web-login` Epic의 핵심은 WebView에 남은 Jenkins session으로 crumb를 받고 사용자별 API token을 발급한 뒤 Keychain에 저장하는 것이다. 이 작업은 Jenkins auth/session, token response parsing, Keychain persistence, 앱 session restore를 함께 만지므로 `login-webview` 이후 종속 plan으로 분리한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active review stub의 `사용자 리뷰 요청` 섹션에 기록한다. 후속 에이전트가 재실행으로 해소할 수 있는 검증 공백은 사용자 리뷰 요청이 아니며, code-review가 최종 stop 여부를 판단한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md` +- Task ids: + - `crumb-token`: WebView same-origin 요청으로 crumb를 받고 사용자별 API token을 자동 발급한다. + - `keychain-store`: 자동 발급한 username/API token을 `TokenStore`를 통해 macOS Keychain에 저장하고 삭제할 수 있다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/usable-mvp/PHASE.md` +- `agent-roadmap/phase/usable-mvp/milestones/jenkins-credential.md` +- `agent-ops/rules/project/domain/artifact-flow/rules.md` +- `agent-ops/rules/project/domain/macos-platform/rules.md` +- `agent-test/local/rules.md` +- `agent-test/local/artifact-flow-smoke.md` +- `agent-test/local/macos-platform-smoke.md` +- `pubspec.yaml` +- `/config/.pub-cache/hosted/pub.dev/webview_flutter-4.13.1/pubspec.yaml` +- `/config/.pub-cache/hosted/pub.dev/webview_flutter-4.13.1/README.md` +- `/config/.pub-cache/hosted/pub.dev/webview_flutter_wkwebview-3.25.1/pubspec.yaml` +- `lib/src/app.dart` +- `lib/src/features/app_shell.dart` +- `lib/src/features/settings/settings_page.dart` +- `lib/src/features/builds/builds_page.dart` +- `lib/src/services/jenkins_artifact_session.dart` +- `lib/src/services/jenkins_client.dart` +- `lib/src/services/token_store.dart` +- `test/jenkins_artifact_session_test.dart` +- `test/jenkins_client_test.dart` +- `test/token_store_test.dart` +- `test/widget_test.dart` + +### 테스트 환경 규칙 + +- 선택 환경: `local`. +- `agent-test/local/rules.md`를 읽었고 artifact-flow smoke와 macos-platform smoke profile을 읽었다. +- 필수 remote runner 명령: `zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze'`, `zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test'`. +- WebView macOS plugin과 Keychain wiring의 compile evidence를 위해 `zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter build macos'`를 포함한다. +- 실제 Jenkins sandbox endpoint가 없으면 token 발급은 fake bridge/mock HTTP로 검증하고, 실제 Jenkins 수동 smoke는 생략 사유를 review stub에 남긴다. + +### 테스트 커버리지 공백 + +- `TokenStore.saveSession/readSession/clearSession`은 `test/token_store_test.dart`에 있다. +- WebView same-origin crumb/token JS bridge, token response parser, token 저장 후 session restore refresh는 기존 테스트가 없다. fake bridge와 mock storage 기반 unit/widget test가 필요하다. +- 실제 Jenkins CSRF crumb/token endpoint의 조직별 path 차이는 unit test만으로 보장할 수 없으므로, endpoint가 제공되면 수동 smoke가 필요하다. + +### 심볼 참조 + +- renamed/removed symbol: none expected. 새 public typedef/class 추가 중심이다. + +### 분할 판단 + +- split decision policy를 먼저 평가했다. +- 공유 task group: `m-jenkins-credential`. +- `01_web_login_entry`: predecessor. WebView 진입과 login completion result를 제공해야 한다. +- `02+01_crumb_token_store`: 이 plan. directory name 기준 predecessor index `01` 필요. +- predecessor 상태: active `agent-task/m-jenkins-credential/01_web_login_entry/complete.log` 없음, archive candidate도 확인되지 않음. 구현 시작 전 해당 complete.log가 필요하다. + +### 범위 결정 근거 + +- 포함: WebView same-origin JS bridge, crumb fetch, API token generate request, response parser, `TokenStore.saveSession/clearSession` wiring, AppSokApp session refresh, mock tests. +- 제외: LDAP 직접 연동, 공용 service account token, Jenkins plugin/Auth Broker, session-restore Epic의 `reauth`/`logout-clear` UI 전반. +- 실제 Jenkins endpoint URL은 secret 없이 표준 Jenkins API path로 구현하고, 조직별 차이가 발견되면 사용자 리뷰 요청이 아니라 test evidence와 follow-up plan으로 처리한다. + +### 빌드 등급 + +- `cloud-G06`: auth/security, WebView JS bridge, Keychain persistence, app session refresh가 결합되어 실패 모드가 hard-to-review다. + +## 구현 체크리스트 + +- [ ] `01_web_login_entry` predecessor complete.log를 확인하고 없으면 구현을 시작하지 않는다. +- [ ] WebView same-origin bridge로 `/whoAmI/api/json`, `/crumbIssuer/api/json`, API token generate POST를 실행하고 결과 parser를 추가한다. +- [ ] 자동 발급된 username/API token/base URL을 `TokenStore.saveSession`으로 저장하고 실패 시 partial credential을 남기지 않는다. +- [ ] AppSokApp/AppSokShell/SettingsPage wiring을 통해 저장 성공 후 Jenkins session을 restore하고 build 목록 loader가 활성화되게 한다. +- [ ] mock bridge/storage/client 기반 unit/widget test를 추가하고 `keychain-store` 검증을 포함한다. +- [ ] 최종 검증 명령을 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 의존 관계 및 구현 순서 + +- 이 subtask는 directory name `02+01_crumb_token_store` 기준으로 `01_web_login_entry` 완료에 의존한다. +- 구현 시작 전 `agent-task/m-jenkins-credential/01_web_login_entry/complete.log` 또는 matching archive complete.log가 있어야 한다. + +### [AUTH_TOKEN-1] Same-Origin Crumb And Token Bridge + +#### 문제 + +[jenkins_client.dart](/config/workspace/appsok/lib/src/services/jenkins_client.dart:192)는 stored credential로 `/whoAmI`를 호출하지만 WebView session cookie를 쓰는 crumb/token 발급 API가 없다. [settings_page.dart](/config/workspace/appsok/lib/src/features/settings/settings_page.dart:89)의 Web Login UI도 token 발급 경로와 연결되어 있지 않다. + +Before: + +```dart +Future fetchCurrentUser({ + required Uri baseUrl, + required JenkinsCredentials credentials, +}) async { + final uri = baseUrl.replace( + path: _appendPath(baseUrl.path, 'whoAmI/api/json'), + ); +``` + +#### 해결 방법 + +- Plan 1의 WebView completion bridge를 확장하거나 새 `JenkinsWebAuthBridge` abstraction을 추가한다. +- JS fetch는 `credentials: 'same-origin'`만 사용하고 raw cookie를 Dart로 전달하지 않는다. +- 순서: + 1. `/whoAmI/api/json`에서 username/id 확인. + 2. `/crumbIssuer/api/json`에서 `crumbRequestField`와 `crumb` 확인. + 3. `/user//descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken`에 `POST`하고 token JSON을 parse. +- token name은 `AppSok macOS `처럼 non-secret label만 쓴다. +- 모든 error string은 `JenkinsClientException.toString()` redaction 또는 equivalent sanitizer를 거치게 한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `lib/src/features/settings/jenkins_web_login_page.dart` 또는 bridge file: same-origin JS bridge. +- [ ] `lib/src/services/jenkins_client.dart`: 필요한 response model/parser만 추가하고 Basic-auth client와 경계를 유지한다. +- [ ] `test/jenkins_web_auth_bridge_test.dart`: crumb/token success, forbidden, malformed JSON, missing token tests. + +#### 테스트 작성 + +- 작성: `test/jenkins_web_auth_bridge_test.dart`. +- assertion: username path encoding, crumb header name propagation, generated token value parsing, secret redaction. + +#### 중간 검증 + +```bash +flutter test test/jenkins_web_auth_bridge_test.dart +``` + +예상: bridge/parser tests pass. + +### [AUTH_TOKEN-2] Keychain Store And Session Refresh Wiring + +#### 문제 + +[token_store.dart](/config/workspace/appsok/lib/src/services/token_store.dart:66)는 `saveSession`을 제공하고 [token_store.dart](/config/workspace/appsok/lib/src/services/token_store.dart:78)는 `clearSession`을 제공하지만, WebView에서 자동 발급한 token을 저장하는 호출 경로가 없다. [app.dart](/config/workspace/appsok/lib/src/app.dart:33)의 `_startup`은 `late final Future`라 로그인 성공 후 session restore를 다시 시작할 수 없다. + +Before: + +```dart +late final Future _startup = _session.restore(); +``` + +```dart +Future saveSession(JenkinsSessionConfig session) async { + await _storage.write(key: _baseUrlKey, value: session.baseUrl.toString()); + await _storage.write( + key: _usernameKey, + value: session.credentials.username, + ); +``` + +#### 해결 방법 + +- AppSokApp의 startup future를 mutable state로 바꾸고, Web login completion handler가 session save 후 `_session.restore()`를 재실행하게 한다. +- SettingsPage에서 token 발급 성공 결과를 `JenkinsSessionConfig`로 저장한다. +- 저장 직후 `JenkinsArtifactSession.restore()` 또는 `JenkinsClient.fetchCurrentUser`로 검증하고, 실패 시 `TokenStore.clearSession()`을 호출해 partial credential을 제거한다. +- 삭제 기능은 기존 `clearSession` API 검증을 유지하고, 전체 logout UI는 `logout-clear` Task 범위로 남긴다. + +#### 수정 파일 및 체크리스트 + +- [ ] `lib/src/app.dart`: session restore refresh callback. +- [ ] `lib/src/features/app_shell.dart`: SettingsPage에 auth completion handler 전달. +- [ ] `lib/src/features/settings/settings_page.dart`: save/progress/success/failure UI. +- [ ] `lib/src/services/token_store.dart`: 필요한 경우 generated session helper 추가. 기존 key names 유지. +- [ ] `test/token_store_test.dart`: generated session save/read/clear 검증 보강. +- [ ] `test/widget_test.dart` 또는 focused app wiring test: login success 후 jobs loader 활성화. + +#### 테스트 작성 + +- 작성/보강: + - `test/token_store_test.dart`: generated credential round trip and clear. + - `test/widget_test.dart` 또는 `test/app_auth_test.dart`: fake login completion 후 stored session restore and jobs visible. + +#### 중간 검증 + +```bash +flutter test test/token_store_test.dart test/jenkins_web_auth_bridge_test.dart +``` + +예상: storage and bridge tests pass. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `lib/src/features/settings/jenkins_web_login_page.dart` | AUTH_TOKEN-1 | +| `lib/src/services/jenkins_client.dart` | AUTH_TOKEN-1 | +| `lib/src/services/token_store.dart` | AUTH_TOKEN-2 | +| `lib/src/features/settings/settings_page.dart` | AUTH_TOKEN-2 | +| `lib/src/features/app_shell.dart` | AUTH_TOKEN-2 | +| `lib/src/app.dart` | AUTH_TOKEN-2 | +| `test/jenkins_web_auth_bridge_test.dart` | AUTH_TOKEN-1 | +| `test/token_store_test.dart` | AUTH_TOKEN-2 | +| `test/widget_test.dart` 또는 `test/app_auth_test.dart` | AUTH_TOKEN-2 | + +## 최종 검증 + +```bash +zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze' +zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test' +zsh -lc 'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter build macos' +``` + +예상: analyzer issue 없음, 모든 테스트 통과, macOS build 성공. 실제 Jenkins sandbox endpoint가 없으면 수동 smoke 생략 사유를 review stub에 기록한다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/lib/src/app.dart b/lib/src/app.dart index 22d5ee5..f55823b 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'features/app_shell.dart'; +import 'features/devices/devices_page.dart'; import 'services/adb_service.dart'; import 'services/artifact_staging_service.dart'; import 'services/jenkins_artifact_session.dart'; @@ -15,6 +16,8 @@ class AppSokApp extends StatefulWidget { this.jenkinsClient, this.artifactStagingService, this.session, + this.adbDeviceLoader, + this.adbInstaller, this.adbDiagnosticsLoader, }); @@ -22,6 +25,8 @@ class AppSokApp extends StatefulWidget { final JenkinsClient? jenkinsClient; final ArtifactStagingService? artifactStagingService; final JenkinsArtifactSession? session; + final AdbDeviceLoader? adbDeviceLoader; + final AdbInstaller? adbInstaller; final AdbDiagnosticsLoader? adbDiagnosticsLoader; @override @@ -58,6 +63,13 @@ class _AppSokAppState extends State { artifactDownloader: isReady ? _session.downloadArtifact : null, artifactStager: isReady ? _session.stageApk : null, artifactCleaner: isReady ? _session.cleanup : null, + deviceLoader: widget.adbDeviceLoader ?? _adbService.listDevices, + installer: + widget.adbInstaller ?? + (device, pending) => _adbService.installApk( + serial: device.serial, + apkPath: pending.apkPath, + ), adbDiagnosticsLoader: widget.adbDiagnosticsLoader ?? _adbService.collectDiagnostics, ), diff --git a/lib/src/features/app_shell.dart b/lib/src/features/app_shell.dart index c182efa..ab40845 100644 --- a/lib/src/features/app_shell.dart +++ b/lib/src/features/app_shell.dart @@ -24,6 +24,7 @@ class AppSokShell extends StatefulWidget { this.artifactStager, this.artifactCleaner, this.installer, + this.deviceLoader, this.adbDiagnosticsLoader, }); @@ -33,6 +34,7 @@ class AppSokShell extends StatefulWidget { final ArtifactStager? artifactStager; final ArtifactCleaner? artifactCleaner; final AdbInstaller? installer; + final AdbDeviceLoader? deviceLoader; final AdbDiagnosticsLoader? adbDiagnosticsLoader; @override @@ -160,11 +162,13 @@ class _AppSokShellState extends State { selectedIcon: Icons.usb, child: DevicesPage( pendingInstall: _pendingInstall, - onPendingInstallCancelled: - _isInstalling ? null : _handlePendingInstallCancelled, + onPendingInstallCancelled: _isInstalling + ? null + : _handlePendingInstallCancelled, installer: widget.installer == null ? null : _handleDeviceInstallRequested, + deviceLoader: widget.deviceLoader, isInstalling: _isInstalling, lastInstallDevice: _lastInstallDevice, lastInstallResult: _lastInstallResult, @@ -180,9 +184,7 @@ class _AppSokShellState extends State { label: '설정', icon: Icons.tune_outlined, selectedIcon: Icons.tune, - child: SettingsPage( - adbDiagnosticsLoader: widget.adbDiagnosticsLoader, - ), + child: SettingsPage(adbDiagnosticsLoader: widget.adbDiagnosticsLoader), ), ]; diff --git a/lib/src/features/devices/devices_page.dart b/lib/src/features/devices/devices_page.dart index 77f7ccc..3a68ef6 100644 --- a/lib/src/features/devices/devices_page.dart +++ b/lib/src/features/devices/devices_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../models/adb_device.dart'; @@ -6,13 +8,15 @@ import '../../services/adb_service.dart'; typedef PendingInstallRunner = Future Function(AdbDevice device, PendingInstall pending); +typedef AdbDeviceLoader = Future> Function(); -class DevicesPage extends StatelessWidget { +class DevicesPage extends StatefulWidget { const DevicesPage({ super.key, this.pendingInstall, this.onPendingInstallCancelled, this.installer, + this.deviceLoader, this.isInstalling = false, this.lastInstallDevice, this.lastInstallResult, @@ -21,30 +25,112 @@ class DevicesPage extends StatelessWidget { final PendingInstall? pendingInstall; final VoidCallback? onPendingInstallCancelled; final PendingInstallRunner? installer; + final AdbDeviceLoader? deviceLoader; final bool isInstalling; final AdbDevice? lastInstallDevice; final AdbInstallResult? lastInstallResult; + @override + State createState() => _DevicesPageState(); +} + +class _DevicesPageState extends State { + static const _filterAll = 'all'; + static const _filterReady = 'ready'; + static const _filterAttention = 'attention'; + + Set _selectedFilter = const {_filterAll}; + List? _loadedDevices; + bool _isLoading = false; + Object? _loadError; + + @override + void initState() { + super.initState(); + if (widget.deviceLoader != null) { + unawaited(_refreshDevices()); + } + } + + @override + void didUpdateWidget(DevicesPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.deviceLoader != oldWidget.deviceLoader) { + _loadedDevices = null; + _loadError = null; + if (widget.deviceLoader != null) { + unawaited(_refreshDevices()); + } + } + } + + Future _refreshDevices() async { + final loader = widget.deviceLoader; + if (loader == null) { + setState(() { + _loadedDevices = _sampleDevices; + _loadError = null; + }); + return; + } + + setState(() { + _isLoading = true; + _loadError = null; + }); + + try { + final devices = await loader(); + if (!mounted) return; + setState(() { + _loadedDevices = devices; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loadError = e; + _isLoading = false; + }); + } + } + + List get _devices => widget.deviceLoader == null + ? _sampleDevices + : (_loadedDevices ?? const []); + + List get _filteredDevices { + final selected = _selectedFilter.single; + return switch (selected) { + _filterReady => _devices.where((device) => device.isReady).toList(), + _filterAttention => _devices.where((device) => !device.isReady).toList(), + _ => _devices, + }; + } + @override Widget build(BuildContext context) { - final devices = _sampleDevices; + final devices = _filteredDevices; return Padding( padding: const EdgeInsets.fromLTRB(28, 0, 28, 28), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (pendingInstall != null) ...[ + if (widget.pendingInstall != null) ...[ _PendingInstallBanner( - pendingInstall: pendingInstall!, - onCancel: isInstalling ? null : onPendingInstallCancelled, + pendingInstall: widget.pendingInstall!, + onCancel: widget.isInstalling + ? null + : widget.onPendingInstallCancelled, ), const SizedBox(height: 12), ], - if (lastInstallResult != null && lastInstallDevice != null) ...[ + if (widget.lastInstallResult != null && + widget.lastInstallDevice != null) ...[ _InstallResultBanner( - device: lastInstallDevice!, - result: lastInstallResult!, + device: widget.lastInstallDevice!, + result: widget.lastInstallResult!, ), const SizedBox(height: 12), ], @@ -56,49 +142,136 @@ class DevicesPage extends StatelessWidget { SegmentedButton( segments: const [ ButtonSegment( - value: 'all', + value: _filterAll, label: Text('전체'), icon: Icon(Icons.devices), ), ButtonSegment( - value: 'ready', + value: _filterReady, label: Text('준비됨'), icon: Icon(Icons.check_circle_outline), ), ButtonSegment( - value: 'offline', + value: _filterAttention, label: Text('대기'), - icon: Icon(Icons.hourglass_empty), + icon: Icon(Icons.warning_amber_outlined), ), ], - selected: const {'all'}, - onSelectionChanged: (_) {}, + selected: _selectedFilter, + onSelectionChanged: (selection) { + setState(() => _selectedFilter = selection); + }, ), OutlinedButton.icon( - onPressed: () {}, + key: const ValueKey('device-refresh-button'), + onPressed: _isLoading ? null : _refreshDevices, icon: const Icon(Icons.refresh), label: const Text('새로고침'), ), ], ), + if (_isLoading) ...[ + const SizedBox(height: 12), + const LinearProgressIndicator(), + ], + if (_loadError != null) ...[ + const SizedBox(height: 12), + _DeviceLoadError(onRetry: _refreshDevices), + ], const SizedBox(height: 18), Expanded( - child: GridView.builder( - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 360, - mainAxisExtent: 168, - mainAxisSpacing: 12, - crossAxisSpacing: 12, + child: devices.isEmpty && _isLoading + ? const Center(child: CircularProgressIndicator()) + : devices.isEmpty + ? const _EmptyDevicesView() + : GridView.builder( + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 360, + mainAxisExtent: 196, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + ), + itemCount: devices.length, + itemBuilder: (context, index) { + return _DeviceTile( + device: devices[index], + pendingInstall: widget.pendingInstall, + isInstalling: widget.isInstalling, + installer: widget.installer, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _DeviceLoadError extends StatelessWidget { + const _DeviceLoadError({required this.onRetry}); + + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Material( + key: const ValueKey('device-load-error'), + color: colorScheme.errorContainer.withValues(alpha: 0.34), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: colorScheme.error.withValues(alpha: 0.32)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 10, 10, 10), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 10), + Expanded( + child: Text( + 'device 목록을 불러오지 못했습니다', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: colorScheme.error, + fontWeight: FontWeight.w700, + ), ), - itemCount: devices.length, - itemBuilder: (context, index) { - return _DeviceTile( - device: devices[index], - pendingInstall: pendingInstall, - isInstalling: isInstalling, - installer: installer, - ); - }, + ), + IconButton( + tooltip: '다시 시도', + onPressed: onRetry, + icon: const Icon(Icons.refresh), + ), + ], + ), + ), + ); + } +} + +class _EmptyDevicesView extends StatelessWidget { + const _EmptyDevicesView(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.usb_off_outlined, size: 40, color: colorScheme.secondary), + const SizedBox(height: 8), + Text( + '연결된 device 없음', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, ), ), ], @@ -123,12 +296,17 @@ class _DeviceTile extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final isReady = device.state == 'device'; + final stateInfo = _DeviceStateInfo.from(device, colorScheme); + final metadataLabel = _metadataLabel(device); final canInstall = - pendingInstall != null && installer != null && isReady && !isInstalling; + pendingInstall != null && + installer != null && + device.isReady && + !isInstalling; return Material( + key: ValueKey('device-tile-${device.serial}'), color: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), @@ -141,10 +319,7 @@ class _DeviceTile extends StatelessWidget { children: [ Row( children: [ - Icon( - Icons.phone_android, - color: isReady ? colorScheme.primary : colorScheme.secondary, - ), + Icon(Icons.phone_android, color: stateInfo.color), const SizedBox(width: 8), Expanded( child: Text( @@ -163,10 +338,32 @@ class _DeviceTile extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle(color: colorScheme.onSurfaceVariant), ), + if (metadataLabel.isNotEmpty) + Text( + metadataLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), const Spacer(), + Text( + key: ValueKey('device-state-message-${device.serial}'), + stateInfo.message, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), Row( children: [ - Flexible(child: _DeviceStateChip(state: device.state)), + Flexible(child: _DeviceStateChip(info: stateInfo)), const SizedBox(width: 8), _CompactIconButton( tooltip: 'logcat', @@ -190,6 +387,16 @@ class _DeviceTile extends StatelessWidget { ), ); } + + String _metadataLabel(AdbDevice device) { + final metadata = [ + if (device.product != null && device.product!.isNotEmpty) + 'product:${device.product}', + if (device.device != null && device.device!.isNotEmpty) + 'device:${device.device}', + ]; + return metadata.join(' · '); + } } class _CompactIconButton extends StatelessWidget { @@ -216,37 +423,96 @@ class _CompactIconButton extends StatelessWidget { } class _DeviceStateChip extends StatelessWidget { - const _DeviceStateChip({required this.state}); + const _DeviceStateChip({required this.info}); - final String state; + final _DeviceStateInfo info; @override Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final isReady = state == 'device'; - return Container( height: 28, padding: const EdgeInsets.symmetric(horizontal: 10), alignment: Alignment.center, decoration: BoxDecoration( - color: (isReady ? colorScheme.primary : colorScheme.secondary) - .withValues(alpha: 0.12), + color: info.color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), - child: Text( - isReady ? 'ready' : state, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: isReady ? colorScheme.primary : colorScheme.secondary, - fontWeight: FontWeight.w800, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(info.icon, size: 14, color: info.color), + const SizedBox(width: 5), + Flexible( + child: Text( + key: info.key, + info.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: info.color, fontWeight: FontWeight.w800), + ), + ), + ], ), ); } } +class _DeviceStateInfo { + const _DeviceStateInfo({ + required this.label, + required this.message, + required this.icon, + required this.color, + required this.key, + }); + + final String label; + final String message; + final IconData icon; + final Color color; + final Key key; + + static _DeviceStateInfo from(AdbDevice device, ColorScheme colorScheme) { + if (device.isReady) { + return _DeviceStateInfo( + label: 'ready', + message: '설치 가능', + icon: Icons.check_circle_outline, + color: colorScheme.primary, + key: ValueKey('device-state-${device.serial}'), + ); + } + + if (device.needsAuthorization) { + return _DeviceStateInfo( + label: '승인 필요', + message: '폰에서 USB debugging 승인 필요', + icon: Icons.verified_user_outlined, + color: colorScheme.error, + key: ValueKey('device-state-${device.serial}'), + ); + } + + if (device.isOffline) { + return _DeviceStateInfo( + label: 'offline', + message: 'ADB 연결 대기', + icon: Icons.hourglass_empty, + color: colorScheme.secondary, + key: ValueKey('device-state-${device.serial}'), + ); + } + + return _DeviceStateInfo( + label: device.state, + message: '상태 확인 필요', + icon: Icons.help_outline, + color: colorScheme.onSurfaceVariant, + key: ValueKey('device-state-${device.serial}'), + ); + } +} + class _PendingInstallBanner extends StatelessWidget { const _PendingInstallBanner({required this.pendingInstall, this.onCancel}); @@ -325,10 +591,7 @@ class _PendingInstallBanner extends StatelessWidget { } class _InstallResultBanner extends StatelessWidget { - const _InstallResultBanner({ - required this.device, - required this.result, - }); + const _InstallResultBanner({required this.device, required this.result}); final AdbDevice device; final AdbInstallResult result; diff --git a/lib/src/models/adb_device.dart b/lib/src/models/adb_device.dart index 2e6f389..c2469e2 100644 --- a/lib/src/models/adb_device.dart +++ b/lib/src/models/adb_device.dart @@ -13,6 +13,10 @@ class AdbDevice { final String? product; final String? device; + bool get isReady => state == 'device'; + bool get needsAuthorization => state == 'unauthorized'; + bool get isOffline => state == 'offline'; + String get displayName { if (model != null && model!.isNotEmpty) { return model!.replaceAll('_', ' '); diff --git a/lib/src/services/adb_service.dart b/lib/src/services/adb_service.dart index 4b8f290..d50d618 100644 --- a/lib/src/services/adb_service.dart +++ b/lib/src/services/adb_service.dart @@ -38,10 +38,12 @@ class AdbService { return result.stdout .toString() .split('\n') - .skip(1) .map((line) => line.trim()) .where((line) => line.isNotEmpty) + .where((line) => line != 'List of devices attached') + .where((line) => !line.startsWith('*')) .map(_parseDeviceLine) + .whereType() .toList(); } @@ -89,11 +91,9 @@ class AdbService { // Collect adb version output. Timeout so a hung adb server never blocks callers. try { - final result = await _processRunner( - rt.executablePath, - ['version'], - environment: rt.environment, - ).timeout(const Duration(seconds: 5)); + final result = await _processRunner(rt.executablePath, [ + 'version', + ], environment: rt.environment).timeout(const Duration(seconds: 5)); if (result.exitCode == 0) { version = result.stdout.toString().trim(); } else { @@ -138,20 +138,22 @@ class AdbService { ); } - AdbDevice _parseDeviceLine(String line) { + AdbDevice? _parseDeviceLine(String line) { final parts = line.split(RegExp(r'\s+')); + if (parts.length < 2) return null; + final metadata = {}; for (final part in parts.skip(2)) { - final keyValue = part.split(':'); - if (keyValue.length == 2) { - metadata[keyValue[0]] = keyValue[1]; + final separator = part.indexOf(':'); + if (separator > 0 && separator < part.length - 1) { + metadata[part.substring(0, separator)] = part.substring(separator + 1); } } return AdbDevice( serial: parts.first, - state: parts.length > 1 ? parts[1] : 'unknown', + state: parts[1], model: metadata['model'], product: metadata['product'], device: metadata['device'], diff --git a/lib/src/services/jenkins_client.dart b/lib/src/services/jenkins_client.dart index c64a487..f3672a8 100644 --- a/lib/src/services/jenkins_client.dart +++ b/lib/src/services/jenkins_client.dart @@ -16,6 +16,7 @@ class DownloadProgressEvent { final int receivedBytes; final int? totalBytes; final bool isComplete; + /// Raw bytes delivered in this event. Empty on the final [isComplete] event. final List chunkBytes; } @@ -207,7 +208,10 @@ class JenkinsClient { id, ]); - return JenkinsUser(id: id, displayName: displayName.isEmpty ? id : displayName); + return JenkinsUser( + id: id, + displayName: displayName.isEmpty ? id : displayName, + ); } String _firstNonBlank(List values) { @@ -271,5 +275,37 @@ class JenkinsClientException implements Exception { final String message; @override - String toString() => 'JenkinsClientException($statusCode): $message'; + String toString() => + 'JenkinsClientException($statusCode): ${_redactSensitiveText(message)}'; +} + +String _redactSensitiveText(String value) { + const marker = '[redacted]'; + var redacted = value; + + redacted = redacted.replaceAllMapped( + RegExp( + r'(authorization\s*[:=]\s*basic\s+)[A-Za-z0-9+/_=-]+', + caseSensitive: false, + ), + (match) => '${match[1]}$marker', + ); + + redacted = redacted.replaceAllMapped( + RegExp( + r'''(\b(?:api[_-]?token|token|password|jenkins-crumb|crumb)\b\s*[:=]\s*)(["']?)[^"'\s,;&]+(["']?)''', + caseSensitive: false, + ), + (match) => '${match[1]}${match[2]}$marker${match[3]}', + ); + + redacted = redacted.replaceAllMapped( + RegExp( + r'([?&](?:api[_-]?token|token|password|jenkins-crumb|crumb)=)[^&\s]+', + caseSensitive: false, + ), + (match) => '${match[1]}$marker', + ); + + return redacted; } diff --git a/test/adb_service_test.dart b/test/adb_service_test.dart index d91bf0d..00854d2 100644 --- a/test/adb_service_test.dart +++ b/test/adb_service_test.dart @@ -68,9 +68,7 @@ void main() { 'Android Debug Bridge version 1.0.41\n' 'Version 35.0.2-12147458\n'; final service = AdbService( - runtimeConfig: const AdbRuntimeConfig( - customAdbPath: '/opt/appsok/adb', - ), + runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/opt/appsok/adb'), processRunner: (executable, args, {environment}) async { expect(args, ['version']); return ProcessResult(42, 0, versionOutput, ''); @@ -86,9 +84,7 @@ void main() { test('records versionError on non-zero exit', () async { final service = AdbService( - runtimeConfig: const AdbRuntimeConfig( - customAdbPath: '/opt/appsok/adb', - ), + runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/opt/appsok/adb'), processRunner: (_, args, {environment}) async { return ProcessResult(42, 1, '', 'cannot run adb'); }, @@ -102,9 +98,7 @@ void main() { test('records versionError with exit code when stderr is empty', () async { final service = AdbService( - runtimeConfig: const AdbRuntimeConfig( - customAdbPath: '/opt/appsok/adb', - ), + runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/opt/appsok/adb'), processRunner: (_, args, {environment}) async { return ProcessResult(42, 127, '', ''); }, @@ -118,9 +112,7 @@ void main() { test('records versionError when process throws', () async { final service = AdbService( - runtimeConfig: const AdbRuntimeConfig( - customAdbPath: '/not/found/adb', - ), + runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/not/found/adb'), processRunner: (_, args, {environment}) async { throw const OSError('No such file or directory', 2); }, @@ -155,9 +147,7 @@ void main() { test('sha256 is null when executable does not exist', () async { final service = AdbService( - runtimeConfig: const AdbRuntimeConfig( - customAdbPath: '/no/such/adb', - ), + runtimeConfig: const AdbRuntimeConfig(customAdbPath: '/no/such/adb'), processRunner: (_, args, {environment}) async => ProcessResult(0, 0, '', ''), ); @@ -208,6 +198,49 @@ void main() { }, ); + test( + 'parses device, unauthorized, and offline adb devices fixtures', + () async { + final service = AdbService( + runtimeConfig: const AdbRuntimeConfig( + customAdbPath: '/opt/appsok/adb', + ), + processRunner: (_, args, {environment}) async { + expect(args, ['devices', '-l']); + return ProcessResult( + 42, + 0, + 'List of devices attached\n' + 'R5CT90A1B2C device product:dm3q ' + 'model:SM_S918N device:dm3q transport_id:1\n' + 'ZX1G22K7Q9 unauthorized usb:336592896X ' + 'product:shiba model:Pixel_8 device:shiba\n' + 'emulator-5554 offline product:sdk_gphone64_arm64 ' + 'model:sdk_gphone64_arm64 device:emu64a\n', + '', + ); + }, + ); + + final devices = await service.listDevices(); + + expect(devices, hasLength(3)); + expect(devices[0].serial, 'R5CT90A1B2C'); + expect(devices[0].state, 'device'); + expect(devices[0].isReady, isTrue); + expect(devices[0].product, 'dm3q'); + expect(devices[0].model, 'SM_S918N'); + expect(devices[1].serial, 'ZX1G22K7Q9'); + expect(devices[1].state, 'unauthorized'); + expect(devices[1].needsAuthorization, isTrue); + expect(devices[1].model, 'Pixel_8'); + expect(devices[2].serial, 'emulator-5554'); + expect(devices[2].state, 'offline'); + expect(devices[2].isOffline, isTrue); + expect(devices[2].product, 'sdk_gphone64_arm64'); + }, + ); + test('runs adb install with the shared server port', () async { final calls = <({List args, String? port})>[]; final service = AdbService( diff --git a/test/builds_page_test.dart b/test/builds_page_test.dart index 74bec05..2747429 100644 --- a/test/builds_page_test.dart +++ b/test/builds_page_test.dart @@ -877,6 +877,36 @@ void main() { expect(find.text('다운로드 실패'), findsOneWidget); }); + testWidgets('redacts credential-like values in download failure details', ( + tester, + ) async { + final apiToken = ['api', 'token', 'value'].join('-'); + final password = ['pass', 'word', 'value'].join('-'); + + await navigateToCtaWithDownloader( + tester, + downloader: taskDownloader(() async* { + yield List.filled(512, 1); + }), + stager: ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + throw JenkinsClientException( + statusCode: 500, + message: 'apiToken=$apiToken password="$password"', + ); + }, + ); + + await tester.tap(find.byKey(const ValueKey('install-cta-button'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('download-error-label')), findsOneWidget); + expect(find.text('다운로드 실패'), findsOneWidget); + expect(find.textContaining('[redacted]'), findsOneWidget); + expect(find.textContaining(apiToken), findsNothing); + expect(find.textContaining(password), findsNothing); + }); + testWidgets( 'disposes active download as cancellation and does not stage partial apk', (tester) async { diff --git a/test/jenkins_client_test.dart b/test/jenkins_client_test.dart index c0d894a..d6eb21f 100644 --- a/test/jenkins_client_test.dart +++ b/test/jenkins_client_test.dart @@ -259,45 +259,48 @@ void main() { group('JenkinsClient.downloadArtifactStream', () { final artifactUrl = Uri.parse('https://jenkins.example/artifact/app.apk'); - test('downloadArtifactStream emits progress while reading chunks', () async { - final chunk1 = List.filled(1024, 1); - final chunk2 = List.filled(512, 2); + test( + 'downloadArtifactStream emits progress while reading chunks', + () async { + final chunk1 = List.filled(1024, 1); + final chunk2 = List.filled(512, 2); - final client = JenkinsClient( - client: MockClient.streaming((request, _) async { - final controller = StreamController>(); - scheduleMicrotask(() async { - controller.add(chunk1); - controller.add(chunk2); - await controller.close(); - }); - return http.StreamedResponse( - controller.stream, - 200, - contentLength: chunk1.length + chunk2.length, - ); - }), - ); + final client = JenkinsClient( + client: MockClient.streaming((request, _) async { + final controller = StreamController>(); + scheduleMicrotask(() async { + controller.add(chunk1); + controller.add(chunk2); + await controller.close(); + }); + return http.StreamedResponse( + controller.stream, + 200, + contentLength: chunk1.length + chunk2.length, + ); + }), + ); - final task = client.downloadArtifactStream( - artifactUrl: artifactUrl, - credentials: credentials, - ); + final task = client.downloadArtifactStream( + artifactUrl: artifactUrl, + credentials: credentials, + ); - final events = await task.events.toList(); + final events = await task.events.toList(); - expect(events, hasLength(3)); - expect(events[0].receivedBytes, chunk1.length); - expect(events[0].chunkBytes, chunk1); - expect(events[0].isComplete, isFalse); - expect(events[1].receivedBytes, chunk1.length + chunk2.length); - expect(events[1].chunkBytes, chunk2); - expect(events[1].isComplete, isFalse); - expect(events[2].isComplete, isTrue); - expect(events[2].chunkBytes, isEmpty); - expect(events[2].receivedBytes, chunk1.length + chunk2.length); - expect(events[2].totalBytes, chunk1.length + chunk2.length); - }); + expect(events, hasLength(3)); + expect(events[0].receivedBytes, chunk1.length); + expect(events[0].chunkBytes, chunk1); + expect(events[0].isComplete, isFalse); + expect(events[1].receivedBytes, chunk1.length + chunk2.length); + expect(events[1].chunkBytes, chunk2); + expect(events[1].isComplete, isFalse); + expect(events[2].isComplete, isTrue); + expect(events[2].chunkBytes, isEmpty); + expect(events[2].receivedBytes, chunk1.length + chunk2.length); + expect(events[2].totalBytes, chunk1.length + chunk2.length); + }, + ); test('downloadArtifactStream reports forbidden response', () async { final client = JenkinsClient( @@ -381,10 +384,7 @@ void main() { capturedUri = request.url; capturedHeaders = request.headers; return http.Response( - jsonEncode({ - 'id': 'ada', - 'fullName': 'Ada Lovelace', - }), + jsonEncode({'id': 'ada', 'fullName': 'Ada Lovelace'}), 200, ); }), @@ -406,10 +406,8 @@ void main() { test('fetchCurrentUser parses id and display name with fallback', () async { final client = JenkinsClient( client: MockClient( - (_) async => http.Response( - jsonEncode({'id': 'grace', 'fullName': ''}), - 200, - ), + (_) async => + http.Response(jsonEncode({'id': 'grace', 'fullName': ''}), 200), ), ); @@ -448,26 +446,28 @@ void main() { }, ); - test('fetchCurrentUser throws JenkinsClientException on forbidden response', - () async { - final client = JenkinsClient( - client: MockClient((_) async => http.Response('Forbidden', 403)), - ); + test( + 'fetchCurrentUser throws JenkinsClientException on forbidden response', + () async { + final client = JenkinsClient( + client: MockClient((_) async => http.Response('Forbidden', 403)), + ); - expect( - () => client.fetchCurrentUser( - baseUrl: baseUrl, - credentials: credentials, - ), - throwsA( - isA().having( - (e) => e.statusCode, - 'statusCode', - 403, + expect( + () => client.fetchCurrentUser( + baseUrl: baseUrl, + credentials: credentials, ), - ), - ); - }); + throwsA( + isA().having( + (e) => e.statusCode, + 'statusCode', + 403, + ), + ), + ); + }, + ); }); group('JenkinsClient.fetchRecentBuilds', () { @@ -560,4 +560,29 @@ void main() { }, ); }); + + group('JenkinsClientException', () { + test('toString redacts credential-like values', () { + final apiToken = ['api', 'token', 'value'].join('-'); + final password = ['pass', 'word', 'value'].join('-'); + final basic = base64Encode(utf8.encode('user:$apiToken')); + final exception = JenkinsClientException( + statusCode: 500, + message: + 'Authorization: Basic $basic apiToken=$apiToken ' + 'password="$password" ' + 'https://jenkins.example/me?token=$apiToken', + ); + + final text = exception.toString(); + + expect(text, contains('JenkinsClientException(500)')); + expect(text, contains('apiToken=[redacted]')); + expect(text, contains('password="[redacted]"')); + expect(text, contains('?token=[redacted]')); + expect(text, isNot(contains(apiToken))); + expect(text, isNot(contains(password))); + expect(text, isNot(contains(basic))); + }); + }); } diff --git a/test/widget_test.dart b/test/widget_test.dart index 95dc830..0b61c96 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -12,12 +12,86 @@ import 'package:appsok/src/features/app_shell.dart'; import 'package:appsok/src/models/adb_device.dart'; import 'package:appsok/src/models/jenkins_build.dart'; import 'package:appsok/src/models/pending_install.dart'; -import 'package:appsok/src/features/builds/builds_page.dart' show ArtifactCleaner; +import 'package:appsok/src/features/builds/builds_page.dart' + show ArtifactCleaner; import 'package:appsok/src/services/adb_service.dart'; import 'package:appsok/src/services/artifact_staging_service.dart'; +import 'package:appsok/src/services/jenkins_artifact_session.dart'; import 'package:appsok/src/services/jenkins_client.dart'; +import 'package:appsok/src/services/token_store.dart'; import 'package:appsok/src/theme/app_theme.dart'; +class _FakeJenkinsArtifactSession extends JenkinsArtifactSession { + _FakeJenkinsArtifactSession() + : super( + store: TokenStore(), + client: JenkinsClient( + client: MockClient((_) async => http.Response('{}', 200)), + ), + stager: ArtifactStagingService(), + ); + + static const _artifact = BuildArtifact( + fileName: 'app-release.apk', + relativePath: 'outputs/app-release.apk', + ); + + @override + Future restore() async => true; + + @override + Future> loadJobs() async => [ + JenkinsJob( + name: 'android-app', + url: Uri.parse('https://jenkins.example/job/android-app/'), + ), + ]; + + @override + Future> loadBuilds(JenkinsJob job) async => [ + JenkinsBuild( + number: 7, + jobName: job.name, + url: Uri.parse('https://jenkins.example/job/android-app/7/'), + startedAt: null, + result: 'SUCCESS', + artifacts: const [_artifact], + requestedBy: 'toki', + ), + ]; + + @override + DownloadTask downloadArtifact(JenkinsBuild build, BuildArtifact artifact) { + return DownloadTask.fromStream( + Stream.fromIterable(const [ + DownloadProgressEvent( + receivedBytes: 4, + totalBytes: 4, + chunkBytes: [1, 2, 3, 4], + ), + DownloadProgressEvent( + receivedBytes: 4, + totalBytes: 4, + isComplete: true, + ), + ]), + ); + } + + @override + Future stageApk({ + required JenkinsBuild build, + required BuildArtifact artifact, + required Stream> byteStream, + }) async { + await byteStream.drain(); + return const StagedApk(path: '/tmp/staged/app-release.apk', sizeBytes: 4); + } + + @override + Future cleanup(String path) async {} +} + void main() { test('keeps the AppSok work app theme baseline', () { final theme = AppTheme.light(); @@ -47,7 +121,18 @@ void main() { tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); - await tester.pumpWidget(const AppSokApp()); + await tester.pumpWidget( + AppSokApp( + adbDeviceLoader: () async => const [ + AdbDevice( + serial: 'R5CT90A1B2C', + state: 'device', + model: 'SM_S918N', + product: 'dm3q', + ), + ], + ), + ); final navTargets = ['빌드', '디바이스', '콘솔', '설정']; for (final label in navTargets) { @@ -302,88 +387,83 @@ void main() { expect(cleanedPaths, ['/tmp/staged/app-release.apk']); }); - testWidgets( - 'keeps install button disabled when shell has no installer', - (tester) async { - await tester.pumpWidget( - MaterialApp( - theme: AppTheme.light(), - home: AppSokShell( - jobLoader: () async => [ - JenkinsJob( - name: 'android-app', - url: Uri.parse('https://jenkins.example/job/android-app/'), - ), - ], - buildLoader: (_) async => [ - JenkinsBuild( - number: 7, - jobName: 'android-app', - url: Uri.parse('https://jenkins.example/job/android-app/7/'), - startedAt: null, - result: 'SUCCESS', - artifacts: const [ - BuildArtifact( - fileName: 'app-release.apk', - relativePath: 'outputs/app-release.apk', - ), - ], - requestedBy: 'toki', - ), - ], - artifactDownloader: (build, artifact) { - var received = 0; - final events = (() async* { - final chunk = List.filled(1024, 1); - received += chunk.length; - yield DownloadProgressEvent( - receivedBytes: received, - totalBytes: 1024, - chunkBytes: chunk, - isComplete: true, + testWidgets('keeps install button disabled when shell has no installer', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: AppSokShell( + jobLoader: () async => [ + JenkinsJob( + name: 'android-app', + url: Uri.parse('https://jenkins.example/job/android-app/'), + ), + ], + buildLoader: (_) async => [ + JenkinsBuild( + number: 7, + jobName: 'android-app', + url: Uri.parse('https://jenkins.example/job/android-app/7/'), + startedAt: null, + result: 'SUCCESS', + artifacts: const [ + BuildArtifact( + fileName: 'app-release.apk', + relativePath: 'outputs/app-release.apk', + ), + ], + requestedBy: 'toki', + ), + ], + artifactDownloader: (build, artifact) { + var received = 0; + final events = (() async* { + final chunk = List.filled(1024, 1); + received += chunk.length; + yield DownloadProgressEvent( + receivedBytes: received, + totalBytes: 1024, + chunkBytes: chunk, + isComplete: true, + ); + })(); + return DownloadTask.fromStream(events); + }, + artifactStager: + ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + return const StagedApk( + path: '/tmp/staged/app-release.apk', + sizeBytes: 1024, ); - })(); - return DownloadTask.fromStream(events); - }, - artifactStager: - ({ - required build, - required artifact, - required byteStream, - }) async { - await byteStream.drain(); - return const StagedApk( - path: '/tmp/staged/app-release.apk', - sizeBytes: 1024, - ); - }, - // No installer — install buttons must be disabled - ), + }, + // No installer — install buttons must be disabled ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('android-app').first); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('build-row-7'))); - await tester.pump(); - await tester.tap(find.byKey(const ValueKey('install-cta-button'))); - await tester.pumpAndSettle(); - await tester.tap(find.byKey(const ValueKey('install-cta-button'))); - await tester.pumpAndSettle(); + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('android-app').first); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('build-row-7'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('install-cta-button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('install-cta-button'))); + await tester.pumpAndSettle(); - expect( - find.byKey(const ValueKey('pending-install-banner')), - findsOneWidget, - ); + expect( + find.byKey(const ValueKey('pending-install-banner')), + findsOneWidget, + ); - final installButtons = tester.widgetList( - find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'), - ); - for (final btn in installButtons) { - expect(btn.onPressed, isNull); - } - }, - ); + final installButtons = tester.widgetList( + find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'), + ); + for (final btn in installButtons) { + expect(btn.onPressed, isNull); + } + }); // ── UILC: Install lifecycle regression ────────────────────────── @@ -401,7 +481,7 @@ void main() { Widget shellWithInstaller({ required Future Function(AdbDevice, PendingInstall) - installerFn, + installerFn, ArtifactCleaner? artifactCleaner, }) { return MaterialApp( @@ -460,23 +540,136 @@ void main() { testWidgets('non-ready device keeps install button disabled', (tester) async { await tester.pumpWidget( shellWithInstaller( - installerFn: (device, pending) async => const AdbInstallResult( - exitCode: 0, - stdout: 'Success', - stderr: '', - ), + installerFn: (device, pending) async => + const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''), ), ); await navigateToDevicesWithPending(tester); // ZX1G22K7Q9 is unauthorized — its install button must be disabled - final allInstallButtons = tester.widgetList( - find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'), - ).toList(); + final allInstallButtons = tester + .widgetList( + find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'), + ) + .toList(); // 3 devices: 2 ready (enabled), 1 unauthorized (disabled) expect(allInstallButtons.where((b) => b.onPressed == null), hasLength(1)); }); + testWidgets( + 'loaded device states distinguish ready, unauthorized, and offline', + (tester) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: AppSokShell( + deviceLoader: () async => const [ + AdbDevice( + serial: 'R5CT90A1B2C', + state: 'device', + model: 'SM_S918N', + product: 'dm3q', + device: 'dm3q', + ), + AdbDevice( + serial: 'ZX1G22K7Q9', + state: 'unauthorized', + model: 'Pixel_8', + product: 'shiba', + device: 'shiba', + ), + AdbDevice( + serial: 'emulator-5554', + state: 'offline', + model: 'sdk_gphone64_arm64', + product: 'emu64a', + device: 'emu64a', + ), + ], + ), + ), + ); + + await tester.tap( + find.descendant( + of: find.byType(NavigationRail), + matching: find.text('디바이스'), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('device-state-R5CT90A1B2C')), + findsOneWidget, + ); + expect(find.text('ready'), findsOneWidget); + expect( + find.byKey(const ValueKey('device-state-ZX1G22K7Q9')), + findsOneWidget, + ); + expect(find.text('승인 필요'), findsOneWidget); + expect(find.text('폰에서 USB debugging 승인 필요'), findsOneWidget); + expect( + find.byKey(const ValueKey('device-state-emulator-5554')), + findsOneWidget, + ); + expect(find.text('offline'), findsOneWidget); + expect(find.text('ADB 연결 대기'), findsOneWidget); + }, + ); + + testWidgets('refresh button reloads the adb device list', (tester) async { + var calls = 0; + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: AppSokShell( + deviceLoader: () async { + calls += 1; + if (calls == 1) { + return const [ + AdbDevice( + serial: 'first-device', + state: 'device', + model: 'Pixel_8', + product: 'shiba', + ), + ]; + } + return const [ + AdbDevice( + serial: 'second-device', + state: 'offline', + model: 'SM_S918N', + product: 'dm3q', + ), + ]; + }, + ), + ), + ); + + await tester.tap( + find.descendant( + of: find.byType(NavigationRail), + matching: find.text('디바이스'), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Pixel 8'), findsOneWidget); + expect(find.text('SM S918N'), findsNothing); + + await tester.tap(find.byKey(const ValueKey('device-refresh-button'))); + await tester.pumpAndSettle(); + + expect(calls, 2); + expect(find.text('Pixel 8'), findsNothing); + expect(find.text('SM S918N'), findsOneWidget); + expect(find.text('offline'), findsOneWidget); + }); + testWidgets('ready device runs installer and shows success output', ( tester, ) async { @@ -510,11 +703,8 @@ void main() { final cleanedPaths = []; await tester.pumpWidget( shellWithInstaller( - installerFn: (device, pending) async => const AdbInstallResult( - exitCode: 0, - stdout: 'Success', - stderr: '', - ), + installerFn: (device, pending) async => + const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''), artifactCleaner: (path) async => cleanedPaths.add(path), ), ); @@ -556,7 +746,10 @@ void main() { await tester.pumpAndSettle(); expect(cleanedPaths, ['/tmp/staged/app-release.apk']); - expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget); + expect( + find.byKey(const ValueKey('install-result-banner')), + findsOneWidget, + ); expect( find.textContaining('INSTALL_FAILED_VERSION_DOWNGRADE'), findsOneWidget, @@ -726,8 +919,7 @@ void main() { await tester.pumpWidget( shellWithInstaller( - installerFn: (device, pending) async => - throw Exception('adb missing'), + installerFn: (device, pending) async => throw Exception('adb missing'), artifactCleaner: (path) async => cleanedPaths.add(path), ), ); @@ -748,9 +940,7 @@ void main() { expect(find.textContaining('adb missing'), findsOneWidget); // Install buttons no longer stuck — hourglass_top icon must not appear expect( - find.byWidgetPredicate( - (w) => w is Icon && w.icon == Icons.hourglass_top, - ), + find.byWidgetPredicate((w) => w is Icon && w.icon == Icons.hourglass_top), findsNothing, ); }); @@ -758,11 +948,22 @@ void main() { testWidgets( 'switches between all shell destinations with status placeholders', (WidgetTester tester) async { - await tester.pumpWidget(const AppSokApp()); + await tester.pumpWidget( + AppSokApp( + adbDeviceLoader: () async => const [ + AdbDevice( + serial: 'device-ready-1', + state: 'device', + model: 'Pixel_8', + product: 'shiba', + ), + ], + ), + ); final navTargets = >[ {'label': '빌드', 'marker': 'Jenkins 연결 필요'}, - {'label': '디바이스', 'marker': 'R5CT90A1B2C'}, + {'label': '디바이스', 'marker': 'Pixel 8'}, {'label': '콘솔', 'marker': '패키지 또는 태그'}, {'label': '설정', 'marker': 'Base URL'}, ]; @@ -787,7 +988,9 @@ void main() { // ── AppSokApp stored-session wiring ────────────────────────────── - testWidgets('AppSokApp wires stored Jenkins session into builds page', (tester) async { + testWidgets('AppSokApp wires stored Jenkins session into builds page', ( + tester, + ) async { final requestedPaths = []; FlutterSecureStorage.setMockInitialValues({ @@ -832,17 +1035,83 @@ void main() { expect(find.text('sample-job'), findsOneWidget); }); - testWidgets('AppSokApp keeps Jenkins connection required state when no stored session exists', (tester) async { - FlutterSecureStorage.setMockInitialValues({}); + testWidgets('AppSokApp wires staged apk into adb installer', (tester) async { + final installCalls = <({String serial, String apkPath})>[]; - await tester.pumpWidget(const AppSokApp()); + await tester.pumpWidget( + AppSokApp( + session: _FakeJenkinsArtifactSession(), + adbDeviceLoader: () async => const [ + AdbDevice( + serial: 'R5CT90A1B2C', + state: 'device', + model: 'SM_S918N', + product: 'dm3q', + ), + ], + adbInstaller: (device, pending) async { + installCalls.add((serial: device.serial, apkPath: pending.apkPath)); + return const AdbInstallResult( + exitCode: 0, + stdout: 'Success', + stderr: '', + ); + }, + ), + ); await tester.pumpAndSettle(); + await tester.tap(find.text('android-app').first); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('build-row-7'))); + await tester.pump(); - expect(find.text('Jenkins 연결 필요'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('install-cta-button'))); + await tester.pumpAndSettle(); + + final verifiedLabel = find.byKey(const ValueKey('verified-label')); + for (var i = 0; i < 20 && verifiedLabel.evaluate().isEmpty; i += 1) { + await tester.pump(const Duration(milliseconds: 20)); + } + await tester.pumpAndSettle(); + expect(verifiedLabel, findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('install-cta-button'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('pending-install-banner')), + findsOneWidget, + ); + + final installButtons = find.byWidgetPredicate( + (w) => w is IconButton && w.tooltip == '설치', + ); + await tester.tap(installButtons.first); + await tester.pumpAndSettle(); + + expect(installCalls, hasLength(1)); + expect(installCalls.single.serial, 'R5CT90A1B2C'); + expect(installCalls.single.apkPath, '/tmp/staged/app-release.apk'); + expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget); + expect(find.textContaining('Success'), findsWidgets); }); - testWidgets('AppSokApp does not expose token text on auth failure', (tester) async { + testWidgets( + 'AppSokApp keeps Jenkins connection required state when no stored session exists', + (tester) async { + FlutterSecureStorage.setMockInitialValues({}); + + await tester.pumpWidget(const AppSokApp()); + + await tester.pumpAndSettle(); + + expect(find.text('Jenkins 연결 필요'), findsOneWidget); + }, + ); + + testWidgets('AppSokApp does not expose token text on auth failure', ( + tester, + ) async { const secretToken = 's3cr3t-api-t0k3n'; FlutterSecureStorage.setMockInitialValues({ 'jenkins.baseUrl': 'https://jenkins.example', @@ -879,16 +1148,16 @@ void main() { final fakeDiag = AdbRuntimeDiagnostics( runtime: fakeRuntime, version: 'Android Debug Bridge version 1.0.41', - sha256: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + sha256: + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', versionError: null, ); await tester.pumpWidget( MaterialApp( + theme: AppTheme.light(), home: Scaffold( - body: AppSokShell( - adbDiagnosticsLoader: () async => fakeDiag, - ), + body: AppSokShell(adbDiagnosticsLoader: () async => fakeDiag), ), ), ); @@ -915,48 +1184,46 @@ void main() { expect(find.text('5038'), findsOneWidget); }); - testWidgets( - 'SettingsPage shows version error row when versionError is set', - (tester) async { - final fakeRuntime = AdbRuntime( - executablePath: 'adb', - source: AdbExecutableSource.system, - serverPort: 5037, - fallbackUsed: true, - ); - final fakeDiag = AdbRuntimeDiagnostics( - runtime: fakeRuntime, - version: null, - sha256: null, - versionError: 'exit code 127', - ); + testWidgets('SettingsPage shows version error row when versionError is set', ( + tester, + ) async { + final fakeRuntime = AdbRuntime( + executablePath: 'adb', + source: AdbExecutableSource.system, + serverPort: 5037, + fallbackUsed: true, + ); + final fakeDiag = AdbRuntimeDiagnostics( + runtime: fakeRuntime, + version: null, + sha256: null, + versionError: 'exit code 127', + ); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: AppSokShell( - adbDiagnosticsLoader: () async => fakeDiag, - ), - ), + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: AppSokShell(adbDiagnosticsLoader: () async => fakeDiag), ), - ); + ), + ); - await tester.tap( - find.descendant( - of: find.byType(NavigationRail), - matching: find.text('설정'), - ), - ); - await tester.pumpAndSettle(); + await tester.tap( + find.descendant( + of: find.byType(NavigationRail), + matching: find.text('설정'), + ), + ); + await tester.pumpAndSettle(); - await tester.tap(find.byIcon(Icons.refresh)); - await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.refresh)); + await tester.pumpAndSettle(); - expect(find.byKey(const ValueKey('diag-version-error')), findsOneWidget); - expect(find.byKey(const ValueKey('diag-version')), findsNothing); - expect(find.text('system (fallback)'), findsOneWidget); - }, - ); + expect(find.byKey(const ValueKey('diag-version-error')), findsOneWidget); + expect(find.byKey(const ValueKey('diag-version')), findsNothing); + expect(find.text('system (fallback)'), findsOneWidget); + }); testWidgets( 'SettingsPage ADB diagnostics has no overflow at compact viewport', @@ -973,8 +1240,7 @@ void main() { ); final fakeDiag = AdbRuntimeDiagnostics( runtime: fakeRuntime, - version: - 'Android Debug Bridge version 1.0.41\nVersion 35.0.2-12147458', + version: 'Android Debug Bridge version 1.0.41\nVersion 35.0.2-12147458', sha256: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', versionError: null, @@ -982,10 +1248,9 @@ void main() { await tester.pumpWidget( MaterialApp( + theme: AppTheme.light(), home: Scaffold( - body: AppSokShell( - adbDiagnosticsLoader: () async => fakeDiag, - ), + body: AppSokShell(adbDiagnosticsLoader: () async => fakeDiag), ), ), ); @@ -1011,10 +1276,9 @@ void main() { 'SettingsPage without diagnostics loader shows no diagnostics section', (tester) async { await tester.pumpWidget( - const MaterialApp( - home: Scaffold( - body: AppSokShell(), - ), + MaterialApp( + theme: AppTheme.light(), + home: const Scaffold(body: AppSokShell()), ), );