feat(artifact-browser): download staging 동작과 테스트를 정리한다

다운로드 스테이징 플로우 변경에 따른 서비스/위젯 동작을 반영하고, 관련 코드리뷰/플랜 아카이브를 함께 동기화한다.
This commit is contained in:
toki 2026-06-10 09:53:55 +09:00
parent 56422bc063
commit c081096ca2
13 changed files with 1277 additions and 117 deletions

View file

@ -0,0 +1,221 @@
<!-- task=m-artifact-browser/04+03_download_staging plan=0 tag=DOWNLOAD_STAGING -->
# Code Review Reference - DOWNLOAD_STAGING
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-09
task=m-artifact-browser/04+03_download_staging, plan=0, tag=DOWNLOAD_STAGING
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `download-progress`: artifact 다운로드 진행률과 취소 상태를 표시한다.
- `temp-download`: 다운로드 파일을 앱 임시/staging 경로에 저장하고 설치 완료 또는 취소 후 삭제한다.
- `artifact-verify`: 다운로드 완료 후 파일 존재, 확장자, 크기 정보를 확인한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` → `code_review_local_G07_N.log`, `PLAN-local-G07.md` → `plan_local_G07_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [DOWNLOAD_STAGING-1] Jenkins artifact 다운로드를 진행률/취소 가능한 흐름으로 확장하고 mock HTTP 테스트를 추가한다. | [x] |
| [DOWNLOAD_STAGING-2] APK를 앱 임시/staging 경로에 저장하고 취소/실패/완료 cleanup 및 파일 검증 테스트를 추가한다. | [x] |
| [DOWNLOAD_STAGING-3] `BuildsPage` 설치 CTA를 다운로드 상태 UI에 연결하고 진행률, 취소, 완료, 실패 widget test를 추가한다. | [x] |
## 구현 체크리스트
- [x] [DOWNLOAD_STAGING-1] Jenkins artifact 다운로드를 진행률/취소 가능한 흐름으로 확장하고 mock HTTP 테스트를 추가한다.
- [x] [DOWNLOAD_STAGING-2] APK를 앱 임시/staging 경로에 저장하고 취소/실패/완료 cleanup 및 파일 검증 테스트를 추가한다.
- [x] [DOWNLOAD_STAGING-3] `BuildsPage` 설치 CTA를 다운로드 상태 UI에 연결하고 진행률, 취소, 완료, 실패 widget test를 추가한다.
- [x] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-artifact-browser/04+03_download_staging/`를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-artifact-browser/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] 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로 이동한다.
## 계획 대비 변경 사항
- **`ArtifactDownloader` typedef 변경**: 플랜은 `DownloadTask` 반환 타입과 `artifactUrl/credentials` 파라미터를 제안했으나, 이 구조로는 UI가 bytes를 직접 받지 못하고 progress events만 받아 stager에 bytes를 전달할 수 없었다. 대신 `ArtifactDownloader`를 `Stream<List<int>> Function(JenkinsBuild, BuildArtifact)` 형태로 정의하여 production 쪽에서 credentials를 closure로 캡처하고 raw byte stream을 직접 돌려주도록 설계 변경했다. `DownloadTask`/`DownloadProgressEvent`는 `JenkinsClient` API로 유지하고, UI 주입 타입과 분리했다.
- **`stageApk` overload 단순화**: 플랜의 `task.events` 기반 `stageApk` 시그니처 대신 `byteStream: Stream<List<int>>`을 직접 받는 단일 메서드로 구현했다. progress 표시는 UI(`_InstallCtaState`)에서 byte 수신량을 직접 누적한다.
- **취소 테스트 방식**: `StreamController.addError` 기반 비동기 취소는 타임아웃이 발생해, `async*` generator 함수로 에러를 throw하는 방식으로 교체했다.
- **`_navigateToCtaWithDownloader` → `navigateToCtaWithDownloader`**: linter `no_leading_underscores_for_local_identifiers` 규칙에 따라 이름 변경.
- **forbidden test `MockClient.streaming` 인자**: `(_, __)` → `(req, body)`로 변경. `unnecessary_underscores` lint 회피.
- **2026-06-10 취소 cleanup 보강**: 취소 버튼이 raw stream subscription만 취소하고 staging stream controller를 닫지 않아 stager가 partial file cleanup까지 도달하지 못할 수 있었다. `_progressController`를 state에 보관하고 취소 시 cancellation error를 흘린 뒤 닫도록 보강했다.
- **2026-06-10 staging verify 보강**: `stageApk` 완료 경로가 size만 확인하고 확장자 검증은 별도 `verify()` 호출에만 있었다. `stageApk`가 `verify(file.path)`를 호출하고 검증 실패 시 staged file을 삭제하도록 바꿨다.
- **local Flutter lockfile drift 정리**: local Flutter 테스트 실행 중 transitive `meta`/`test_api` lock 항목이 runner SDK 기준으로 내려갔으나 작업 범위가 아니므로 검증 후 `pubspec.lock`은 원래 상태로 복구했다.
## 주요 설계 결정
- **`DownloadTask`는 `JenkinsClient` 전용**: UI/staging 경계에서는 raw byte stream만 넘기고, progress 표시는 `_InstallCtaState`가 수신 bytes를 누적해 관리한다. Jenkins streaming 구현 세부와 UI 진행률 표현이 분리된다.
- **partial file cleanup**: `stageApk`의 `await for` 루프에서 예외 발생 시 `catch` 블록이 파일을 삭제한다. `async*` generator throw를 이용해 에러를 결정적으로 전달한다.
- **production downloader closure 패턴**: `ArtifactDownloader`는 credentials와 `JenkinsClient`를 캡처한 closure를 production에서 제공한다. 테스트에서는 `async*` generator를 직접 주입하므로 mock HTTP 클라이언트가 필요 없다.
- **cancel은 실패가 아니라 취소 상태로 유지**: `_cancelRequested`가 켜진 상태에서 stager가 cancellation error를 받으면 UI는 `failed`로 전환하지 않고 `cancelled` 상태를 유지한다.
- **stage 완료는 verify 통과와 동일**: staging service는 파일 존재, `.apk` 확장자, non-zero size 검증을 통과한 경우에만 `StagedApk`를 반환한다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Streaming download가 progress/cancel을 제공하면서 기존 Jenkins auth/error 계약을 깨지 않는지 확인한다.
- Partial file cleanup이 성공, 실패, 취소 경로에서 모두 보장되는지 확인한다.
- Verified APK가 존재/확장자/크기 검증을 통과해야만 다음 handoff로 넘어갈 수 있는지 확인한다.
- 테스트 출력이 실제 코드 경로와 일치하고 remote runner 검증이 포함됐는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### DOWNLOAD_STAGING-1 중간 검증
```
$ flutter test test/jenkins_client_test.dart
Waiting for another flutter command to release the startup lock...
Resolving dependencies...
Downloading packages...
Changed 2 dependencies!
9 packages have newer versions incompatible with dependency constraints.
00:00 +14: All tests passed!
```
### DOWNLOAD_STAGING-2 중간 검증
```
$ flutter test test/artifact_staging_service_test.dart
Resolving dependencies...
Downloading packages...
Changed 2 dependencies!
9 packages have newer versions incompatible with dependency constraints.
00:00 +10: All tests passed!
```
### DOWNLOAD_STAGING-3 중간 검증
```
$ flutter test test/builds_page_test.dart
Waiting for another flutter command to release the startup lock...
Resolving dependencies...
Downloading packages...
Changed 2 dependencies!
9 packages have newer versions incompatible with dependency constraints.
00:02 +33: All tests passed!
```
### 최종 검증
```
$ git diff --check
(출력 없음 — whitespace 문제 없음)
$ flutter analyze
Analyzing appsok...
No issues found! (ran in 4.5s)
$ flutter test test/jenkins_client_test.dart test/artifact_staging_service_test.dart test/builds_page_test.dart
Waiting for another flutter command to release the startup lock...
00:03 +57: All tests passed!
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze'\'''
Waiting for another flutter command to release the startup lock...
Resolving dependencies...
Downloading packages...
Got dependencies!
Analyzing appsok...
No issues found! (ran in 1.9s)
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test'\''' # first parallel attempt
Unable to delete file or directory at "/Users/toki/docker/services/code-server/data/volume/workspace/appsok/macos/Flutter/ephemeral/Packages/.packages". This may be due to the project being in a read-only volume. Consider relocating the project and trying again.
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test'\''' # serial rerun
00:02 +61: All tests passed!
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only |
| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Implementing agent must not modify |
| 검증 결과 | Implementing agent | Fill command output only |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- plan deviation: Fail
- verification trust: Pass
- 발견된 문제:
- Required: [lib/src/services/artifact_staging_service.dart:33](/config/workspace/appsok/lib/src/services/artifact_staging_service.dart:33) `_fileName`은 `artifact.fileName`만 path separator를 치환하고 `build.jobName`은 그대로 파일 경로에 붙입니다. Jenkins folder/full job 이름처럼 `team/android-app` 형태가 들어오면 [lib/src/services/artifact_staging_service.dart:46](/config/workspace/appsok/lib/src/services/artifact_staging_service.dart:46)이 staging root 아래 단일 파일이 아니라 하위 경로를 만들고, 부모 디렉터리가 없으면 다운로드가 실패합니다. `jobName`도 같은 sanitize 규칙을 적용하고, slash/backslash가 포함된 job name staging 테스트를 추가하세요.
- Required: [lib/src/features/builds/builds_page.dart:20](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:20) UI 주입 계약은 raw byte stream만 받지만, [lib/src/services/jenkins_client.dart:8](/config/workspace/appsok/lib/src/services/jenkins_client.dart:8)의 실제 Jenkins streaming API는 누적 progress event만 내보내고 chunk bytes를 제공하지 않습니다. 반대로 UI는 [lib/src/features/builds/builds_page.dart:546](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:546)의 `_totalBytes`를 설정할 경로가 없어 content-length progress를 표시할 수 없습니다. Jenkins stream 결과가 bytes, received/total, cancel을 한 계약으로 제공하도록 맞추고, UI가 total bytes를 표시하며 stager에는 bytes를 전달하도록 연결하세요.
- Required: [lib/src/features/builds/builds_page.dart:753](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:753) verified local APK path는 화면에 표시만 되고, [lib/src/features/builds/builds_page.dart:769](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:769)의 설치 callback은 여전히 `JenkinsBuild`와 `BuildArtifact`만 전달합니다. 이 상태로는 다음 handoff가 검증된 staging APK를 식별할 수 없습니다. verified 상태의 install handoff가 `StagedApk` 또는 local path를 전달하도록 callback 계약과 widget test를 보강하세요.
- 다음 단계: WARN/FAIL follow-up plan/review 파일을 작성한다. `USER_REVIEW.md`는 작성하지 않는다.

