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:
parent
ce27006e05
commit
ce859e1cc0
17 changed files with 1761 additions and 324 deletions
|
|
@ -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` 확인이 통과해 완료 처리한다.
|
||||
|
||||
## 범위 제외
|
||||
|
||||
|
|
@ -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 기능을 제공한다.
|
||||
|
|
|
|||
|
|
@ -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] 로그인 상태 복원
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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 |
|
||||
|
|
@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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('_', ' ');
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
),
|
||||
);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue