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
This commit is contained in:
toki 2026-06-13 09:44:43 +09:00
parent ce27006e05
commit ce859e1cc0
17 changed files with 1761 additions and 324 deletions

View file

@ -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` 확인이 통과해 완료 처리한다.
## 범위 제외

View file

@ -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 기능을 제공한다.

View file

@ -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] 로그인 상태 복원

View file

@ -0,0 +1,153 @@
<!-- task=m-jenkins-credential/01_web_login_entry plan=0 tag=WEB_LOGIN -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 |

View file

@ -0,0 +1,226 @@
<!-- task=m-jenkins-credential/01_web_login_entry plan=0 tag=WEB_LOGIN -->
# 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<String>(
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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,156 @@
<!-- task=m-jenkins-credential/02+01_crumb_token_store plan=0 tag=AUTH_TOKEN -->
# 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-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 |

View file

@ -0,0 +1,227 @@
<!-- task=m-jenkins-credential/02+01_crumb_token_store plan=0 tag=AUTH_TOKEN -->
# 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<JenkinsUser> 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/<encoded-user>/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken``POST`하고 token JSON을 parse.
- token name은 `AppSok macOS <timestamp>`처럼 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<bool>`라 로그인 성공 후 session restore를 다시 시작할 수 없다.
Before:
```dart
late final Future<bool> _startup = _session.restore();
```
```dart
Future<void> 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -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<AppSokApp> {
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,
),

View file

@ -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<AppSokShell> {
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<AppSokShell> {
label: '설정',
icon: Icons.tune_outlined,
selectedIcon: Icons.tune,
child: SettingsPage(
adbDiagnosticsLoader: widget.adbDiagnosticsLoader,
),
child: SettingsPage(adbDiagnosticsLoader: widget.adbDiagnosticsLoader),
),
];

View file

@ -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<void> Function(AdbDevice device, PendingInstall pending);
typedef AdbDeviceLoader = Future<List<AdbDevice>> 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<DevicesPage> createState() => _DevicesPageState();
}
class _DevicesPageState extends State<DevicesPage> {
static const _filterAll = 'all';
static const _filterReady = 'ready';
static const _filterAttention = 'attention';
Set<String> _selectedFilter = const {_filterAll};
List<AdbDevice>? _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<void> _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<AdbDevice> get _devices => widget.deviceLoader == null
? _sampleDevices
: (_loadedDevices ?? const []);
List<AdbDevice> 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<String>(
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;

View file

@ -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('_', ' ');

View file

@ -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<AdbDevice>()
.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 = <String, String>{};
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'],

View file

@ -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<int> 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<String?> 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;
}

View file

@ -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<String> args, String? port})>[];
final service = AdbService(

View file

@ -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<int>.filled(512, 1);
}),
stager: ({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
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 {

View file

@ -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<int>.filled(1024, 1);
final chunk2 = List<int>.filled(512, 2);
test(
'downloadArtifactStream emits progress while reading chunks',
() async {
final chunk1 = List<int>.filled(1024, 1);
final chunk2 = List<int>.filled(512, 2);
final client = JenkinsClient(
client: MockClient.streaming((request, _) async {
final controller = StreamController<List<int>>();
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<List<int>>();
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<JenkinsClientException>().having(
(e) => e.statusCode,
'statusCode',
403,
expect(
() => client.fetchCurrentUser(
baseUrl: baseUrl,
credentials: credentials,
),
),
);
});
throwsA(
isA<JenkinsClientException>().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)));
});
});
}

View file

@ -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<bool> restore() async => true;
@override
Future<List<JenkinsJob>> loadJobs() async => [
JenkinsJob(
name: 'android-app',
url: Uri.parse('https://jenkins.example/job/android-app/'),
),
];
@override
Future<List<JenkinsBuild>> 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<DownloadProgressEvent>.fromIterable(const [
DownloadProgressEvent(
receivedBytes: 4,
totalBytes: 4,
chunkBytes: [1, 2, 3, 4],
),
DownloadProgressEvent(
receivedBytes: 4,
totalBytes: 4,
isComplete: true,
),
]),
);
}
@override
Future<StagedApk> stageApk({
required JenkinsBuild build,
required BuildArtifact artifact,
required Stream<List<int>> byteStream,
}) async {
await byteStream.drain<void>();
return const StagedApk(path: '/tmp/staged/app-release.apk', sizeBytes: 4);
}
@override
Future<void> 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<int>.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<int>.filled(1024, 1);
received += chunk.length;
yield DownloadProgressEvent(
receivedBytes: received,
totalBytes: 1024,
chunkBytes: chunk,
isComplete: true,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 1024,
);
})();
return DownloadTask.fromStream(events);
},
artifactStager:
({
required build,
required artifact,
required byteStream,
}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/staged/app-release.apk',
sizeBytes: 1024,
);
},
// No 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<IconButton>(
find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'),
);
for (final btn in installButtons) {
expect(btn.onPressed, isNull);
}
},
);
final installButtons = tester.widgetList<IconButton>(
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<AdbInstallResult> 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<IconButton>(
find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'),
).toList();
final allInstallButtons = tester
.widgetList<IconButton>(
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 = <String>[];
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 = <Map<String, String>>[
{'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 = <String>[];
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()),
),
);