View file

@ -0,0 +1,214 @@
<!-- task=m-artifact-browser/04+03_download_staging plan=1 tag=REVIEW_DOWNLOAD_STAGING -->
# Code Review Reference - REVIEW_DOWNLOAD_STAGING
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves.
> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-06-10
task=m-artifact-browser/04+03_download_staging, plan=1, tag=REVIEW_DOWNLOAD_STAGING
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `download-progress`: artifact 다운로드 진행률과 취소 상태를 표시한다.
- `temp-download`: 다운로드 파일을 앱 임시/staging 경로에 저장하고 설치 완료 또는 취소 후 삭제한다.
- `artifact-verify`: 다운로드 완료 후 파일 존재, 확장자, 크기 정보를 확인한다.
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-local-G08.md` -> `code_review_local_G08_N.log`, `PLAN-local-G08.md` -> `plan_local_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_DOWNLOAD_STAGING-1] staging 파일명이 Jenkins folder/full job name의 path separator에도 안전하도록 보강하고 테스트를 추가한다. | [x] |
| [REVIEW_DOWNLOAD_STAGING-2] Jenkins streaming download 계약과 `BuildsPage` downloader/stager 계약을 연결해 bytes, received/total progress, cancel이 한 흐름으로 동작하게 한다. | [x] |
| [REVIEW_DOWNLOAD_STAGING-3] verified APK install handoff가 `StagedApk` 또는 local APK path를 전달하도록 callback 계약과 widget test를 보강한다. | [x] |
## 구현 체크리스트
- [x] [REVIEW_DOWNLOAD_STAGING-1] staging 파일명이 Jenkins folder/full job name의 path separator에도 안전하도록 보강하고 테스트를 추가한다.
- [x] [REVIEW_DOWNLOAD_STAGING-2] Jenkins streaming download 계약과 `BuildsPage` downloader/stager 계약을 연결해 bytes, received/total progress, cancel이 한 흐름으로 동작하게 한다.
- [x] [REVIEW_DOWNLOAD_STAGING-3] verified APK install handoff가 `StagedApk` 또는 local APK path를 전달하도록 callback 계약과 widget test를 보강한다.
- [x] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-artifact-browser/04+03_download_staging/`를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-artifact-browser/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] 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로 이동한다.
## 계획 대비 변경 사항
- **[REVIEW_DOWNLOAD_STAGING-1]** 계획은 `jobName`만 sanitize하도록 명시했으나, `artifact.fileName`에도 동일하게 적용했다. 경로 구분자가 포함된 fileName이 하위 디렉터리를 만들 수 있는 동일한 위험이 있기 때문이다.
- **[REVIEW_DOWNLOAD_STAGING-2]** G07에서 `ArtifactDownloader`가 `Stream<List<int>>`를 반환했으나 G08에서 `DownloadTask`를 반환하도록 변경했다. `DownloadProgressEvent.chunkBytes`를 통해 progress metadata와 raw bytes를 단일 이벤트 흐름으로 통합했다. 기존 widget test 4개 전부 `taskDownloader()` 헬퍼로 마이그레이션했다.
- **[REVIEW_DOWNLOAD_STAGING-3]** 계획은 "StagedApk 또는 local APK path"를 전달하는 것으로 기술했으나 `StagedApk?`를 세 번째 파라미터로 추가하는 방식을 선택했다. path만 전달하면 sizeBytes 등 추가 정보가 소실되며, nullable로 선언해 downloader 없이 직접 설치 요청하는 idle 상태에서 `null`을 전달할 수 있게 했다.
## 주요 설계 결정
- **`DownloadProgressEvent.chunkBytes`**: 단일 이벤트에 progress metadata와 raw byte payload를 함께 실어 두 스트림을 분리했을 때 발생하는 동기화 문제를 제거했다. `isComplete` 이벤트에는 `chunkBytes = const []`로 명시해 빈 청크를 쓰는 오용을 방지했다.
- **`DownloadTask.fromStream()` 팩토리**: 내부 `_()` 생성자는 private이어서 테스트에서 직접 생성할 수 없다. 공개 팩토리를 추가해 테스트가 외부에서 `DownloadTask`를 구성할 수 있게 했다.
- **`taskDownloader()` 헬퍼 (테스트)**: raw `async*` 바이트 제너레이터를 `DownloadProgressEvent` 스트림으로 래핑하고 완료 이벤트를 자동 append한다. `StreamTransformer.fromHandlers`로 `handleDone`에서 `isComplete` 이벤트를 삽입해 실제 `JenkinsClient.downloadArtifactStream`의 동작을 재현한다.
- **`InstallRequestHandler` 세 번째 파라미터 `StagedApk?`**: idle 상태의 직접 설치 버튼은 `null`을 전달하고, verified 상태의 설치 버튼은 staging 결과인 `StagedApk`를 전달한다. 호출자가 staged 여부를 타입 수준에서 구분할 수 있다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 자동 후속 불가 이유: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- staging 파일명이 job name과 artifact name 양쪽의 path separator를 모두 제거하는지 확인한다.
- Jenkins streaming event와 UI downloader 계약이 chunk bytes, received/total progress, cancel을 실제로 공유하는지 확인한다.
- `BuildsPage`가 total bytes를 표시하고 stager에는 동일 chunk bytes를 전달하는지 확인한다.
- verified install callback이 build/artifact만이 아니라 검증된 local APK path 또는 `StagedApk`를 전달하는지 확인한다.
- 새 테스트가 기존 happy path뿐 아니라 slash 포함 job name, determinate progress, verified handoff를 검증하는지 확인한다.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### REVIEW_DOWNLOAD_STAGING-1 중간 검증
```
$ flutter test test/artifact_staging_service_test.dart
00:00 +0: ArtifactStagingService.stageApk stageApk creates file directly under staging root for slash job name
00:00 +1: ArtifactStagingService.stageApk stageApk creates file directly under staging root for backslash job name
00:00 +2: ArtifactStagingService.stageApk stageApk writes downloaded bytes into temp directory
00:00 +3: ArtifactStagingService.stageApk stageApk deletes partial file when cancelled
00:00 +4: ArtifactStagingService.stageApk stageApk deletes partial file on stream error
00:00 +5: ArtifactStagingService.stageApk stageApk rejects non apk artifact filename
00:00 +6: ArtifactStagingService.verify verify rejects non apk extension
00:00 +7: ArtifactStagingService.verify verify rejects zero byte file
00:00 +8: ArtifactStagingService.verify verify rejects missing file
00:00 +9: ArtifactStagingService.verify verify accepts valid apk file
00:00 +10: ArtifactStagingService.cleanup cleanup deletes existing staged file
00:00 +11: ArtifactStagingService.cleanup cleanup is safe when file does not exist
00:00 +12: All tests passed!
```
### REVIEW_DOWNLOAD_STAGING-2 중간 검증
```
$ flutter test test/jenkins_client_test.dart test/builds_page_test.dart
...
00:02 +49: All tests passed!
```
### REVIEW_DOWNLOAD_STAGING-3 중간 검증
```
$ flutter test test/builds_page_test.dart
...
00:02 +35: All tests passed!
```
### 최종 검증
```
$ git diff --check
(no output — no whitespace errors)
$ flutter analyze
Analyzing appsok...
No issues found! (ran in 4.5s)
$ flutter test test/jenkins_client_test.dart test/artifact_staging_service_test.dart test/builds_page_test.dart
00:02 +57: /config/workspace/appsok/test/builds_page_test.dart: shows verified apk state after staging succeeds
00:02 +58: /config/workspace/appsok/test/builds_page_test.dart: install button in verified state passes staged apk to callback
00:02 +59: /config/workspace/appsok/test/builds_page_test.dart: shows download failure state when staging fails
00:02 +60: /config/workspace/appsok/test/builds_page_test.dart: renders selected job view without overflow at compact viewport
00:02 +61: All tests passed!
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '"'"'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze'"'"''
Analyzing appsok...
No issues found! (ran in 1.9s)
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '"'"'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test'"'"''
...
00:02 +62: install button in verified state passes staged apk to callback
00:02 +63: shows download failure state when staging fails
00:02 +64: renders selected job view without overflow at compact viewport
00:02 +65: All tests passed!
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these |
| Roadmap Targets | Fixed at stub creation from plan | Implementing agent must not modify |
| 구현 항목별 완료 여부 | Implementing agent | Check `[ ]` to `[x]` only |
| 구현 체크리스트 | Implementing agent | Check `[ ]` to `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Implementing agent must not modify |
| 검증 결과 | Implementing agent | Fill command output only |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- plan deviation: Fail
- verification trust: Warn
- 발견된 문제:
- Required: [lib/src/features/builds/builds_page.dart:527](/config/workspace/appsok/lib/src/features/builds/builds_page.dart:527) `_InstallCtaState.dispose()`는 `_task?.cancel()`을 호출하지만 `_byteController`를 정상 `close()`로 끝냅니다. 다운로드가 일부 chunk를 stager에 쓴 뒤 사용자가 뒤로 가거나 위젯이 dispose되면 `ArtifactStagingService.stageApk`는 정상 stream 종료로 보고 [lib/src/services/artifact_staging_service.dart:71](/config/workspace/appsok/lib/src/services/artifact_staging_service.dart:71)의 verify를 통과한 partial `.apk`를 staged 성공으로 반환할 수 있습니다. mounted가 false라 UI는 갱신되지 않지만 partial staging file cleanup도 수행되지 않습니다. dispose/route-away cancellation도 버튼 취소와 같은 cancellation error를 stager에 전달하도록 공통 cancel helper로 정리하고, 위젯 dispose 중 partial byte stream이 cleanup 경로로 들어가는 widget test를 추가하세요.
- 다음 단계: WARN/FAIL follow-up plan/review 파일을 작성한다. `USER_REVIEW.md`는 작성하지 않는다.

View file

@ -1,6 +1,6 @@
<!-- task=m-artifact-browser/04+03_download_staging plan=0 tag=DOWNLOAD_STAGING --> <!-- task=m-artifact-browser/04+03_download_staging plan=2 tag=REVIEW2_DOWNLOAD_STAGING -->
# Code Review Reference - DOWNLOAD_STAGING # Code Review Reference - REVIEW2_DOWNLOAD_STAGING
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** > **[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. > The task is NOT complete until every implementation-owned section below is filled in.
@ -13,8 +13,8 @@
## 개요 ## 개요
date=2026-06-09 date=2026-06-10
task=m-artifact-browser/04+03_download_staging, plan=0, tag=DOWNLOAD_STAGING task=m-artifact-browser/04+03_download_staging, plan=2, tag=REVIEW2_DOWNLOAD_STAGING
## Roadmap Targets ## Roadmap Targets
@ -33,7 +33,7 @@ task=m-artifact-browser/04+03_download_staging, plan=0, tag=DOWNLOAD_STAGING
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. 리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다. 1. 판정을 append한다.
2. `CODE_REVIEW-local-G07.md` → `code_review_local_G07_N.log`, `PLAN-local-G07.md` → `plan_local_G07_M.log`로 아카이브한다. 2. `CODE_REVIEW-local-G08.md` -> `code_review_local_G08_N.log`, `PLAN-local-G08.md` -> `plan_local_G08_M.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. 3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. 4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. 5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
@ -44,43 +44,43 @@ task=m-artifact-browser/04+03_download_staging, plan=0, tag=DOWNLOAD_STAGING
| 항목 | 완료 여부 | | 항목 | 완료 여부 |
|------|---------| |------|---------|
| [DOWNLOAD_STAGING-1] Jenkins artifact 다운로드를 진행률/취소 가능한 흐름으로 확장하고 mock HTTP 테스트를 추가한다. | [ ] | | [REVIEW2_DOWNLOAD_STAGING-1] active download 중 widget dispose가 partial staging을 성공 완료로 처리하지 않도록 cancellation cleanup 경로를 보강한다. | [x] |
| [DOWNLOAD_STAGING-2] APK를 앱 임시/staging 경로에 저장하고 취소/실패/완료 cleanup 및 파일 검증 테스트를 추가한다. | [ ] | | [REVIEW2_DOWNLOAD_STAGING-2] dispose/route-away 중 partial byte stream이 stager cleanup 경로로 들어가는 widget test를 추가한다. | [x] |
| [DOWNLOAD_STAGING-3] `BuildsPage` 설치 CTA를 다운로드 상태 UI에 연결하고 진행률, 취소, 완료, 실패 widget test를 추가한다. | [ ] |
## 구현 체크리스트 ## 구현 체크리스트
- [ ] [DOWNLOAD_STAGING-1] Jenkins artifact 다운로드를 진행률/취소 가능한 흐름으로 확장하고 mock HTTP 테스트를 추가한다. - [x] [REVIEW2_DOWNLOAD_STAGING-1] active download 중 widget dispose가 partial staging을 성공 완료로 처리하지 않도록 cancellation cleanup 경로를 보강한다.
- [ ] [DOWNLOAD_STAGING-2] APK를 앱 임시/staging 경로에 저장하고 취소/실패/완료 cleanup 및 파일 검증 테스트를 추가한다. - [x] [REVIEW2_DOWNLOAD_STAGING-2] dispose/route-away 중 partial byte stream이 stager cleanup 경로로 들어가는 widget test를 추가한다.
- [ ] [DOWNLOAD_STAGING-3] `BuildsPage` 설치 CTA를 다운로드 상태 UI에 연결하고 진행률, 취소, 완료, 실패 widget test를 추가한다. - [x] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [ ] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다. - [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트 ## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. - [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. - [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. - [x] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다.
- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. - [x] 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하는지 확인한다. - [x] `.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` 파일을 남기지 않는다. - [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-artifact-browser/04+03_download_staging/`를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. - [x] PASS이면 active task 디렉터리 `agent-task/m-artifact-browser/04+03_download_staging/`를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. - [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-artifact-browser/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. - [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-artifact-browser/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-{build_lane}-GNN.md`와 `CODE_REVIEW-{review_lane}-GNN.md`를 작성하고 `complete.log`를 작성하지 않는다. - [ ] 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이면 `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로 이동한다. - [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항 ## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ 계획 그대로 구현했다. 변경 없음.
## 주요 설계 결정 ## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._ - **`_cancelDownload()` 헬퍼 추출**: 기존 `_cancel()`에서 HTTP 취소·구독 해제·byte stream error 전달·controller close 로직을 분리했다. `dispose()`와 `_cancel()` 양쪽이 이 헬퍼를 호출하므로 두 경로가 동일한 stager 통보 경로를 사용한다.
- **dispose에서 `setState` 없음**: dispose 시점에는 widget tree에서 이미 제거됐으므로 `setState`를 호출하지 않는다. `_startDownload` catch 블록이 `!mounted` 확인으로 early return하므로 disposed 이후 failed 상태로 전환하지 않는다.
- **테스트에서 뒤로가기 버튼으로 dispose 트리거**: `_InstallCta`는 `_JobSelected` 하위 위젯이므로 뒤로가기 버튼 탭이 `_JobSelected`를 제거하고 `_InstallCta.dispose()`를 호출한다. 별도의 pump 없이 단일 tap + pump 조합으로 dispose 경로를 검증할 수 있다.
## 사용자 리뷰 요청 ## 사용자 리뷰 요청
@ -96,10 +96,10 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
## 리뷰어를 위한 체크포인트 ## 리뷰어를 위한 체크포인트
- Streaming download가 progress/cancel을 제공하면서 기존 Jenkins auth/error 계약을 깨지 않는지 확인한다. - dispose/route-away cancellation이 버튼 cancel과 동일하게 `_ArtifactDownloadCancelledException`을 stager byte stream에 전달하는지 확인한다.
- Partial file cleanup이 성공, 실패, 취소 경로에서 모두 보장되는지 확인한다. - active download 중 뒤로가기 또는 widget 교체가 partial file을 staged success로 남기지 않는지 확인한다.
- Verified APK가 존재/확장자/크기 검증을 통과해야만 다음 handoff로 넘어갈 수 있는지 확인한다. - 새 widget test가 stager error 또는 cleanup signal을 직접 관찰하는지 확인한다.
- 테스트 출력이 실제 코드 경로와 일치하고 remote runner 검증이 포함됐는지 확인한다. - 기존 cancel/progress/verified handoff 테스트가 계속 통과하는지 확인한다.
## 검증 결과 ## 검증 결과
@ -112,43 +112,48 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. - 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. - mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### DOWNLOAD_STAGING-1 중간 검증 ### REVIEW2_DOWNLOAD_STAGING-1 중간 검증
```bash
$ flutter test test/jenkins_client_test.dart
(output)
``` ```
### DOWNLOAD_STAGING-2 중간 검증
```bash
$ flutter test test/artifact_staging_service_test.dart
(output)
```
### DOWNLOAD_STAGING-3 중간 검증
```bash
$ flutter test test/builds_page_test.dart $ flutter test test/builds_page_test.dart
(output) ...
00:02 +36: All tests passed!
```
### REVIEW2_DOWNLOAD_STAGING-2 중간 검증
```
$ flutter test test/builds_page_test.dart
...
00:02 +36: All tests passed!
``` ```
### 최종 검증 ### 최종 검증
```bash ```
$ git diff --check $ git diff --check
(output) (no output — no whitespace errors)
$ flutter test test/jenkins_client_test.dart $ flutter analyze
(output) Analyzing appsok...
No issues found! (ran in 4.8s)
$ flutter test test/artifact_staging_service_test.dart
(output)
$ flutter test test/builds_page_test.dart $ flutter test test/builds_page_test.dart
(output) ...
00:02 +34: disposes active download as cancellation and does not stage partial apk
00:02 +35: renders selected job view without overflow at compact viewport
00:02 +36: All tests passed!
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze'\''' $ flutter test test/jenkins_client_test.dart test/artifact_staging_service_test.dart test/builds_page_test.dart
(output) ...
00:03 +62: All tests passed!
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test'\''' $ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '"'"'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze'"'"''
(output) Analyzing appsok...
No issues found! (ran in 1.9s)
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '"'"'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test'"'"''
...
00:02 +64: disposes active download as cancellation and does not stage partial apk
00:02 +65: renders selected job view without overflow at compact viewport
00:02 +66: All tests passed!
``` ```
--- ---
@ -170,3 +175,17 @@ $ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required | | 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless user input is required |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Implementing agent must not modify | | 리뷰어를 위한 체크포인트 | Fixed at stub creation | Implementing agent must not modify |
| 검증 결과 | Implementing agent | Fill command output only | | 검증 결과 | Implementing agent | Fill command output only |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Pass
- verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 종결. active plan/review를 아카이브하고 `complete.log` 작성 후 task directory를 archive로 이동한다.

View file

@ -0,0 +1,50 @@
# Complete - m-artifact-browser/04+03_download_staging
## 완료 일시
2026-06-10
## 요약
Artifact download staging follow-up loop completed after 3 review passes; final verdict PASS.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | FAIL | Required: staging job name sanitization, Jenkins streaming/UI contract alignment, verified APK handoff contract. |
| `plan_local_G08_1.log` | `code_review_local_G08_1.log` | FAIL | Required: dispose/route-away cancellation could allow partial staging success. |
| `plan_local_G08_2.log` | `code_review_local_G08_2.log` | PASS | Required follow-up closed with dispose cancellation cleanup and widget evidence. |
## 구현/정리 내용
- Jenkins artifact streaming events now carry chunk bytes with cumulative progress metadata and cancellation.
- Artifact staging verifies staged APKs and sanitizes job/artifact path separators before writing temp files.
- Builds UI shows progress/cancel/verified/failure states, forwards verified `StagedApk` to install handoff, and treats dispose/route-away during active download as cancellation.
- Widget/unit tests cover chunk progress, staging cleanup, verified handoff, and dispose cancellation.
## 최종 검증
- `git diff --check` - PASS; no whitespace errors.
- `flutter analyze` - PASS; `No issues found!`.
- `flutter test test/builds_page_test.dart` - PASS; `36: All tests passed!`.
- `flutter test test/jenkins_client_test.dart test/artifact_staging_service_test.dart test/builds_page_test.dart` - PASS; `62: All tests passed!`.
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze'\'''` - PASS; `No issues found!`.
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test'\'''` - PASS; `66: All tests passed!`.
## Roadmap Completion
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Completed task ids:
- `download-progress`: PASS; evidence=`plan_local_G08_2.log`, `code_review_local_G08_2.log`; verification=`flutter test test/builds_page_test.dart`, remote `flutter test`
- `temp-download`: PASS; evidence=`plan_local_G08_2.log`, `code_review_local_G08_2.log`; verification=`flutter test test/artifact_staging_service_test.dart`, remote `flutter test`
- `artifact-verify`: PASS; evidence=`plan_local_G08_2.log`, `code_review_local_G08_2.log`; verification=`flutter test test/artifact_staging_service_test.dart`, remote `flutter test`
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,178 @@
<!-- task=m-artifact-browser/04+03_download_staging plan=1 tag=REVIEW_DOWNLOAD_STAGING -->
# Plan - REVIEW_DOWNLOAD_STAGING
## 이 파일을 읽는 구현 에이전트에게
이 계획은 `code_review_local_G07_0.log`의 FAIL Required 항목만 닫는 follow-up이다. 구현의 마지막 단계는 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이다. 검증 명령을 실행하고 실제 출력, 계획 대비 변경, 설계 결정을 기록한 뒤 active 파일을 유지한 상태로 리뷰 준비를 보고한다.
사용자 결정, 사용자 소유 외부 환경, 범위 충돌 없이는 진행할 수 없을 때만 review stub의 `사용자 리뷰 요청` 섹션에 근거를 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행으로 메울 수 있는 증거 공백은 사용자 리뷰 요청 사유가 아니다. finalization, `complete.log`, archive 이동은 code-review-skill 전용이다.
## 배경
첫 리뷰는 local focused test와 analyzer가 통과했지만, 실제 Jenkins job 이름과 다운로드 API 연결 계약에서 Required 결함을 발견했다. 이 follow-up은 staging 파일명 안전성, Jenkins streaming event와 UI/stager 연결, verified APK handoff 계약을 보완한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `download-progress`: artifact 다운로드 진행률과 취소 상태를 표시한다.
- `temp-download`: 다운로드 파일을 앱 임시/staging 경로에 저장하고 설치 완료 또는 취소 후 삭제한다.
- `artifact-verify`: 다운로드 완료 후 파일 존재, 확장자, 크기 정보를 확인한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/artifact-flow/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/artifact-flow-smoke.md`
- `agent-task/m-artifact-browser/04+03_download_staging/plan_local_G07_0.log`
- `agent-task/m-artifact-browser/04+03_download_staging/code_review_local_G07_0.log`
- `lib/src/services/jenkins_client.dart`
- `lib/src/services/artifact_staging_service.dart`
- `lib/src/features/builds/builds_page.dart`
- `lib/src/models/jenkins_build.dart`
- `lib/src/features/app_shell.dart`
- `test/jenkins_client_test.dart`
- `test/artifact_staging_service_test.dart`
- `test/builds_page_test.dart`
### 리뷰 근거
- `git diff --check`: 통과.
- `flutter analyze`: `No issues found!`.
- `flutter test test/jenkins_client_test.dart test/artifact_staging_service_test.dart test/builds_page_test.dart`: `All tests passed!`.
- 통과한 테스트는 slash 포함 job name, Jenkins streaming chunk와 UI total progress 연결, verified APK path handoff를 검증하지 않는다.
### 범위 결정 근거
- 포함: `code_review_local_G07_0.log`의 Required 3건과 직접 관련 테스트.
- 제외: 실제 `adb install`, device 선택, Jenkins credential 획득, 앱 shell wiring. 해당 작업은 dependent split 또는 다른 Milestone 책임이다.
- 새 패키지는 추가하지 않는다.
### 빌드 등급
- `local-G08`: 파일 I/O edge case와 UI/API 계약 정렬이 필요하지만 변경 파일과 테스트 범위가 `artifact-flow` 안에 갇혀 있고 결정적 unit/widget test로 검증 가능하다.
## 구현 체크리스트
- [ ] [REVIEW_DOWNLOAD_STAGING-1] staging 파일명이 Jenkins folder/full job name의 path separator에도 안전하도록 보강하고 테스트를 추가한다.
- [ ] [REVIEW_DOWNLOAD_STAGING-2] Jenkins streaming download 계약과 `BuildsPage` downloader/stager 계약을 연결해 bytes, received/total progress, cancel이 한 흐름으로 동작하게 한다.
- [ ] [REVIEW_DOWNLOAD_STAGING-3] verified APK install handoff가 `StagedApk` 또는 local APK path를 전달하도록 callback 계약과 widget test를 보강한다.
- [ ] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW_DOWNLOAD_STAGING-1] Safe Staging File Name
### 문제
`ArtifactStagingService._fileName`은 artifact 파일명만 sanitize하고 `build.jobName`은 그대로 사용한다. Jenkins folder/full job name이 `team/android-app`처럼 path separator를 포함하면 staging root 아래 단일 파일이 아니라 하위 경로가 만들어져 `openWrite()`가 실패할 수 있다.
### 해결 방법
- `build.jobName`과 `artifact.fileName` 모두 같은 sanitize helper를 거치게 한다.
- slash와 backslash를 `_`로 치환하고 빈 값이 되지 않도록 한다.
- `Directory` injection 테스트에서 job name이 `team/android-app` 또는 `team\\android-app`인 경우에도 staging root 바로 아래 파일이 생성되는지 확인한다.
### 수정 파일 및 체크리스트
- [ ] `lib/src/services/artifact_staging_service.dart`: job/artifact 공통 sanitize helper를 추가한다.
- [ ] `test/artifact_staging_service_test.dart`: separator 포함 job name 테스트를 추가한다.
### 중간 검증
```bash
flutter test test/artifact_staging_service_test.dart
```
## [REVIEW_DOWNLOAD_STAGING-2] Streaming Contract Alignment
### 문제
`JenkinsClient.downloadArtifactStream`은 `DownloadProgressEvent`만 내보내고 실제 chunk bytes를 전달하지 않는다. 반대로 `BuildsPage.ArtifactDownloader`는 raw `Stream<List<int>>`만 받으므로 Jenkins의 `contentLength`를 `_totalBytes`에 설정할 수 없다. 현재 계약으로는 실제 Jenkins 다운로드를 사용해 파일을 쓰면서 determinate progress와 cancel을 함께 제공할 수 없다.
### 해결 방법
- `DownloadProgressEvent` 또는 새 event 타입이 chunk bytes, `receivedBytes`, `totalBytes?`, `isComplete`를 함께 담도록 계약을 정렬한다.
- `ArtifactDownloader`가 Jenkins streaming 결과를 직접 표현할 수 있게 한다. 예: `DownloadTask`를 반환하도록 하거나, cancel forwarding이 가능한 event stream wrapper를 사용한다.
- `BuildsPage`는 event의 chunk bytes를 stager stream에 전달하고, event의 `receivedBytes`/`totalBytes`로 progress label/bar를 갱신한다.
- cancel 버튼은 raw subscription 취소만이 아니라 실제 download task cancel까지 호출되게 한다.
- tests:
- Jenkins client stream test에서 chunk bytes와 total bytes를 검증한다.
- BuildsPage widget test에서 total bytes가 있을 때 determinate label/value가 표시되는지 검증한다.
- cancel test에서 download task cancel path가 호출되고 partial cleanup 신호가 stager로 전달되는지 검증한다.
### 수정 파일 및 체크리스트
- [ ] `lib/src/services/jenkins_client.dart`: streaming event/download task 계약을 bytes+progress+cancel이 가능한 형태로 보강한다.
- [ ] `lib/src/features/builds/builds_page.dart`: downloader/stager 연결을 새 streaming 계약에 맞춘다.
- [ ] `test/jenkins_client_test.dart`: chunk bytes, total bytes, cancel forwarding 테스트를 추가/수정한다.
- [ ] `test/builds_page_test.dart`: determinate progress와 cancel forwarding 테스트를 추가/수정한다.
### 중간 검증
```bash
flutter test test/jenkins_client_test.dart test/builds_page_test.dart
```
## [REVIEW_DOWNLOAD_STAGING-3] Verified APK Handoff Contract
### 문제
verified 상태는 `_stagedPath`를 화면에 표시하지만 설치 callback은 여전히 `JenkinsBuild`와 `BuildArtifact`만 전달한다. 다음 handoff가 검증된 staging APK를 식별하려면 callback 계약 또는 전달 객체에 local APK path/size가 포함되어야 한다.
### 해결 방법
- 설치 callback 계약을 `StagedApk` 또는 local APK path를 포함하는 형태로 보강한다.
- 아직 downloader/stager가 없는 fallback UI가 필요하면 staged 값이 없는 legacy install 요청과 verified install 요청을 명확히 분리한다.
- widget test에서 staging 성공 후 설치 버튼을 누르면 callback이 verified path와 size를 받는지 검증한다.
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/builds/builds_page.dart`: verified install handoff에 staged APK 정보를 포함한다.
- [ ] `test/builds_page_test.dart`: verified APK path/size callback 테스트를 추가한다.
### 중간 검증
```bash
flutter test test/builds_page_test.dart
```
## 의존 관계 및 구현 순서
1. `REVIEW_DOWNLOAD_STAGING-1`
2. `REVIEW_DOWNLOAD_STAGING-2`
3. `REVIEW_DOWNLOAD_STAGING-3`
4. 최종 검증과 review stub 작성
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `lib/src/services/artifact_staging_service.dart` | REVIEW_DOWNLOAD_STAGING-1 |
| `lib/src/services/jenkins_client.dart` | REVIEW_DOWNLOAD_STAGING-2 |
| `lib/src/features/builds/builds_page.dart` | REVIEW_DOWNLOAD_STAGING-2, REVIEW_DOWNLOAD_STAGING-3 |
| `test/artifact_staging_service_test.dart` | REVIEW_DOWNLOAD_STAGING-1 |
| `test/jenkins_client_test.dart` | REVIEW_DOWNLOAD_STAGING-2 |
| `test/builds_page_test.dart` | REVIEW_DOWNLOAD_STAGING-2, REVIEW_DOWNLOAD_STAGING-3 |
## 최종 검증
```bash
git diff --check
flutter analyze
flutter test test/jenkins_client_test.dart test/artifact_staging_service_test.dart test/builds_page_test.dart
ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze'\'''
ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test'\'''
```
예상: diff whitespace 문제 없음, local focused tests 통과, remote `flutter analyze`에서 `No issues found!`, remote `flutter test`에서 `All tests passed!`.

View file

@ -0,0 +1,144 @@
<!-- task=m-artifact-browser/04+03_download_staging plan=2 tag=REVIEW2_DOWNLOAD_STAGING -->
# Plan - REVIEW2_DOWNLOAD_STAGING
## 이 파일을 읽는 구현 에이전트에게
이 계획은 `code_review_local_G08_1.log`의 FAIL Required 항목만 닫는 follow-up이다. 구현의 마지막 단계는 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것이다. 검증 명령을 실행하고 실제 출력, 계획 대비 변경, 설계 결정을 기록한 뒤 active 파일을 유지한 상태로 리뷰 준비를 보고한다.
사용자 결정, 사용자 소유 외부 환경, 범위 충돌 없이는 진행할 수 없을 때만 review stub의 `사용자 리뷰 요청` 섹션에 근거를 채우고 멈춘다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행으로 메울 수 있는 증거 공백은 사용자 리뷰 요청 사유가 아니다. finalization, `complete.log`, archive 이동은 code-review-skill 전용이다.
## 배경
G08 구현은 staging filename, streaming event, verified handoff를 대부분 보완했지만, active download 중 `_InstallCta`가 dispose되는 경로에서 byte stream을 정상 종료한다. 일부 bytes가 이미 staging file에 쓰인 뒤 정상 close가 전달되면 `ArtifactStagingService.stageApk`가 partial APK를 성공 staging처럼 verify할 수 있다. 이 follow-up은 dispose/route-away cancellation을 버튼 cancel과 같은 cleanup 경로로 통합한다.
## Roadmap Targets
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md`
- Task ids:
- `download-progress`: artifact 다운로드 진행률과 취소 상태를 표시한다.
- `temp-download`: 다운로드 파일을 앱 임시/staging 경로에 저장하고 설치 완료 또는 취소 후 삭제한다.
- `artifact-verify`: 다운로드 완료 후 파일 존재, 확장자, 크기 정보를 확인한다.
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-roadmap/current.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md`
- `agent-ops/rules/project/domain/artifact-flow/rules.md`
- `agent-test/local/rules.md`
- `agent-test/local/artifact-flow-smoke.md`
- `agent-task/m-artifact-browser/04+03_download_staging/plan_local_G08_1.log`
- `agent-task/m-artifact-browser/04+03_download_staging/code_review_local_G08_1.log`
- `lib/src/features/builds/builds_page.dart`
- `lib/src/services/artifact_staging_service.dart`
- `lib/src/services/jenkins_client.dart`
- `test/builds_page_test.dart`
- `test/artifact_staging_service_test.dart`
- `test/jenkins_client_test.dart`
### 리뷰 근거
- `git diff --check`: 통과.
- `flutter analyze`: 최초 병렬 실행은 Flutter startup lock/ephemeral cleanup 충돌로 실패했으나, 직렬 재실행은 `No issues found!`.
- `flutter test test/jenkins_client_test.dart test/artifact_staging_service_test.dart test/builds_page_test.dart`: `All tests passed!`.
- 통과한 tests는 explicit cancel button 경로를 검증하지만, 위젯 dispose/route-away 중 partial staging cleanup 경로는 검증하지 않는다.
### 범위 결정 근거
- 포함: active download 중 `_InstallCtaState.dispose()` 또는 route-away로 발생하는 cancellation cleanup, 해당 widget test.
- 제외: streaming event 계약 추가 변경, `ArtifactStagingService` verify 정책 변경, 실제 `adb install`, device 선택 UI.
- 새 패키지는 추가하지 않는다.
### 빌드 등급
- `local-G08`: 단일 UI lifecycle/cancellation bug이지만 비동기 stream, file cleanup, widget disposal이 얽혀 있어 focused widget test와 소스 리뷰가 함께 필요하다.
## 구현 체크리스트
- [ ] [REVIEW2_DOWNLOAD_STAGING-1] active download 중 widget dispose가 partial staging을 성공 완료로 처리하지 않도록 cancellation cleanup 경로를 보강한다.
- [ ] [REVIEW2_DOWNLOAD_STAGING-2] dispose/route-away 중 partial byte stream이 stager cleanup 경로로 들어가는 widget test를 추가한다.
- [ ] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## [REVIEW2_DOWNLOAD_STAGING-1] Dispose Cancellation Cleanup
### 문제
`_InstallCtaState.dispose()`는 `_task?.cancel()` 호출 후 `_byteController?.close()`를 정상 종료로 보낸다. 이미 partial bytes가 stager에 전달된 경우 `ArtifactStagingService.stageApk`는 정상 종료로 간주해 verify를 실행하고, non-empty `.apk` partial file을 성공 staging처럼 남길 수 있다.
### 해결 방법
- 버튼 cancel과 dispose cancel이 같은 helper를 사용하게 한다.
- dispose에서는 UI `setState` 없이 `_task.cancel()`, event subscription cancel, `_byteController.addError(_ArtifactDownloadCancelledException())`, controller close를 수행한다.
- `_byteController`가 이미 closed인 경우에는 중복 add/close를 피한다.
- `_startDownload`의 catch/finally가 dispose 후에도 unhandled error를 만들지 않도록 mounted/cancelled state를 명확히 유지한다.
### 수정 파일 및 체크리스트
- [ ] `lib/src/features/builds/builds_page.dart`: dispose cancellation이 stager에 cancellation error를 전달하도록 보강한다.
- [ ] `test/builds_page_test.dart`: active download 중 뒤로 가기 또는 widget 교체로 `_InstallCta`가 dispose되면 stager가 cancellation error를 받고 partial success callback이 발생하지 않는지 검증한다.
### 중간 검증
```bash
flutter test test/builds_page_test.dart
```
## [REVIEW2_DOWNLOAD_STAGING-2] Verification Evidence
### 문제
G08 검증은 버튼 cancel, determinate progress, handoff는 확인했지만 route-away/dispose cleanup을 직접 검증하지 않았다.
### 해결 방법
- 새 widget test 이름 예시: `disposes active download as cancellation and does not stage partial apk`.
- test 구성:
- 첫 chunk를 emit하고 두 번째 chunk 전 pause하는 `DownloadTask`를 만든다.
- stager는 byte stream을 drain하고, error를 별도 completer에 기록한다.
- 다운로드 시작 후 뒤로가기 버튼을 탭하거나 다른 widget으로 repump해 `_InstallCta`를 dispose한다.
- stager error가 cancellation을 포함하는지 확인한다.
- verified label/install callback이 발생하지 않았는지 확인한다.
### 수정 파일 및 체크리스트
- [ ] `test/builds_page_test.dart`: dispose cancellation widget test를 추가한다.
### 중간 검증
```bash
flutter test test/builds_page_test.dart
```
## 의존 관계 및 구현 순서
1. `REVIEW2_DOWNLOAD_STAGING-1`
2. `REVIEW2_DOWNLOAD_STAGING-2`
3. 최종 검증과 review stub 작성
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `lib/src/features/builds/builds_page.dart` | REVIEW2_DOWNLOAD_STAGING-1 |
| `test/builds_page_test.dart` | REVIEW2_DOWNLOAD_STAGING-2 |
## 최종 검증
```bash
git diff --check
flutter analyze
flutter test test/builds_page_test.dart
flutter test test/jenkins_client_test.dart test/artifact_staging_service_test.dart test/builds_page_test.dart
ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze'\'''
ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter test'\'''
```
예상: diff whitespace 문제 없음, local focused tests 통과, remote `flutter analyze`에서 `No issues found!`, remote `flutter test`에서 `All tests passed!`.

View file

@ -12,13 +12,12 @@ typedef JenkinsBuildLoader =
Future<List<JenkinsBuild>> Function(JenkinsJob job); Future<List<JenkinsBuild>> Function(JenkinsJob job);
typedef InstallRequestHandler = typedef InstallRequestHandler =
void Function(JenkinsBuild build, BuildArtifact artifact); void Function(JenkinsBuild build, BuildArtifact artifact, StagedApk? stagedApk);
/// Returns a byte stream for the artifact. The stream emits raw bytes and /// Returns a [DownloadTask] that emits [DownloadProgressEvent]s carrying both
/// closes on completion or cancellation. The caller may cancel by closing a /// chunk bytes and cumulative progress. The task can be cancelled mid-stream.
/// subscription before all bytes arrive.
typedef ArtifactDownloader = typedef ArtifactDownloader =
Stream<List<int>> Function(JenkinsBuild build, BuildArtifact artifact); DownloadTask Function(JenkinsBuild build, BuildArtifact artifact);
typedef ArtifactStager = typedef ArtifactStager =
Future<StagedApk> Function({ Future<StagedApk> Function({
@ -488,6 +487,13 @@ class _BuildRow extends StatelessWidget {
enum _DownloadState { idle, downloading, verified, failed, cancelled } enum _DownloadState { idle, downloading, verified, failed, cancelled }
class _ArtifactDownloadCancelledException implements Exception {
const _ArtifactDownloadCancelledException();
@override
String toString() => 'Artifact download cancelled';
}
class _InstallCta extends StatefulWidget { class _InstallCta extends StatefulWidget {
const _InstallCta({ const _InstallCta({
super.key, super.key,
@ -512,13 +518,15 @@ class _InstallCtaState extends State<_InstallCta> {
_DownloadState _dlState = _DownloadState.idle; _DownloadState _dlState = _DownloadState.idle;
int _receivedBytes = 0; int _receivedBytes = 0;
int? _totalBytes; int? _totalBytes;
String? _stagedPath; StagedApk? _stagedApk;
String? _errorMessage; String? _errorMessage;
StreamSubscription<List<int>>? _downloadSub; DownloadTask? _task;
StreamSubscription<DownloadProgressEvent>? _eventSub;
StreamController<List<int>>? _byteController;
@override @override
void dispose() { void dispose() {
_downloadSub?.cancel(); _cancelDownload();
super.dispose(); super.dispose();
} }
@ -527,28 +535,44 @@ class _InstallCtaState extends State<_InstallCta> {
final stager = widget.artifactStager; final stager = widget.artifactStager;
if (downloader == null || stager == null) return; if (downloader == null || stager == null) return;
final task = downloader(widget.item, widget.artifact);
final byteController = StreamController<List<int>>();
setState(() { setState(() {
_dlState = _DownloadState.downloading; _dlState = _DownloadState.downloading;
_receivedBytes = 0; _receivedBytes = 0;
_totalBytes = null; _totalBytes = null;
_stagedPath = null; _stagedApk = null;
_errorMessage = null; _errorMessage = null;
_task = task;
_byteController = byteController;
}); });
// Wrap the raw byte stream to track received bytes for the progress indicator. // Forward chunk bytes to stager; update progress UI from event metadata.
final rawStream = downloader(widget.item, widget.artifact); _eventSub = task.events.listen(
final progressController = StreamController<List<int>>(); (event) {
if (!mounted || byteController.isClosed) return;
_downloadSub = rawStream.listen( setState(() {
(chunk) { _receivedBytes = event.receivedBytes;
if (!mounted) return; _totalBytes = event.totalBytes;
setState(() => _receivedBytes += chunk.length); });
progressController.add(chunk); if (event.chunkBytes.isNotEmpty) {
byteController.add(event.chunkBytes);
}
if (event.isComplete && !byteController.isClosed) {
byteController.close();
}
}, },
onDone: () => progressController.close(),
onError: (Object e) { onError: (Object e) {
progressController.addError(e); if (!byteController.isClosed) {
progressController.close(); byteController.addError(e);
byteController.close();
}
},
onDone: () {
if (!byteController.isClosed) {
byteController.close();
}
}, },
cancelOnError: true, cancelOnError: true,
); );
@ -557,27 +581,43 @@ class _InstallCtaState extends State<_InstallCta> {
final staged = await stager( final staged = await stager(
build: widget.item, build: widget.item,
artifact: widget.artifact, artifact: widget.artifact,
byteStream: progressController.stream, byteStream: byteController.stream,
); );
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_dlState = _DownloadState.verified; _dlState = _DownloadState.verified;
_stagedPath = staged.path; _stagedApk = staged;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
if (_dlState == _DownloadState.cancelled) return;
setState(() { setState(() {
_dlState = _DownloadState.failed; _dlState = _DownloadState.failed;
_errorMessage = e.toString(); _errorMessage = e.toString();
}); });
} finally { } finally {
_downloadSub = null; await _eventSub?.cancel();
_eventSub = null;
_task = null;
_byteController = null;
} }
} }
void _cancelDownload() {
_task?.cancel();
_task = null;
_eventSub?.cancel();
_eventSub = null;
final bc = _byteController;
if (bc != null && !bc.isClosed) {
bc.addError(const _ArtifactDownloadCancelledException());
bc.close();
}
_byteController = null;
}
void _cancel() { void _cancel() {
_downloadSub?.cancel(); _cancelDownload();
_downloadSub = null;
if (!mounted) return; if (!mounted) return;
setState(() => _dlState = _DownloadState.cancelled); setState(() => _dlState = _DownloadState.cancelled);
} }
@ -596,10 +636,8 @@ class _InstallCtaState extends State<_InstallCta> {
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: switch (_dlState) { child: switch (_dlState) {
_DownloadState.idle || _DownloadState.cancelled => _buildIdle( _DownloadState.idle ||
context, _DownloadState.cancelled => _buildIdle(context, colorScheme),
colorScheme,
),
_DownloadState.downloading => _buildDownloading(context, colorScheme), _DownloadState.downloading => _buildDownloading(context, colorScheme),
_DownloadState.verified => _buildVerified(context, colorScheme), _DownloadState.verified => _buildVerified(context, colorScheme),
_DownloadState.failed => _buildFailed(context, colorScheme), _DownloadState.failed => _buildFailed(context, colorScheme),
@ -621,9 +659,9 @@ class _InstallCtaState extends State<_InstallCta> {
children: [ children: [
Text( Text(
_dlState == _DownloadState.cancelled ? '다운로드 취소됨' : '설치 준비', _dlState == _DownloadState.cancelled ? '다운로드 취소됨' : '설치 준비',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.w700, context,
), ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -652,7 +690,11 @@ class _InstallCtaState extends State<_InstallCta> {
key: const ValueKey('install-cta-button'), key: const ValueKey('install-cta-button'),
onPressed: widget.onInstallRequested == null onPressed: widget.onInstallRequested == null
? null ? null
: () => widget.onInstallRequested!(widget.item, widget.artifact), : () => widget.onInstallRequested!(
widget.item,
widget.artifact,
null,
),
icon: const Icon(Icons.install_mobile), icon: const Icon(Icons.install_mobile),
label: const Text('설치'), label: const Text('설치'),
), ),
@ -714,12 +756,13 @@ class _InstallCtaState extends State<_InstallCta> {
Text( Text(
key: const ValueKey('verified-label'), key: const ValueKey('verified-label'),
'다운로드 완료', '다운로드 완료',
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.w700, context,
), ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700),
), ),
Text( Text(
_stagedPath ?? '#${widget.item.number} · ${widget.artifact.fileName}', _stagedApk?.path ??
'#${widget.item.number} · ${widget.artifact.fileName}',
style: TextStyle( style: TextStyle(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
fontSize: 12, fontSize: 12,
@ -735,7 +778,11 @@ class _InstallCtaState extends State<_InstallCta> {
key: const ValueKey('install-cta-button'), key: const ValueKey('install-cta-button'),
onPressed: widget.onInstallRequested == null onPressed: widget.onInstallRequested == null
? null ? null
: () => widget.onInstallRequested!(widget.item, widget.artifact), : () => widget.onInstallRequested!(
widget.item,
widget.artifact,
_stagedApk,
),
icon: const Icon(Icons.install_mobile), icon: const Icon(Icons.install_mobile),
label: const Text('설치'), label: const Text('설치'),
), ),

View file

@ -30,9 +30,13 @@ class ArtifactStagingService {
return getTemporaryDirectory(); return getTemporaryDirectory();
} }
static String _sanitize(String value) =>
value.replaceAll(RegExp(r'[/\\]'), '_');
String _fileName(JenkinsBuild build, BuildArtifact artifact) { String _fileName(JenkinsBuild build, BuildArtifact artifact) {
final safe = artifact.fileName.replaceAll(RegExp(r'[/\\]'), '_'); final safeJob = _sanitize(build.jobName);
return '${build.jobName}_${build.number}_$safe'; final safeFile = _sanitize(artifact.fileName);
return '${safeJob}_${build.number}_$safeFile';
} }
/// Downloads bytes from [byteStream] into a temp file and returns a [StagedApk]. /// Downloads bytes from [byteStream] into a temp file and returns a [StagedApk].
@ -64,13 +68,14 @@ class ArtifactStagingService {
await sink.close(); await sink.close();
final size = await file.length(); try {
if (size == 0) { return await verify(file.path);
await file.delete(); } on ArtifactVerifyException {
throw ArtifactVerifyException('Downloaded file is empty'); if (await file.exists()) {
await file.delete();
}
rethrow;
} }
return StagedApk(path: file.path, sizeBytes: size);
} }
/// Verifies an already-staged file: must exist, have .apk extension, and be non-empty. /// Verifies an already-staged file: must exist, have .apk extension, and be non-empty.

View file

@ -10,16 +10,26 @@ class DownloadProgressEvent {
required this.receivedBytes, required this.receivedBytes,
this.totalBytes, this.totalBytes,
this.isComplete = false, this.isComplete = false,
this.chunkBytes = const [],
}); });
final int receivedBytes; final int receivedBytes;
final int? totalBytes; final int? totalBytes;
final bool isComplete; final bool isComplete;
/// Raw bytes delivered in this event. Empty on the final [isComplete] event.
final List<int> chunkBytes;
} }
class DownloadTask { class DownloadTask {
DownloadTask._(this.events, this._cancel); DownloadTask._(this.events, this._cancel);
/// Creates a [DownloadTask] from an existing event stream and a cancel callback.
/// Used in tests and production wrappers where the stream is already constructed.
DownloadTask.fromStream(
Stream<DownloadProgressEvent> events, {
void Function()? onCancel,
}) : this._(events, onCancel ?? () {});
final Stream<DownloadProgressEvent> events; final Stream<DownloadProgressEvent> events;
final void Function() _cancel; final void Function() _cancel;
@ -135,6 +145,7 @@ class JenkinsClient {
DownloadProgressEvent( DownloadProgressEvent(
receivedBytes: received, receivedBytes: received,
totalBytes: totalBytes, totalBytes: totalBytes,
chunkBytes: chunk,
), ),
); );
}, },

View file

@ -36,6 +36,47 @@ void main() {
}); });
group('ArtifactStagingService.stageApk', () { group('ArtifactStagingService.stageApk', () {
test('stageApk creates file directly under staging root for slash job name', () async {
final slashBuild = JenkinsBuild(
number: 5,
jobName: 'team/android-app',
url: Uri.parse('https://jenkins.example/job/team/job/android-app/5/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [],
);
final bytes = List<int>.filled(512, 1);
final staged = await service.stageApk(
build: slashBuild,
artifact: _apk,
byteStream: Stream.fromIterable([bytes]),
);
// file must be directly under tempDir no subdirectory created
expect(staged.path, startsWith(tempDir.path));
expect(staged.path.replaceFirst(tempDir.path, '').split('/').length, 2);
expect(File(staged.path).parent.path, tempDir.path);
});
test('stageApk creates file directly under staging root for backslash job name', () async {
final bsBuild = JenkinsBuild(
number: 6,
jobName: r'team\android-app',
url: Uri.parse('https://jenkins.example/job/team/android-app/6/'),
startedAt: null,
result: 'SUCCESS',
artifacts: const [],
);
final bytes = List<int>.filled(512, 1);
final staged = await service.stageApk(
build: bsBuild,
artifact: _apk,
byteStream: Stream.fromIterable([bytes]),
);
expect(File(staged.path).parent.path, tempDir.path);
});
test('stageApk writes downloaded bytes into temp directory', () async { test('stageApk writes downloaded bytes into temp directory', () async {
final bytes = List<int>.filled(1024, 0xAB); final bytes = List<int>.filled(1024, 0xAB);
final staged = await service.stageApk( final staged = await service.stageApk(
@ -89,6 +130,25 @@ void main() {
final file = File('${tempDir.path}/android-app_1_app-qa.apk'); final file = File('${tempDir.path}/android-app_1_app-qa.apk');
expect(await file.exists(), isFalse); expect(await file.exists(), isFalse);
}); });
test('stageApk rejects non apk artifact filename', () async {
const nonApk = BuildArtifact(
fileName: 'app-qa.txt',
relativePath: 'outputs/app-qa.txt',
);
await expectLater(
service.stageApk(
build: _makeBuild(),
artifact: nonApk,
byteStream: Stream.value([1, 2, 3]),
),
throwsA(isA<ArtifactVerifyException>()),
);
final file = File('${tempDir.path}/android-app_1_app-qa.txt');
expect(await file.exists(), isFalse);
});
}); });
group('ArtifactStagingService.verify', () { group('ArtifactStagingService.verify', () {
@ -139,10 +199,7 @@ void main() {
}); });
test('cleanup is safe when file does not exist', () async { test('cleanup is safe when file does not exist', () async {
await expectLater( await expectLater(service.cleanup('${tempDir.path}/gone.apk'), completes);
service.cleanup('${tempDir.path}/gone.apk'),
completes,
);
}); });
}); });
} }

View file

@ -501,7 +501,7 @@ void main() {
buildLoader: (_) async => [ buildLoader: (_) async => [
_makeBuild(number: 7, artifacts: const [apk]), _makeBuild(number: 7, artifacts: const [apk]),
], ],
onInstallRequested: (build, artifact) { onInstallRequested: (build, artifact, _) {
requestedBuild = build; requestedBuild = build;
requestedArtifact = artifact; requestedArtifact = artifact;
}, },
@ -599,6 +599,41 @@ void main() {
// UI-4: Download States // UI-4: Download States
// Converts a raw byte generator into a DownloadTask carrying progress events.
ArtifactDownloader taskDownloader(
Stream<List<int>> Function() makeStream, {
int? totalBytes,
}) {
return (build, artifact) {
var received = 0;
final events = makeStream().map((chunk) {
received += chunk.length;
return DownloadProgressEvent(
receivedBytes: received,
totalBytes: totalBytes,
chunkBytes: chunk,
);
});
// Append isComplete event after all chunks
final withComplete = events.transform(
StreamTransformer<DownloadProgressEvent, DownloadProgressEvent>.fromHandlers(
handleData: (event, sink) => sink.add(event),
handleDone: (sink) {
sink.add(
DownloadProgressEvent(
receivedBytes: received,
totalBytes: totalBytes,
isComplete: true,
),
);
sink.close();
},
),
);
return DownloadTask.fromStream(withComplete);
};
}
Future<void> navigateToCtaWithDownloader( Future<void> navigateToCtaWithDownloader(
WidgetTester tester, { WidgetTester tester, {
required ArtifactDownloader downloader, required ArtifactDownloader downloader,
@ -634,11 +669,11 @@ void main() {
await navigateToCtaWithDownloader( await navigateToCtaWithDownloader(
tester, tester,
downloader: (build, artifact) async* { downloader: taskDownloader(() async* {
yield List<int>.filled(512, 1); yield List<int>.filled(512, 1);
await pauseCompleter.future; await pauseCompleter.future;
yield List<int>.filled(512, 2); yield List<int>.filled(512, 2);
}, }),
stager: ({required build, required artifact, required byteStream}) async { stager: ({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>(); await byteStream.drain<void>();
return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024); return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024);
@ -650,7 +685,49 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.byKey(const ValueKey('download-progress-bar')), findsOneWidget); expect(find.byKey(const ValueKey('download-progress-bar')), findsOneWidget);
expect(find.byKey(const ValueKey('download-cancel-button')), findsOneWidget); expect(
find.byKey(const ValueKey('download-cancel-button')),
findsOneWidget,
);
pauseCompleter.complete();
});
testWidgets('shows determinate progress label when total bytes are known', (
tester,
) async {
final pauseCompleter = Completer<void>();
await navigateToCtaWithDownloader(
tester,
downloader: taskDownloader(
() async* {
yield List<int>.filled(512, 1);
await pauseCompleter.future;
yield List<int>.filled(512, 2);
},
totalBytes: 1024,
),
stager: ({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>();
return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024);
},
);
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pump();
await tester.pump();
// Determinate label shows "received / total" format
expect(
find.byWidgetPredicate(
(w) =>
w is Text &&
(w.key == const ValueKey('download-progress-label')) &&
(w.data?.contains(' / ') ?? false),
),
findsOneWidget,
);
pauseCompleter.complete(); pauseCompleter.complete();
}); });
@ -659,16 +736,22 @@ void main() {
tester, tester,
) async { ) async {
final pauseCompleter = Completer<void>(); final pauseCompleter = Completer<void>();
final stagerErrorCompleter = Completer<Object>();
await navigateToCtaWithDownloader( await navigateToCtaWithDownloader(
tester, tester,
downloader: (build, artifact) async* { downloader: taskDownloader(() async* {
yield List<int>.filled(256, 1); yield List<int>.filled(256, 1);
await pauseCompleter.future; await pauseCompleter.future;
yield List<int>.filled(256, 2); yield List<int>.filled(256, 2);
}, }),
stager: ({required build, required artifact, required byteStream}) async { stager: ({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>(); try {
await byteStream.drain<void>();
} catch (e) {
stagerErrorCompleter.complete(e);
rethrow;
}
return const StagedApk(path: '/tmp/app.apk', sizeBytes: 512); return const StagedApk(path: '/tmp/app.apk', sizeBytes: 512);
}, },
); );
@ -677,7 +760,10 @@ void main() {
await tester.pump(); await tester.pump();
await tester.pump(); await tester.pump();
expect(find.byKey(const ValueKey('download-cancel-button')), findsOneWidget); expect(
find.byKey(const ValueKey('download-cancel-button')),
findsOneWidget,
);
await tester.tap(find.byKey(const ValueKey('download-cancel-button'))); await tester.tap(find.byKey(const ValueKey('download-cancel-button')));
await tester.pump(); await tester.pump();
@ -686,6 +772,10 @@ void main() {
expect(find.byKey(const ValueKey('download-progress-bar')), findsNothing); expect(find.byKey(const ValueKey('download-progress-bar')), findsNothing);
expect(find.text('다운로드 취소됨'), findsOneWidget); expect(find.text('다운로드 취소됨'), findsOneWidget);
expect(
(await stagerErrorCompleter.future).toString(),
contains('cancelled'),
);
}); });
testWidgets('shows verified apk state after staging succeeds', ( testWidgets('shows verified apk state after staging succeeds', (
@ -693,9 +783,9 @@ void main() {
) async { ) async {
await navigateToCtaWithDownloader( await navigateToCtaWithDownloader(
tester, tester,
downloader: (build, artifact) async* { downloader: taskDownloader(() async* {
yield List<int>.filled(1024, 1); yield List<int>.filled(1024, 1);
}, }),
stager: ({required build, required artifact, required byteStream}) async { stager: ({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>(); await byteStream.drain<void>();
return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024); return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024);
@ -709,14 +799,70 @@ void main() {
expect(find.text('다운로드 완료'), findsOneWidget); expect(find.text('다운로드 완료'), findsOneWidget);
}); });
testWidgets(
'install button in verified state passes staged apk to callback',
(tester) async {
StagedApk? receivedStaged;
const apk = BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
);
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [
_makeBuild(number: 7, artifacts: const [apk]),
],
artifactDownloader: taskDownloader(() async* {
yield List<int>.filled(1024, 1);
}),
artifactStager: ({
required build,
required artifact,
required byteStream,
}) async {
await byteStream.drain<void>();
return const StagedApk(
path: '/tmp/app-release.apk',
sizeBytes: 1024,
);
},
onInstallRequested: (build, artifact, staged) {
receivedStaged = staged;
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
// Trigger download and wait for verified state
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verified-label')), findsOneWidget);
// Install button in verified state must forward StagedApk to callback
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
expect(receivedStaged?.path, '/tmp/app-release.apk');
expect(receivedStaged?.sizeBytes, 1024);
},
);
testWidgets('shows download failure state when staging fails', ( testWidgets('shows download failure state when staging fails', (
tester, tester,
) async { ) async {
await navigateToCtaWithDownloader( await navigateToCtaWithDownloader(
tester, tester,
downloader: (build, artifact) async* { downloader: taskDownloader(() async* {
yield List<int>.filled(512, 1); yield List<int>.filled(512, 1);
}, }),
stager: ({required build, required artifact, required byteStream}) async { stager: ({required build, required artifact, required byteStream}) async {
await byteStream.drain<void>(); await byteStream.drain<void>();
throw Exception('staging failed'); throw Exception('staging failed');
@ -730,6 +876,71 @@ void main() {
expect(find.text('다운로드 실패'), findsOneWidget); expect(find.text('다운로드 실패'), findsOneWidget);
}); });
testWidgets(
'disposes active download as cancellation and does not stage partial apk',
(tester) async {
final pauseCompleter = Completer<void>();
final stagerErrorCompleter = Completer<Object>();
const apk = BuildArtifact(
fileName: 'app-release.apk',
relativePath: 'outputs/app-release.apk',
);
await tester.pumpWidget(
_wrap(
BuildsPage(
jobLoader: () async => _sampleJobs(),
buildLoader: (_) async => [
_makeBuild(number: 7, artifacts: const [apk]),
],
artifactDownloader: taskDownloader(() async* {
yield List<int>.filled(256, 1);
await pauseCompleter.future;
yield List<int>.filled(256, 2);
}),
artifactStager: ({
required build,
required artifact,
required byteStream,
}) async {
try {
await byteStream.drain<void>();
} catch (e) {
stagerErrorCompleter.complete(e);
rethrow;
}
return const StagedApk(path: '/tmp/app.apk', sizeBytes: 512);
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('android-app'));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('build-row-7')));
await tester.pump();
await tester.tap(find.byKey(const ValueKey('install-cta-button')));
await tester.pump();
await tester.pump();
expect(find.byKey(const ValueKey('download-progress-bar')), findsOneWidget);
// Navigate back disposes _InstallCta while download is in progress
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pump();
pauseCompleter.complete(); // unblock lingering async* generator
await tester.pumpAndSettle();
expect(
(await stagerErrorCompleter.future).toString(),
contains('cancelled'),
);
expect(find.byKey(const ValueKey('verified-label')), findsNothing);
},
);
testWidgets( testWidgets(
'renders selected job view without overflow at compact viewport', 'renders selected job view without overflow at compact viewport',
(tester) async { (tester) async {

View file

@ -288,10 +288,13 @@ void main() {
expect(events, hasLength(3)); expect(events, hasLength(3));
expect(events[0].receivedBytes, chunk1.length); expect(events[0].receivedBytes, chunk1.length);
expect(events[0].chunkBytes, chunk1);
expect(events[0].isComplete, isFalse); expect(events[0].isComplete, isFalse);
expect(events[1].receivedBytes, chunk1.length + chunk2.length); expect(events[1].receivedBytes, chunk1.length + chunk2.length);
expect(events[1].chunkBytes, chunk2);
expect(events[1].isComplete, isFalse); expect(events[1].isComplete, isFalse);
expect(events[2].isComplete, isTrue); expect(events[2].isComplete, isTrue);
expect(events[2].chunkBytes, isEmpty);
expect(events[2].receivedBytes, chunk1.length + chunk2.length); expect(events[2].receivedBytes, chunk1.length + chunk2.length);
expect(events[2].totalBytes, chunk1.length + chunk2.length); expect(events[2].totalBytes, chunk1.length + chunk2.length);
}); });