From c081096ca2ecaaed7b49b6d17485c930e59947bc Mon Sep 17 00:00:00 2001 From: toki Date: Wed, 10 Jun 2026 09:53:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(artifact-browser):=20download=20staging=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=EA=B3=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다운로드 스테이징 플로우 변경에 따른 서비스/위젯 동작을 반영하고, 관련 코드리뷰/플랜 아카이브를 함께 동기화한다. --- .../code_review_local_G07_0.log | 221 ++++++++++++++++ .../code_review_local_G08_1.log | 214 ++++++++++++++++ .../code_review_local_G08_2.log} | 127 ++++++---- .../04+03_download_staging/complete.log | 50 ++++ .../plan_local_G07_0.log} | 0 .../plan_local_G08_1.log | 178 +++++++++++++ .../plan_local_G08_2.log | 144 +++++++++++ lib/src/features/builds/builds_page.dart | 125 +++++++--- .../services/artifact_staging_service.dart | 21 +- lib/src/services/jenkins_client.dart | 11 + test/artifact_staging_service_test.dart | 65 ++++- test/builds_page_test.dart | 235 +++++++++++++++++- test/jenkins_client_test.dart | 3 + 13 files changed, 1277 insertions(+), 117 deletions(-) create mode 100644 agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G07_0.log create mode 100644 agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G08_1.log rename agent-task/{m-artifact-browser/04+03_download_staging/CODE_REVIEW-local-G07.md => archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G08_2.log} (63%) create mode 100644 agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/complete.log rename agent-task/{m-artifact-browser/04+03_download_staging/PLAN-local-G07.md => archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G07_0.log} (100%) create mode 100644 agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G08_1.log create mode 100644 agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G08_2.log diff --git a/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G07_0.log b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G07_0.log new file mode 100644 index 0000000..47d43f5 --- /dev/null +++ b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G07_0.log @@ -0,0 +1,221 @@ + + +# Code Review Reference - DOWNLOAD_STAGING + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-06-09 +task=m-artifact-browser/04+03_download_staging, plan=0, tag=DOWNLOAD_STAGING + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/usable-mvp/milestones/artifact-browser.md` +- Task ids: + - `download-progress`: artifact 다운로드 진행률과 취소 상태를 표시한다. + - `temp-download`: 다운로드 파일을 앱 임시/staging 경로에 저장하고 설치 완료 또는 취소 후 삭제한다. + - `artifact-verify`: 다운로드 완료 후 파일 존재, 확장자, 크기 정보를 확인한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G07.md` → `code_review_local_G07_N.log`, `PLAN-local-G07.md` → `plan_local_G07_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 code-review가 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다. +4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [DOWNLOAD_STAGING-1] Jenkins artifact 다운로드를 진행률/취소 가능한 흐름으로 확장하고 mock HTTP 테스트를 추가한다. | [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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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> Function(JenkinsBuild, BuildArtifact)` 형태로 정의하여 production 쪽에서 credentials를 closure로 캡처하고 raw byte stream을 직접 돌려주도록 설계 변경했다. `DownloadTask`/`DownloadProgressEvent`는 `JenkinsClient` API로 유지하고, UI 주입 타입과 분리했다. +- **`stageApk` overload 단순화**: 플랜의 `task.events` 기반 `stageApk` 시그니처 대신 `byteStream: Stream>`을 직접 받는 단일 메서드로 구현했다. 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`는 작성하지 않는다. diff --git a/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G08_1.log b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G08_1.log new file mode 100644 index 0000000..8c2a9df --- /dev/null +++ b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G08_1.log @@ -0,0 +1,214 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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>`를 반환했으나 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`는 작성하지 않는다. diff --git a/agent-task/m-artifact-browser/04+03_download_staging/CODE_REVIEW-local-G07.md b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G08_2.log similarity index 63% rename from agent-task/m-artifact-browser/04+03_download_staging/CODE_REVIEW-local-G07.md rename to agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G08_2.log index da2c994..c21adba 100644 --- a/agent-task/m-artifact-browser/04+03_download_staging/CODE_REVIEW-local-G07.md +++ b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/code_review_local_G08_2.log @@ -1,6 +1,6 @@ - + -# 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.** > The task is NOT complete until every implementation-owned section below is filled in. @@ -13,8 +13,8 @@ ## 개요 -date=2026-06-09 -task=m-artifact-browser/04+03_download_staging, plan=0, tag=DOWNLOAD_STAGING +date=2026-06-10 +task=m-artifact-browser/04+03_download_staging, plan=2, tag=REVIEW2_DOWNLOAD_STAGING ## Roadmap Targets @@ -33,7 +33,7 @@ task=m-artifact-browser/04+03_download_staging, plan=0, tag=DOWNLOAD_STAGING 리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. 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 이동한다. 4. PASS이고 task group이 `m-`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. 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 테스트를 추가한다. | [ ] | -| [DOWNLOAD_STAGING-2] APK를 앱 임시/staging 경로에 저장하고 취소/실패/완료 cleanup 및 파일 검증 테스트를 추가한다. | [ ] | -| [DOWNLOAD_STAGING-3] `BuildsPage` 설치 CTA를 다운로드 상태 UI에 연결하고 진행률, 취소, 완료, 실패 widget test를 추가한다. | [ ] | +| [REVIEW2_DOWNLOAD_STAGING-1] active download 중 widget dispose가 partial staging을 성공 완료로 처리하지 않도록 cancellation cleanup 경로를 보강한다. | [x] | +| [REVIEW2_DOWNLOAD_STAGING-2] dispose/route-away 중 partial byte stream이 stager cleanup 경로로 들어가는 widget test를 추가한다. | [x] | ## 구현 체크리스트 -- [ ] [DOWNLOAD_STAGING-1] Jenkins artifact 다운로드를 진행률/취소 가능한 흐름으로 확장하고 mock HTTP 테스트를 추가한다. -- [ ] [DOWNLOAD_STAGING-2] APK를 앱 임시/staging 경로에 저장하고 취소/실패/완료 cleanup 및 파일 검증 테스트를 추가한다. -- [ ] [DOWNLOAD_STAGING-3] `BuildsPage` 설치 CTA를 다운로드 상태 UI에 연결하고 진행률, 취소, 완료, 실패 widget test를 추가한다. -- [ ] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다. -- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. +- [x] [REVIEW2_DOWNLOAD_STAGING-1] active download 중 widget dispose가 partial staging을 성공 완료로 처리하지 않도록 cancellation cleanup 경로를 보강한다. +- [x] [REVIEW2_DOWNLOAD_STAGING-2] dispose/route-away 중 partial byte stream이 stager cleanup 경로로 들어가는 widget test를 추가한다. +- [x] 최종 검증 명령을 실행하고 remote runner 결과를 review stub에 기록한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. ## 코드리뷰 전용 체크리스트 > **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. > 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. -- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. -- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. -- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_{review_lane}_GNN_N.log`로 아카이브한다. -- [ ] active `PLAN-*-G??.md`를 `plan_{build_lane}_GNN_M.log`로 아카이브한다. -- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. -- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. -- [ ] PASS이면 active task 디렉터리 `agent-task/m-artifact-browser/04+03_download_staging/`를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. -- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. -- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-artifact-browser/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [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하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-artifact-browser/04+03_download_staging/`를 `agent-task/archive/YYYY/MM/m-artifact-browser/04+03_download_staging/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [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`를 작성하지 않는다. - [ ] 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로 이동한다. ## 계획 대비 변경 사항 -_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ +계획 그대로 구현했다. 변경 없음. ## 주요 설계 결정 -_구현 에이전트가 주요 설계 결정 사항을 기록한다._ +- **`_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 계약을 깨지 않는지 확인한다. -- Partial file cleanup이 성공, 실패, 취소 경로에서 모두 보장되는지 확인한다. -- Verified APK가 존재/확장자/크기 검증을 통과해야만 다음 handoff로 넘어갈 수 있는지 확인한다. -- 테스트 출력이 실제 코드 경로와 일치하고 remote runner 검증이 포함됐는지 확인한다. +- dispose/route-away cancellation이 버튼 cancel과 동일하게 `_ArtifactDownloadCancelledException`을 stager byte stream에 전달하는지 확인한다. +- active download 중 뒤로가기 또는 widget 교체가 partial file을 staged success로 남기지 않는지 확인한다. +- 새 widget test가 stager error 또는 cleanup signal을 직접 관찰하는지 확인한다. +- 기존 cancel/progress/verified handoff 테스트가 계속 통과하는지 확인한다. ## 검증 결과 @@ -112,43 +112,48 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 - 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. - mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. -### DOWNLOAD_STAGING-1 중간 검증 -```bash -$ flutter test test/jenkins_client_test.dart -(output) +### REVIEW2_DOWNLOAD_STAGING-1 중간 검증 ``` - -### DOWNLOAD_STAGING-2 중간 검증 -```bash -$ flutter test test/artifact_staging_service_test.dart -(output) -``` - -### DOWNLOAD_STAGING-3 중간 검증 -```bash $ flutter test test/builds_page_test.dart -(output) +... +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 -(output) +(no output — no whitespace errors) -$ flutter test test/jenkins_client_test.dart -(output) - -$ flutter test test/artifact_staging_service_test.dart -(output) +$ flutter analyze +Analyzing appsok... +No issues found! (ran in 4.8s) $ 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'\''' -(output) +$ flutter test test/jenkins_client_test.dart test/artifact_staging_service_test.dart test/builds_page_test.dart +... +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'\''' -(output) +$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '"'"'cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && flutter analyze'"'"'' +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 | | 리뷰어를 위한 체크포인트 | Fixed at stub creation | Implementing agent must not modify | | 검증 결과 | 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/complete.log b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/complete.log new file mode 100644 index 0000000..b331253 --- /dev/null +++ b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-artifact-browser/04+03_download_staging/PLAN-local-G07.md b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G07_0.log similarity index 100% rename from agent-task/m-artifact-browser/04+03_download_staging/PLAN-local-G07.md rename to agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G07_0.log diff --git a/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G08_1.log b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G08_1.log new file mode 100644 index 0000000..395cf34 --- /dev/null +++ b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G08_1.log @@ -0,0 +1,178 @@ + + +# 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>`만 받으므로 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!`. diff --git a/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G08_2.log b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G08_2.log new file mode 100644 index 0000000..f4ef283 --- /dev/null +++ b/agent-task/archive/2026/06/m-artifact-browser/04+03_download_staging/plan_local_G08_2.log @@ -0,0 +1,144 @@ + + +# 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!`. diff --git a/lib/src/features/builds/builds_page.dart b/lib/src/features/builds/builds_page.dart index 942ab27..96ec4ef 100644 --- a/lib/src/features/builds/builds_page.dart +++ b/lib/src/features/builds/builds_page.dart @@ -12,13 +12,12 @@ typedef JenkinsBuildLoader = Future> Function(JenkinsJob job); 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 -/// closes on completion or cancellation. The caller may cancel by closing a -/// subscription before all bytes arrive. +/// Returns a [DownloadTask] that emits [DownloadProgressEvent]s carrying both +/// chunk bytes and cumulative progress. The task can be cancelled mid-stream. typedef ArtifactDownloader = - Stream> Function(JenkinsBuild build, BuildArtifact artifact); + DownloadTask Function(JenkinsBuild build, BuildArtifact artifact); typedef ArtifactStager = Future Function({ @@ -488,6 +487,13 @@ class _BuildRow extends StatelessWidget { enum _DownloadState { idle, downloading, verified, failed, cancelled } +class _ArtifactDownloadCancelledException implements Exception { + const _ArtifactDownloadCancelledException(); + + @override + String toString() => 'Artifact download cancelled'; +} + class _InstallCta extends StatefulWidget { const _InstallCta({ super.key, @@ -512,13 +518,15 @@ class _InstallCtaState extends State<_InstallCta> { _DownloadState _dlState = _DownloadState.idle; int _receivedBytes = 0; int? _totalBytes; - String? _stagedPath; + StagedApk? _stagedApk; String? _errorMessage; - StreamSubscription>? _downloadSub; + DownloadTask? _task; + StreamSubscription? _eventSub; + StreamController>? _byteController; @override void dispose() { - _downloadSub?.cancel(); + _cancelDownload(); super.dispose(); } @@ -527,28 +535,44 @@ class _InstallCtaState extends State<_InstallCta> { final stager = widget.artifactStager; if (downloader == null || stager == null) return; + final task = downloader(widget.item, widget.artifact); + final byteController = StreamController>(); + setState(() { _dlState = _DownloadState.downloading; _receivedBytes = 0; _totalBytes = null; - _stagedPath = null; + _stagedApk = null; _errorMessage = null; + _task = task; + _byteController = byteController; }); - // Wrap the raw byte stream to track received bytes for the progress indicator. - final rawStream = downloader(widget.item, widget.artifact); - final progressController = StreamController>(); - - _downloadSub = rawStream.listen( - (chunk) { - if (!mounted) return; - setState(() => _receivedBytes += chunk.length); - progressController.add(chunk); + // Forward chunk bytes to stager; update progress UI from event metadata. + _eventSub = task.events.listen( + (event) { + if (!mounted || byteController.isClosed) return; + setState(() { + _receivedBytes = event.receivedBytes; + _totalBytes = event.totalBytes; + }); + if (event.chunkBytes.isNotEmpty) { + byteController.add(event.chunkBytes); + } + if (event.isComplete && !byteController.isClosed) { + byteController.close(); + } }, - onDone: () => progressController.close(), onError: (Object e) { - progressController.addError(e); - progressController.close(); + if (!byteController.isClosed) { + byteController.addError(e); + byteController.close(); + } + }, + onDone: () { + if (!byteController.isClosed) { + byteController.close(); + } }, cancelOnError: true, ); @@ -557,27 +581,43 @@ class _InstallCtaState extends State<_InstallCta> { final staged = await stager( build: widget.item, artifact: widget.artifact, - byteStream: progressController.stream, + byteStream: byteController.stream, ); if (!mounted) return; setState(() { _dlState = _DownloadState.verified; - _stagedPath = staged.path; + _stagedApk = staged; }); } catch (e) { if (!mounted) return; + if (_dlState == _DownloadState.cancelled) return; setState(() { _dlState = _DownloadState.failed; _errorMessage = e.toString(); }); } 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() { - _downloadSub?.cancel(); - _downloadSub = null; + _cancelDownload(); if (!mounted) return; setState(() => _dlState = _DownloadState.cancelled); } @@ -596,10 +636,8 @@ class _InstallCtaState extends State<_InstallCta> { child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: switch (_dlState) { - _DownloadState.idle || _DownloadState.cancelled => _buildIdle( - context, - colorScheme, - ), + _DownloadState.idle || + _DownloadState.cancelled => _buildIdle(context, colorScheme), _DownloadState.downloading => _buildDownloading(context, colorScheme), _DownloadState.verified => _buildVerified(context, colorScheme), _DownloadState.failed => _buildFailed(context, colorScheme), @@ -621,9 +659,9 @@ class _InstallCtaState extends State<_InstallCta> { children: [ Text( _dlState == _DownloadState.cancelled ? '다운로드 취소됨' : '설치 준비', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w700, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -652,7 +690,11 @@ class _InstallCtaState extends State<_InstallCta> { key: const ValueKey('install-cta-button'), onPressed: widget.onInstallRequested == null ? null - : () => widget.onInstallRequested!(widget.item, widget.artifact), + : () => widget.onInstallRequested!( + widget.item, + widget.artifact, + null, + ), icon: const Icon(Icons.install_mobile), label: const Text('설치'), ), @@ -714,12 +756,13 @@ class _InstallCtaState extends State<_InstallCta> { Text( key: const ValueKey('verified-label'), '다운로드 완료', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.w700, - ), + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), ), Text( - _stagedPath ?? '#${widget.item.number} · ${widget.artifact.fileName}', + _stagedApk?.path ?? + '#${widget.item.number} · ${widget.artifact.fileName}', style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: 12, @@ -735,7 +778,11 @@ class _InstallCtaState extends State<_InstallCta> { key: const ValueKey('install-cta-button'), onPressed: widget.onInstallRequested == null ? null - : () => widget.onInstallRequested!(widget.item, widget.artifact), + : () => widget.onInstallRequested!( + widget.item, + widget.artifact, + _stagedApk, + ), icon: const Icon(Icons.install_mobile), label: const Text('설치'), ), diff --git a/lib/src/services/artifact_staging_service.dart b/lib/src/services/artifact_staging_service.dart index 9541ca6..b8f203a 100644 --- a/lib/src/services/artifact_staging_service.dart +++ b/lib/src/services/artifact_staging_service.dart @@ -30,9 +30,13 @@ class ArtifactStagingService { return getTemporaryDirectory(); } + static String _sanitize(String value) => + value.replaceAll(RegExp(r'[/\\]'), '_'); + String _fileName(JenkinsBuild build, BuildArtifact artifact) { - final safe = artifact.fileName.replaceAll(RegExp(r'[/\\]'), '_'); - return '${build.jobName}_${build.number}_$safe'; + final safeJob = _sanitize(build.jobName); + final safeFile = _sanitize(artifact.fileName); + return '${safeJob}_${build.number}_$safeFile'; } /// Downloads bytes from [byteStream] into a temp file and returns a [StagedApk]. @@ -64,13 +68,14 @@ class ArtifactStagingService { await sink.close(); - final size = await file.length(); - if (size == 0) { - await file.delete(); - throw ArtifactVerifyException('Downloaded file is empty'); + try { + return await verify(file.path); + } on ArtifactVerifyException { + 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. diff --git a/lib/src/services/jenkins_client.dart b/lib/src/services/jenkins_client.dart index e1694c1..7c0a2fe 100644 --- a/lib/src/services/jenkins_client.dart +++ b/lib/src/services/jenkins_client.dart @@ -10,16 +10,26 @@ class DownloadProgressEvent { required this.receivedBytes, this.totalBytes, this.isComplete = false, + this.chunkBytes = const [], }); final int receivedBytes; final int? totalBytes; final bool isComplete; + /// Raw bytes delivered in this event. Empty on the final [isComplete] event. + final List chunkBytes; } class DownloadTask { 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 events, { + void Function()? onCancel, + }) : this._(events, onCancel ?? () {}); + final Stream events; final void Function() _cancel; @@ -135,6 +145,7 @@ class JenkinsClient { DownloadProgressEvent( receivedBytes: received, totalBytes: totalBytes, + chunkBytes: chunk, ), ); }, diff --git a/test/artifact_staging_service_test.dart b/test/artifact_staging_service_test.dart index 7037ceb..72da975 100644 --- a/test/artifact_staging_service_test.dart +++ b/test/artifact_staging_service_test.dart @@ -36,6 +36,47 @@ void main() { }); 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.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.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 { final bytes = List.filled(1024, 0xAB); final staged = await service.stageApk( @@ -89,6 +130,25 @@ void main() { final file = File('${tempDir.path}/android-app_1_app-qa.apk'); 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()), + ); + + final file = File('${tempDir.path}/android-app_1_app-qa.txt'); + expect(await file.exists(), isFalse); + }); }); group('ArtifactStagingService.verify', () { @@ -139,10 +199,7 @@ void main() { }); test('cleanup is safe when file does not exist', () async { - await expectLater( - service.cleanup('${tempDir.path}/gone.apk'), - completes, - ); + await expectLater(service.cleanup('${tempDir.path}/gone.apk'), completes); }); }); } diff --git a/test/builds_page_test.dart b/test/builds_page_test.dart index a773098..f161b6a 100644 --- a/test/builds_page_test.dart +++ b/test/builds_page_test.dart @@ -501,7 +501,7 @@ void main() { buildLoader: (_) async => [ _makeBuild(number: 7, artifacts: const [apk]), ], - onInstallRequested: (build, artifact) { + onInstallRequested: (build, artifact, _) { requestedBuild = build; requestedArtifact = artifact; }, @@ -599,6 +599,41 @@ void main() { // ── UI-4: Download States ───────────────────────────────────────── + // Converts a raw byte generator into a DownloadTask carrying progress events. + ArtifactDownloader taskDownloader( + Stream> 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.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 navigateToCtaWithDownloader( WidgetTester tester, { required ArtifactDownloader downloader, @@ -634,11 +669,11 @@ void main() { await navigateToCtaWithDownloader( tester, - downloader: (build, artifact) async* { + downloader: taskDownloader(() async* { yield List.filled(512, 1); await pauseCompleter.future; yield List.filled(512, 2); - }, + }), stager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024); @@ -650,7 +685,49 @@ void main() { await tester.pump(); 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(); + + await navigateToCtaWithDownloader( + tester, + downloader: taskDownloader( + () async* { + yield List.filled(512, 1); + await pauseCompleter.future; + yield List.filled(512, 2); + }, + totalBytes: 1024, + ), + stager: ({required build, required artifact, required byteStream}) async { + await byteStream.drain(); + 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(); }); @@ -659,16 +736,22 @@ void main() { tester, ) async { final pauseCompleter = Completer(); + final stagerErrorCompleter = Completer(); await navigateToCtaWithDownloader( tester, - downloader: (build, artifact) async* { + downloader: taskDownloader(() async* { yield List.filled(256, 1); await pauseCompleter.future; yield List.filled(256, 2); - }, + }), stager: ({required build, required artifact, required byteStream}) async { - await byteStream.drain(); + try { + await byteStream.drain(); + } catch (e) { + stagerErrorCompleter.complete(e); + rethrow; + } return const StagedApk(path: '/tmp/app.apk', sizeBytes: 512); }, ); @@ -677,7 +760,10 @@ void main() { 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.pump(); @@ -686,6 +772,10 @@ void main() { expect(find.byKey(const ValueKey('download-progress-bar')), findsNothing); expect(find.text('다운로드 취소됨'), findsOneWidget); + expect( + (await stagerErrorCompleter.future).toString(), + contains('cancelled'), + ); }); testWidgets('shows verified apk state after staging succeeds', ( @@ -693,9 +783,9 @@ void main() { ) async { await navigateToCtaWithDownloader( tester, - downloader: (build, artifact) async* { + downloader: taskDownloader(() async* { yield List.filled(1024, 1); - }, + }), stager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024); @@ -709,14 +799,70 @@ void main() { 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.filled(1024, 1); + }), + artifactStager: ({ + required build, + required artifact, + required byteStream, + }) async { + await byteStream.drain(); + 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', ( tester, ) async { await navigateToCtaWithDownloader( tester, - downloader: (build, artifact) async* { + downloader: taskDownloader(() async* { yield List.filled(512, 1); - }, + }), stager: ({required build, required artifact, required byteStream}) async { await byteStream.drain(); throw Exception('staging failed'); @@ -730,6 +876,71 @@ void main() { expect(find.text('다운로드 실패'), findsOneWidget); }); + testWidgets( + 'disposes active download as cancellation and does not stage partial apk', + (tester) async { + final pauseCompleter = Completer(); + final stagerErrorCompleter = Completer(); + + 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.filled(256, 1); + await pauseCompleter.future; + yield List.filled(256, 2); + }), + artifactStager: ({ + required build, + required artifact, + required byteStream, + }) async { + try { + await byteStream.drain(); + } 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( 'renders selected job view without overflow at compact viewport', (tester) async { diff --git a/test/jenkins_client_test.dart b/test/jenkins_client_test.dart index a16a0e5..0e0496c 100644 --- a/test/jenkins_client_test.dart +++ b/test/jenkins_client_test.dart @@ -288,10 +288,13 @@ void main() { 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); });