feat: logcat console milestone completion - archive subtasks, update console features
This commit is contained in:
parent
2877616548
commit
8c0bb0a6b0
16 changed files with 1179 additions and 82 deletions
|
|
@ -71,4 +71,8 @@
|
|||
- 표준선(선택): MVP에서는 logcat viewer를 우선하고 interactive shell은 후속 후보로 둔다.
|
||||
- 선행 작업: USB 설치와 device 선택
|
||||
- 후속 작업: 설치 세션 이력과 오류 리포트
|
||||
- 작업 현황:
|
||||
- `01_logcat_process`: PASS 완료. archive: `agent-task/archive/2026/06/m-logcat-console/01_logcat_process/complete.log`. `Roadmap Completion`이 없어 기능 Task 체크는 no-op.
|
||||
- `02+01_console_stream`: 활성 작업. Roadmap Targets: `stream`, `lifecycle`. 선행 `01_logcat_process` 완료 조건 충족.
|
||||
- `03+02_auto_scroll`: 활성 작업. Roadmap Targets: `auto-scroll`. 선행 `02+01_console_stream` 완료 대기.
|
||||
- 확인 필요: 없음
|
||||
|
|
|
|||
|
|
@ -43,42 +43,44 @@ task=m-logcat-console/02+01_console_stream, plan=0, tag=LOGCAT_STREAM
|
|||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [LOGCAT_STREAM-1] Shell Device Selection | [ ] |
|
||||
| [LOGCAT_STREAM-2] Live Stream Lifecycle | [ ] |
|
||||
| [LOGCAT_STREAM-1] Shell Device Selection | [x] |
|
||||
| [LOGCAT_STREAM-2] Live Stream Lifecycle | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `AppSokApp`에서 logcat session starter를 `AppSokShell`로 주입한다.
|
||||
- [ ] `DevicesPage` logcat 버튼이 ready device를 console 선택 상태로 넘기고 콘솔 탭으로 이동하게 한다.
|
||||
- [ ] `ConsolePage`가 선택 device의 logcat stream을 시작하고 화면에 append하며 dispose/device 변경 시 session을 정리한다.
|
||||
- [ ] `flutter test test/widget_test.dart test/console_page_test.dart`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
- [x] `AppSokApp`에서 logcat session starter를 `AppSokShell`로 주입한다.
|
||||
- [x] `DevicesPage` logcat 버튼이 ready device를 console 선택 상태로 넘기고 콘솔 탭으로 이동하게 한다.
|
||||
- [x] `ConsolePage`가 선택 device의 logcat stream을 시작하고 화면에 append하며 dispose/device 변경 시 session을 정리한다.
|
||||
- [x] `flutter test test/widget_test.dart test/console_page_test.dart`를 실행한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_local_G06_M.log`로 아카이브한다.
|
||||
- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G06_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_local_G06_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-logcat-console/02+01_console_stream/`를 `agent-task/archive/YYYY/MM/m-logcat-console/02+01_console_stream/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-logcat-console/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G06.md`와 `CODE_REVIEW-local-G06.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
- `test/widget_test.dart` 에 `Process`, `ProcessSignal`, `IOSink` 등의 타입을 mocking하기 위해 `dart:io` 패키지를 추가로 import했습니다.
|
||||
- `AdbLogcatSessionStarter` typedef를 `adb_service.dart`에 선언하여 `AdbService` 도메인 내의 타입 참조 일관성을 유지했습니다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
- `ConsolePage`가 `device` 와 `logcatStarter` 가 전달되지 않고 호출된 경우(기존 widget test 및 preview 상태 등)에는 호환성(compatibility) 유지를 위해 기존 sample log (`_sampleLogLines`)를 fallback 렌더링하도록 `_displayLines` getter를 설계했습니다.
|
||||
- `AppSokApp` 및 `AppSokShell` 에 `AdbLogcatSessionStarter?`를 dynamic dependency injection이 가능하게 필드를 추가하여, production에서는 `_adbService.startLogcat`을 주입하고 test/mocking 환경에서는 mock/fake starter를 주입할 수 있도록 testability를 극대화했습니다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
|
|
@ -94,7 +96,7 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
|
|||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `02+01_console_stream` 구현이 `01_logcat_process` complete.log 이후 시작됐는가.
|
||||
- `02+01_console_stream` 구현이 `agent-task/archive/2026/06/m-logcat-console/01_logcat_process/complete.log` 이후 시작됐는가.
|
||||
- ready device logcat 버튼만 console handoff를 수행하는가.
|
||||
- 화면 이탈과 device 변경 모두 session stop을 보장하는가.
|
||||
- Roadmap Targets의 `stream`, `lifecycle` completion 근거가 충분한가.
|
||||
|
|
@ -113,22 +115,29 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
|
|||
### LOGCAT_STREAM-1 중간 검증
|
||||
```bash
|
||||
$ flutter test test/widget_test.dart
|
||||
(output)
|
||||
00:00 +0: loading /config/workspace/appsok/test/widget_test.dart
|
||||
...
|
||||
00:06 +31: All tests passed!
|
||||
```
|
||||
|
||||
### LOGCAT_STREAM-2 중간 검증
|
||||
```bash
|
||||
$ flutter test test/console_page_test.dart test/widget_test.dart
|
||||
(output)
|
||||
00:00 +0: loading /config/workspace/appsok/test/widget_test.dart
|
||||
...
|
||||
00:07 +33: All tests passed!
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ flutter analyze
|
||||
(output)
|
||||
Analyzing appsok...
|
||||
No issues found! (ran in 4.2s)
|
||||
|
||||
$ flutter test test/widget_test.dart test/console_page_test.dart
|
||||
(output)
|
||||
00:00 +0: loading /config/workspace/appsok/test/widget_test.dart
|
||||
...
|
||||
00:07 +33: All tests passed!
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -136,3 +145,20 @@ $ flutter test test/widget_test.dart test/console_page_test.dart
|
|||
> **[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.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: FAIL
|
||||
- 차원별 평가:
|
||||
- correctness: Fail
|
||||
- completeness: Fail
|
||||
- test coverage: Fail
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Fail
|
||||
- verification trust: Pass
|
||||
- 발견된 문제:
|
||||
- Required: `lib/src/features/console/console_page.dart:120` - 계획의 LOGCAT_STREAM-1 테스트/UX는 ready device logcat 진입 후 선택된 device serial 또는 display name이 console에 표시되어야 한다고 했지만, 현재 `ConsolePage`는 `widget.device`를 stream 시작에만 쓰고 화면에는 표시하지 않습니다. `test/widget_test.dart:1484`도 `콘솔` title만 확인해서 이 요구를 놓칩니다. 콘솔 상단에 선택 device label/serial을 표시하고, ready device logcat 버튼 테스트가 해당 label/serial을 검증하도록 고치세요.
|
||||
- Required: `lib/src/features/console/console_page.dart:65` - `startLogcat`이 늦게 완료되는 동안 widget이 다른 device/starter로 갱신되면, 오래된 `session`이 `mounted` 상태만 통과하고 `_session`/`_subscription`에 붙습니다. 그러면 이전 device logcat이 현재 콘솔에 append되거나 새 session이 leak될 수 있어 `device 변경 시 process를 정리한다`는 lifecycle 요구를 만족하지 못합니다. session generation/token 또는 현재 serial/starter 대조를 추가해 stale session은 즉시 `stop()`하고, 실제 `didUpdateWidget` device 변경과 delayed starter 경계를 검증하는 widget test를 추가하세요.
|
||||
- 다음 단계:
|
||||
- WARN/FAIL 후속: `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.md`를 작성해 위 Required 문제만 좁게 수정한다.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<!-- task=m-logcat-console/03+02_auto_scroll plan=0 tag=LOGCAT_SCROLL -->
|
||||
<!-- task=m-logcat-console/02+01_console_stream plan=1 tag=REVIEW_LOGCAT_STREAM -->
|
||||
|
||||
# Code Review Reference - LOGCAT_SCROLL
|
||||
# Code Review Reference - REVIEW_LOGCAT_STREAM
|
||||
|
||||
> **[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.
|
||||
|
|
@ -14,13 +14,14 @@
|
|||
## 개요
|
||||
|
||||
date=2026-06-14
|
||||
task=m-logcat-console/03+02_auto_scroll, plan=0, tag=LOGCAT_SCROLL
|
||||
task=m-logcat-console/02+01_console_stream, plan=1, tag=REVIEW_LOGCAT_STREAM
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md`
|
||||
- Task ids:
|
||||
- `auto-scroll`: 새 로그가 들어올 때 자동 스크롤하되 pause 상태에서는 사용자의 위치를 유지한다.
|
||||
- `stream`: 선택 device의 `adb logcat` stream을 시작하고 화면에 append한다.
|
||||
- `lifecycle`: 화면 이탈 또는 device 변경 시 logcat process를 정리한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
|
@ -31,8 +32,8 @@ task=m-logcat-console/03+02_auto_scroll, plan=0, tag=LOGCAT_SCROLL
|
|||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G05.md` → `code_review_local_G05_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-logcat-console/03+02_auto_scroll/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
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-logcat-console/02+01_console_stream/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
|
|
@ -42,41 +43,44 @@ task=m-logcat-console/03+02_auto_scroll, plan=0, tag=LOGCAT_SCROLL
|
|||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [LOGCAT_SCROLL-1] Pause-Aware Auto Scroll | [ ] |
|
||||
| [REVIEW_LOGCAT_STREAM-1] Selected Device Identity | [x] |
|
||||
| [REVIEW_LOGCAT_STREAM-2] Async Session Race | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `ConsolePage`에 `ScrollController`와 pause 상태를 추가하고 새 로그 append 후 unpaused 상태에서 bottom으로 이동한다.
|
||||
- [ ] pause/resume 버튼이 auto-scroll 정책을 제어하고 pause 중 사용자 scroll offset을 유지한다.
|
||||
- [ ] `test/console_page_test.dart`에서 unpaused auto-scroll과 paused offset 유지 동작을 검증한다.
|
||||
- [ ] `flutter test test/console_page_test.dart`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
- [x] `ConsolePage`가 선택 device label/serial을 표시하고 logcat 진입 test가 이를 검증한다.
|
||||
- [x] `ConsolePage` logcat start lifecycle에 stale async session guard를 추가한다.
|
||||
- [x] delayed starter/device change widget test로 stale session stop과 현재 session 유지 동작을 검증한다.
|
||||
- [x] `flutter test test/widget_test.dart test/console_page_test.dart`를 실행한다.
|
||||
- [x] `flutter analyze`를 실행한다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G05_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_local_G05_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-logcat-console/03+02_auto_scroll/`를 `agent-task/archive/YYYY/MM/m-logcat-console/03+02_auto_scroll/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-logcat-console/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.md`를 작성하고 `complete.log`를 작성하지 않는다.
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_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-logcat-console/02+01_console_stream/`를 `agent-task/archive/YYYY/MM/m-logcat-console/02+01_console_stream/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-logcat-console/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G07.md`와 `CODE_REVIEW-local-G07.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
- `test/console_page_test.dart` 에 추가한 stale session async start 경계 테스트 중, stale session의 process가 이미 kill/close 되었기 때문에 stdout에 추가적인 이벤트를 emit하려 할 때 `StateError`가 발생하여, emitStdout 대신 `killCalled` 플래그가 true인지만 검증하도록 테스트 assertion 방식을 변경했습니다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
- `ConsolePage`에서 selected device display name과 serial을 디바이스가 null일 때에도 자연스럽도록 "device 미선택"으로 처리하여 UX 일관성을 유지했습니다.
|
||||
- `ConsolePage._startSession` 및 `_stopSession`에 `_sessionGeneration` 카운터 변수를 이용한 Generation Guard를 적용하여, 비동기 세션 시작 중 디바이스가 변경되거나 화면에서 이탈하는 경우에도 stale session이 leak되거나 현재 상태를 오염시키지 않고 즉각 `stop()` 되도록 하였습니다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
|
|
@ -92,10 +96,10 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
|
|||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `03+02_auto_scroll` 구현이 `02+01_console_stream` complete.log 이후 시작됐는가.
|
||||
- unpaused 상태에서 새 로그가 bottom으로 이동하는가.
|
||||
- paused 상태에서 사용자 scroll offset을 보존하는가.
|
||||
- Roadmap Targets의 `auto-scroll` completion 근거가 충분한가.
|
||||
- selected device display name 또는 serial이 console 화면에 표시되는가.
|
||||
- stale async `startLogcat` 완료가 현재 device/session 상태를 덮어쓰지 않는가.
|
||||
- delayed starter/device change test가 실제 `didUpdateWidget` 경로를 검증하는가.
|
||||
- `stream`, `lifecycle` Roadmap Targets를 PASS로 닫을 만큼 검증 근거가 충분한가.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
|
|
@ -108,19 +112,32 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
|
|||
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
|
||||
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
|
||||
|
||||
### LOGCAT_SCROLL-1 중간 검증
|
||||
### REVIEW_LOGCAT_STREAM-1 중간 검증
|
||||
```bash
|
||||
$ flutter test test/widget_test.dart
|
||||
00:00 +0: loading /config/workspace/appsok/test/widget_test.dart
|
||||
...
|
||||
00:06 +33: All tests passed!
|
||||
```
|
||||
|
||||
### REVIEW_LOGCAT_STREAM-2 중간 검증
|
||||
```bash
|
||||
$ flutter test test/console_page_test.dart
|
||||
(output)
|
||||
00:00 +0: loading /config/workspace/appsok/test/console_page_test.dart
|
||||
...
|
||||
00:03 +3: All tests passed!
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ flutter analyze
|
||||
(output)
|
||||
Analyzing appsok...
|
||||
No issues found! (ran in 6.0s)
|
||||
|
||||
$ flutter test test/console_page_test.dart
|
||||
(output)
|
||||
$ flutter test test/widget_test.dart test/console_page_test.dart
|
||||
00:00 +0: loading /config/workspace/appsok/test/widget_test.dart
|
||||
...
|
||||
00:06 +34: All tests passed!
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -128,3 +145,18 @@ $ flutter test test/console_page_test.dart
|
|||
> **[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.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 다음 단계:
|
||||
- PASS: `complete.log`를 작성하고 task 디렉터리를 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# Complete - m-logcat-console/02+01_console_stream
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-14
|
||||
|
||||
## 요약
|
||||
|
||||
`02+01_console_stream` logcat console stream/lifecycle 작업을 2회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_local_G06_0.log` | `code_review_local_G06_0.log` | FAIL | selected device 식별자 표시 누락과 stale async session guard 누락으로 follow-up 필요 |
|
||||
| `plan_local_G07_1.log` | `code_review_local_G07_1.log` | PASS | selected device label/serial 표시, generation guard, delayed starter/device change test로 Required 2건 해소 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `ConsolePage`가 선택된 device display name과 serial을 표시하고, device 미선택 상태도 명확히 보여준다.
|
||||
- `ConsolePage` logcat start lifecycle에 `_sessionGeneration` guard를 추가해 delayed stale session이 현재 상태를 덮어쓰지 못하게 했다.
|
||||
- ready device console handoff, live stream append/dispose, delayed starter device change 경계 테스트를 추가했다.
|
||||
- 리뷰 중 `dart format`으로 변경 파일 포맷을 정리했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `flutter analyze` - PASS; local checkout에서 `No issues found! (ran in 5.6s)`.
|
||||
- `flutter test test/widget_test.dart test/console_page_test.dart` - PASS; local checkout에서 `All tests passed!` (`+34`).
|
||||
- `git diff --check` - PASS; 출력 없음.
|
||||
- `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"'` - PASS; remote Mac runner에서 `No issues found!` 및 전체 `flutter test` `All tests passed!` (`+206`).
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md`
|
||||
- Completed task ids:
|
||||
- `stream`: PASS; evidence=`plan_local_G07_1.log`, `code_review_local_G07_1.log`; verification=`flutter test test/widget_test.dart test/console_page_test.dart`, remote `flutter test`
|
||||
- `lifecycle`: PASS; evidence=`plan_local_G07_1.log`, `code_review_local_G07_1.log`; verification=`flutter test test/console_page_test.dart`, remote `flutter test`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -69,7 +69,7 @@ Console 화면은 현재 샘플 로그만 렌더링하고 실제 device 선택
|
|||
- split decision policy를 먼저 평가했다.
|
||||
- 공유 task group: `m-logcat-console`
|
||||
- 이 subtask는 `02+01_console_stream`이며 predecessor `01`은 `agent-task/m-logcat-console/01_logcat_process/complete.log` 또는 archive matching `complete.log`가 필요하다.
|
||||
- 현재 확인 결과 predecessor complete.log는 없음. 구현 시작 전 `01_logcat_process` PASS 완료가 필요하다.
|
||||
- 현재 확인 결과 predecessor complete.log는 `agent-task/archive/2026/06/m-logcat-console/01_logcat_process/complete.log`에 있다. `01_logcat_process` PASS 완료 조건은 충족됐다.
|
||||
- `03+02_auto_scroll`은 이 plan의 live console UI에 의존한다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
|
@ -93,7 +93,7 @@ Console 화면은 현재 샘플 로그만 렌더링하고 실제 device 선택
|
|||
## 의존 관계 및 구현 순서
|
||||
|
||||
- Directory dependency: `02+01_console_stream` -> `01_logcat_process`
|
||||
- 구현 전 `agent-task/m-logcat-console/01_logcat_process/complete.log` 또는 archive `agent-task/archive/*/*/m-logcat-console/01_logcat_process/complete.log`가 있어야 한다. 현재는 없음.
|
||||
- 구현 전 `agent-task/m-logcat-console/01_logcat_process/complete.log` 또는 archive `agent-task/archive/*/*/m-logcat-console/01_logcat_process/complete.log`가 있어야 한다. 현재 충족: `agent-task/archive/2026/06/m-logcat-console/01_logcat_process/complete.log`.
|
||||
|
||||
## LOGCAT_STREAM-1 Shell Device Selection
|
||||
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<!-- task=m-logcat-console/02+01_console_stream plan=1 tag=REVIEW_LOGCAT_STREAM -->
|
||||
|
||||
# REVIEW_LOGCAT_STREAM 후속 계획
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채우는 것은 필수입니다. 구현 후 검증을 실행하고 실제 내용과 출력을 기록한 뒤 active 파일을 그대로 두고 리뷰 준비를 보고하세요. 최종 판정, log rename, `complete.log`, archive 이동은 code-review-skill 전용입니다. 구현이 사용자 결정, 사용자 소유 외부 환경, 범위 충돌로 막히면 review stub의 `사용자 리뷰 요청` 섹션에 근거를 기록하고 중단하세요. 직접 질문, 채팅 선택지, `request_user_input`, `USER_REVIEW.md` 작성, archive, `complete.log` 작성은 금지입니다. 후속 에이전트가 재실행이나 산출물 수집으로 닫을 수 있는 증거 공백은 사용자 리뷰 요청이 아닙니다.
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md`
|
||||
- Task ids:
|
||||
- `stream`: 선택 device의 `adb logcat` stream을 시작하고 화면에 append한다.
|
||||
- `lifecycle`: 화면 이탈 또는 device 변경 시 logcat process를 정리한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 리뷰 요약
|
||||
|
||||
`code_review_local_G06_0.log` 판정은 FAIL입니다. 선택된 device 식별자 표시가 빠졌고, `startLogcat`이 늦게 완료되는 경계에서 stale session이 현재 콘솔에 붙을 수 있습니다.
|
||||
|
||||
## 범위 결정 근거
|
||||
|
||||
- `filter`, `auto-scroll`, export/clipboard, clear/pause 실제 동작은 이번 후속 범위에서 제외한다.
|
||||
- 로드맵 문서는 수정하지 않는다. PASS completion event 이후 런타임이 처리한다.
|
||||
- 실패 원인이 명확하고 widget test로 재현 가능하므로 follow-up route는 `local-G07`로 둔다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `ConsolePage`가 선택 device label/serial을 표시하고 logcat 진입 test가 이를 검증한다.
|
||||
- [ ] `ConsolePage` logcat start lifecycle에 stale async session guard를 추가한다.
|
||||
- [ ] delayed starter/device change widget test로 stale session stop과 현재 session 유지 동작을 검증한다.
|
||||
- [ ] `flutter test test/widget_test.dart test/console_page_test.dart`를 실행한다.
|
||||
- [ ] `flutter analyze`를 실행한다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## REVIEW_LOGCAT_STREAM-1 Selected Device Identity
|
||||
|
||||
### 문제
|
||||
|
||||
`ConsolePage`는 선택된 `AdbDevice`를 stream 시작에만 사용하고 화면에는 표시하지 않습니다. 이전 계획의 LOGCAT_STREAM-1 테스트 기대값은 ready device logcat 진입 후 선택 device serial 또는 display name이 console에 보이는 것이었지만, 현재 test는 `콘솔` title만 확인합니다.
|
||||
|
||||
### 해결 방법
|
||||
|
||||
콘솔 상단 controls 근처에 선택 device display name과 serial을 표시합니다. `device == null`인 sample/preview 상태에서는 기존 sample console UI가 어색하지 않도록 표시를 생략하거나 "device 미선택" 상태를 명확히 둡니다.
|
||||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `lib/src/features/console/console_page.dart`: `widget.device` 기반 selected device label/serial UI 추가.
|
||||
- [ ] `test/widget_test.dart`: `ready device logcat button opens console for that device`가 console 이동 후 device label 또는 serial을 검증하도록 보강.
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
- 기존 `ready device logcat button opens console for that device` test를 보강한다.
|
||||
- assertion: logcat 버튼 tap 후 `device-ready-logcat` 또는 `Pixel 8`이 console 화면에 표시된다.
|
||||
|
||||
### 중간 검증
|
||||
|
||||
```bash
|
||||
flutter test test/widget_test.dart
|
||||
```
|
||||
|
||||
## REVIEW_LOGCAT_STREAM-2 Async Session Race
|
||||
|
||||
### 문제
|
||||
|
||||
`ConsolePage._startSession`은 `await starter(serial: device.serial)` 이후 `mounted`만 확인하고 `_session`과 `_subscription`을 갱신합니다. 그 사이 widget이 다른 device/starter로 갱신되면 오래된 session이 현재 화면에 붙거나 새 session이 leak될 수 있습니다.
|
||||
|
||||
### 해결 방법
|
||||
|
||||
session generation/token 또는 capture한 serial/starter 대조를 추가합니다. `await starter` 이후 현재 widget 상태와 다르면 반환된 stale session을 즉시 `stop()`하고 화면 상태를 변경하지 않습니다. catch path도 현재 generation일 때만 error/loading 상태를 반영합니다. `_stopSession`은 기존 session/subscription을 정리하면서 pending start를 무효화해야 합니다.
|
||||
|
||||
### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `lib/src/features/console/console_page.dart`: stale async session guard 추가.
|
||||
- [ ] `test/console_page_test.dart`: delayed starter로 device 변경 중 첫 session이 늦게 반환되는 케이스 추가.
|
||||
|
||||
### 테스트 작성
|
||||
|
||||
- `ConsolePage`를 device A로 렌더링하고 starter completer A를 보류한다.
|
||||
- 같은 widget tree에서 device B로 갱신한 뒤 starter completer B를 완료한다.
|
||||
- 이후 completer A를 완료했을 때 A session은 `stop()`되고, B session line만 화면에 append되는지 확인한다.
|
||||
|
||||
### 중간 검증
|
||||
|
||||
```bash
|
||||
flutter test test/console_page_test.dart
|
||||
```
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
flutter analyze
|
||||
flutter test test/widget_test.dart test/console_page_test.dart
|
||||
```
|
||||
|
||||
검증 출력은 `CODE_REVIEW-local-G07.md`의 `검증 결과`에 실제 stdout/stderr로 기록한다.
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
<!-- task=m-logcat-console/03+02_auto_scroll plan=0 tag=LOGCAT_SCROLL -->
|
||||
|
||||
# Code Review Reference - LOGCAT_SCROLL
|
||||
|
||||
> **[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-14
|
||||
task=m-logcat-console/03+02_auto_scroll, plan=0, tag=LOGCAT_SCROLL
|
||||
|
||||
## Roadmap Targets
|
||||
|
||||
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md`
|
||||
- Task ids:
|
||||
- `auto-scroll`: 새 로그가 들어올 때 자동 스크롤하되 pause 상태에서는 사용자의 위치를 유지한다.
|
||||
- Completion mode: check-on-pass
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-local-G05.md` → `code_review_local_G05_N.log`, `PLAN-local-G05.md` → `plan_local_G05_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-logcat-console/03+02_auto_scroll/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [LOGCAT_SCROLL-1] Pause-Aware Auto Scroll | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `ConsolePage`에 `ScrollController`와 pause 상태를 추가하고 새 로그 append 후 unpaused 상태에서 bottom으로 이동한다.
|
||||
- [x] pause/resume 버튼이 auto-scroll 정책을 제어하고 pause 중 사용자 scroll offset을 유지한다.
|
||||
- [x] `test/console_page_test.dart`에서 unpaused auto-scroll과 paused offset 유지 동작을 검증한다.
|
||||
- [x] `flutter test test/console_page_test.dart`를 실행한다.
|
||||
- [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_local_G05_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_local_G05_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-logcat-console/03+02_auto_scroll/`를 `agent-task/archive/YYYY/MM/m-logcat-console/03+02_auto_scroll/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-logcat-console/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- **ListView Lazy Layout 이슈 처리**: pause 중에 10줄의 로그가 더 쌓이고 사용자가 중간(offset 100.0)에 머물렀을 경우, ListView의 Lazy Layout 특성상 화면 밖에 있는 아이템들은 레이아웃되지 않아 `maxScrollExtent`가 즉시 늘어나지 않습니다. 이 상태에서 resume 버튼을 눌러 한 번만 `jumpTo`를 하면 바닥에 완전히 닿지 않고 이전 최대 지점(762.0)에서 멈추는 문제가 발생했습니다. 이를 보완하기 위해 `_scrollToBottom` 함수 내에서 현재 프레임의 `jumpTo` 이후에 `WidgetsBinding.instance.addPostFrameCallback`을 사용해 다음 프레임에서 한 번 더 `jumpTo`를 수행하여 최종 바닥에 정상 안착할 수 있도록 안전장치를 추가했습니다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- **상태의 리셋**: 디바이스 변경이나 세션의 didUpdateWidget이 실행될 때 `_isPaused = false;`로 설정하여, 다른 디바이스로 변경했을 때 이전 일시정지 상태가 유지되어 새로운 로그를 놓치는 현상을 방지했습니다.
|
||||
- **적응형 UI**: `_isPaused` 상태에 따라 툴팁은 '일시정지' / '재개', 아이콘은 `Icons.pause` / `Icons.play_arrow`로 갱신하여 직관적인 조작이 가능하게 했습니다.
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
|
||||
|
||||
- 상태: 없음
|
||||
- 사유 유형: 없음
|
||||
- 결정 필요: 없음
|
||||
- 차단 근거: 없음
|
||||
- 실행한 검증/명령: 없음
|
||||
- 자동 후속 불가 이유: 없음
|
||||
- 재개 조건: 없음
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- `03+02_auto_scroll` 구현이 `02+01_console_stream` complete.log 이후 시작됐는가.
|
||||
- unpaused 상태에서 새 로그가 bottom으로 이동하는가.
|
||||
- paused 상태에서 사용자 scroll offset을 보존하는가.
|
||||
- Roadmap Targets의 `auto-scroll` completion 근거가 충분한가.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
### LOGCAT_SCROLL-1 중간 검증
|
||||
```bash
|
||||
$ flutter test test/console_page_test.dart
|
||||
00:00 +0: loading /config/workspace/appsok/test/console_page_test.dart
|
||||
00:01 +0: colors sample logcat rows by parsed level
|
||||
00:01 +1: colors sample logcat rows by parsed level
|
||||
00:01 +1: appends live stream lines and stops session on dispose
|
||||
00:02 +1: appends live stream lines and stops session on dispose
|
||||
00:02 +2: appends live stream lines and stops session on dispose
|
||||
00:02 +2: discards stale session when device changes during async start
|
||||
00:02 +3: discards stale session when device changes during async start
|
||||
00:02 +3: auto-scrolls to the newest log while running
|
||||
00:02 +4: auto-scrolls to the newest log while running
|
||||
00:02 +4: keeps user scroll position while paused
|
||||
00:02 +5: keeps user scroll position while paused
|
||||
00:02 +5: All tests passed!
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```bash
|
||||
$ flutter analyze
|
||||
Analyzing appsok...
|
||||
No issues found! (ran in 5.3s)
|
||||
|
||||
$ flutter test test/console_page_test.dart
|
||||
00:00 +0: loading /config/workspace/appsok/test/console_page_test.dart
|
||||
00:01 +0: colors sample logcat rows by parsed level
|
||||
00:01 +1: colors sample logcat rows by parsed level
|
||||
00:01 +1: appends live stream lines and stops session on dispose
|
||||
00:02 +1: appends live stream lines and stops session on dispose
|
||||
00:02 +2: appends live stream lines and stops session on dispose
|
||||
00:02 +2: discards stale session when device changes during async start
|
||||
00:02 +3: discards stale session when device changes during async start
|
||||
00:02 +3: auto-scrolls to the newest log while running
|
||||
00:02 +4: auto-scrolls to the newest log while running
|
||||
00:02 +4: keeps user scroll position while paused
|
||||
00:02 +5: keeps user scroll position while paused
|
||||
00:02 +5: 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.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- correctness: Pass
|
||||
- completeness: Pass
|
||||
- test coverage: Pass
|
||||
- API contract: Pass
|
||||
- code quality: Pass
|
||||
- plan deviation: Pass
|
||||
- verification trust: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 리뷰 중 정리:
|
||||
- `dart format`이 요구한 줄바꿈만 원격 Mac runner에서 적용했다. 동작/API 변경은 없다.
|
||||
- 검증 확인:
|
||||
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && dart format lib/src/features/console/console_page.dart test/console_page_test.dart && dart format --output=none --set-exit-if-changed lib/src/features/console/console_page.dart test/console_page_test.dart'\'''` - PASS; 2개 파일 포맷 적용 후 재검사 0 changed.
|
||||
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && git diff --check -- lib/src/features/console/console_page.dart test/console_page_test.dart'\'''` - PASS; 출력 없음.
|
||||
- `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'\'''` - PASS; `No issues found!`, `All tests passed!` (`+208`).
|
||||
- 다음 단계: PASS이므로 active plan/review를 `.log`로 아카이브하고 `complete.log` 작성 후 task directory를 `agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/`로 이동한다.
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# Complete - m-logcat-console/03+02_auto_scroll
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-06-14
|
||||
|
||||
## 요약
|
||||
|
||||
`03+02_auto_scroll` pause-aware logcat auto-scroll 작업을 1회 리뷰 루프로 완료했다. 최종 판정은 PASS다.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | unpaused append 시 bottom scroll, pause 중 offset 유지, resume 시 bottom 복귀 구현과 테스트 확인 |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `ConsolePage`에 `ScrollController`와 `_isPaused` 상태를 추가해 unpaused live append 후 bottom으로 이동하도록 했다.
|
||||
- pause/resume 버튼이 tooltip/icon 상태를 바꾸고, pause 중에는 사용자의 scroll offset을 유지하도록 했다.
|
||||
- `test/console_page_test.dart`에 unpaused auto-scroll 및 paused offset 유지 widget test를 추가했다.
|
||||
- 리뷰 중 원격 Mac runner에서 `dart format`으로 변경 파일의 줄바꿈만 정리했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && dart format lib/src/features/console/console_page.dart test/console_page_test.dart && dart format --output=none --set-exit-if-changed lib/src/features/console/console_page.dart test/console_page_test.dart'\'''` - PASS; 2개 파일 포맷 적용 후 재검사 0 changed.
|
||||
- `ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'zsh -lc '\''cd "$HOME/docker/services/code-server/data/volume/workspace/appsok" && git diff --check -- lib/src/features/console/console_page.dart test/console_page_test.dart'\'''` - PASS; 출력 없음.
|
||||
- `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'\'''` - PASS; `No issues found!`, `All tests passed!` (`+208`).
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/usable-mvp/milestones/logcat-console.md`
|
||||
- Completed task ids:
|
||||
- `auto-scroll`: PASS; evidence=`plan_local_G05_0.log`, `code_review_local_G05_0.log`; verification=`flutter analyze`, `flutter test`, `dart format --output=none --set-exit-if-changed lib/src/features/console/console_page.dart test/console_page_test.dart`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -21,6 +21,7 @@ class AppSokApp extends StatefulWidget {
|
|||
this.adbDiagnosticsLoader,
|
||||
this.webLoginLauncher,
|
||||
this.onWebLoginCompleted,
|
||||
this.adbLogcatStarter,
|
||||
});
|
||||
|
||||
final TokenStore? tokenStore;
|
||||
|
|
@ -32,6 +33,7 @@ class AppSokApp extends StatefulWidget {
|
|||
final AdbDiagnosticsLoader? adbDiagnosticsLoader;
|
||||
final JenkinsWebLoginLauncher? webLoginLauncher;
|
||||
final void Function(JenkinsWebLoginResult result)? onWebLoginCompleted;
|
||||
final AdbLogcatSessionStarter? adbLogcatStarter;
|
||||
|
||||
@override
|
||||
State<AppSokApp> createState() => _AppSokAppState();
|
||||
|
|
@ -156,6 +158,7 @@ class _AppSokAppState extends State<AppSokApp> {
|
|||
),
|
||||
adbDiagnosticsLoader:
|
||||
widget.adbDiagnosticsLoader ?? _adbService.collectDiagnostics,
|
||||
logcatStarter: widget.adbLogcatStarter ?? _adbService.startLogcat,
|
||||
webLoginLauncher: widget.webLoginLauncher,
|
||||
onWebLoginCompleted: widget.onWebLoginCompleted,
|
||||
onSessionSaved: _onSessionSaved,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class AppSokShell extends StatefulWidget {
|
|||
this.sessionClearer,
|
||||
this.connectionStatus,
|
||||
this.onRetryConnection,
|
||||
this.logcatStarter,
|
||||
});
|
||||
|
||||
final JenkinsJobLoader? jobLoader;
|
||||
|
|
@ -58,6 +59,7 @@ class AppSokShell extends StatefulWidget {
|
|||
final JenkinsSessionClearer? sessionClearer;
|
||||
final JenkinsConnectionStatus? connectionStatus;
|
||||
final VoidCallback? onRetryConnection;
|
||||
final AdbLogcatSessionStarter? logcatStarter;
|
||||
|
||||
@override
|
||||
State<AppSokShell> createState() => _AppSokShellState();
|
||||
|
|
@ -66,6 +68,14 @@ class AppSokShell extends StatefulWidget {
|
|||
class _AppSokShellState extends State<AppSokShell> {
|
||||
int _selectedIndex = 0;
|
||||
PendingInstall? _pendingInstall;
|
||||
AdbDevice? _selectedConsoleDevice;
|
||||
|
||||
void _handleLogcatRequested(AdbDevice device) {
|
||||
setState(() {
|
||||
_selectedConsoleDevice = device;
|
||||
_selectedIndex = 2;
|
||||
});
|
||||
}
|
||||
|
||||
// Install lifecycle state — owned by shell so cleanup survives DevicesPage dispose.
|
||||
bool _isInstalling = false;
|
||||
|
|
@ -196,13 +206,17 @@ class _AppSokShellState extends State<AppSokShell> {
|
|||
isInstalling: _isInstalling,
|
||||
lastInstallDevice: _lastInstallDevice,
|
||||
lastInstallResult: _lastInstallResult,
|
||||
onLogcatRequested: _handleLogcatRequested,
|
||||
),
|
||||
),
|
||||
const _ShellPage(
|
||||
_ShellPage(
|
||||
label: '콘솔',
|
||||
icon: Icons.terminal_outlined,
|
||||
selectedIcon: Icons.terminal,
|
||||
child: ConsolePage(),
|
||||
child: ConsolePage(
|
||||
device: _selectedConsoleDevice,
|
||||
logcatStarter: widget.logcatStarter,
|
||||
),
|
||||
),
|
||||
_ShellPage(
|
||||
label: '설정',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,140 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ConsolePage extends StatelessWidget {
|
||||
const ConsolePage({super.key});
|
||||
import '../../models/adb_device.dart';
|
||||
import '../../services/adb_service.dart';
|
||||
|
||||
class ConsolePage extends StatefulWidget {
|
||||
const ConsolePage({super.key, this.device, this.logcatStarter});
|
||||
|
||||
final AdbDevice? device;
|
||||
final AdbLogcatSessionStarter? logcatStarter;
|
||||
|
||||
@override
|
||||
State<ConsolePage> createState() => _ConsolePageState();
|
||||
}
|
||||
|
||||
class _ConsolePageState extends State<ConsolePage> {
|
||||
final List<String> _logLines = [];
|
||||
AdbLogcatSession? _session;
|
||||
StreamSubscription<String>? _subscription;
|
||||
bool _isLoading = false;
|
||||
String? _errorMsg;
|
||||
int _sessionGeneration = 0;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isPaused = false;
|
||||
|
||||
void _scrollToBottom() {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients && !_isPaused) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startSession();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ConsolePage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.device != oldWidget.device ||
|
||||
widget.logcatStarter != oldWidget.logcatStarter) {
|
||||
_stopSession();
|
||||
_logLines.clear();
|
||||
_isPaused = false;
|
||||
_startSession();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_stopSession();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _startSession() async {
|
||||
final starter = widget.logcatStarter;
|
||||
final device = widget.device;
|
||||
if (starter == null || device == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_sessionGeneration++;
|
||||
final currentGen = _sessionGeneration;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMsg = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final session = await starter(serial: device.serial);
|
||||
if (currentGen != _sessionGeneration) {
|
||||
await session.stop();
|
||||
return;
|
||||
}
|
||||
if (!mounted) {
|
||||
await session.stop();
|
||||
return;
|
||||
}
|
||||
_session = session;
|
||||
_subscription = session.lines.listen(
|
||||
(line) {
|
||||
if (!mounted || currentGen != _sessionGeneration) return;
|
||||
setState(() {
|
||||
_logLines.add(line);
|
||||
if (_logLines.length > 1000) {
|
||||
_logLines.removeRange(0, _logLines.length - 1000);
|
||||
}
|
||||
});
|
||||
if (!_isPaused) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && currentGen == _sessionGeneration) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (err) {
|
||||
if (!mounted || currentGen != _sessionGeneration) return;
|
||||
setState(() {
|
||||
_errorMsg = err.toString();
|
||||
});
|
||||
},
|
||||
);
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted || currentGen != _sessionGeneration) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMsg = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _stopSession() {
|
||||
_sessionGeneration++;
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
_session?.stop();
|
||||
_session = null;
|
||||
}
|
||||
|
||||
List<String> get _displayLines =>
|
||||
(widget.device == null || widget.logcatStarter == null)
|
||||
? _sampleLogLines
|
||||
: _logLines;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -12,6 +145,34 @@ class ConsolePage extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (widget.device != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
key: const ValueKey('console-selected-device-label'),
|
||||
children: [
|
||||
const Icon(Icons.phone_android, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${widget.device!.displayName} (${widget.device!.serial})',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
key: ValueKey('console-no-device-label'),
|
||||
children: [
|
||||
Icon(Icons.phone_android, size: 18, color: Colors.grey),
|
||||
SizedBox(width: 8),
|
||||
Text('device 미선택', style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
|
|
@ -44,9 +205,18 @@ class ConsolePage extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
tooltip: '일시정지',
|
||||
onPressed: () {},
|
||||
icon: const Icon(Icons.pause),
|
||||
tooltip: _isPaused ? '재개' : '일시정지',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isPaused = !_isPaused;
|
||||
if (!_isPaused) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => _scrollToBottom(),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
icon: Icon(_isPaused ? Icons.play_arrow : Icons.pause),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.outlined(
|
||||
|
|
@ -66,26 +236,36 @@ class ConsolePage extends StatelessWidget {
|
|||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _logLines.length,
|
||||
itemBuilder: (context, index) {
|
||||
final line = _logLines[index];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Text(
|
||||
line,
|
||||
style: TextStyle(
|
||||
color: _lineColor(line),
|
||||
fontFamily: 'Menlo',
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _errorMsg != null
|
||||
? Center(
|
||||
child: Text(
|
||||
'에러 발생: $_errorMsg',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _displayLines.length,
|
||||
itemBuilder: (context, index) {
|
||||
final line = _displayLines[index];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Text(
|
||||
line,
|
||||
style: TextStyle(
|
||||
color: _lineColor(line),
|
||||
fontFamily: 'Menlo',
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -123,7 +303,7 @@ enum _LogcatLevel {
|
|||
}
|
||||
}
|
||||
|
||||
const _logLines = [
|
||||
const _sampleLogLines = [
|
||||
'06-08 10:41:22.121 1832 2110 I ActivityTaskManager: START u0 com.toki.app/.MainActivity',
|
||||
'06-08 10:41:22.339 5521 5521 D AppSokInstall: install session committed',
|
||||
'06-08 10:41:22.921 5521 5540 W NetworkMonitor: vpn route not available, using usb install cache',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class DevicesPage extends StatefulWidget {
|
|||
this.isInstalling = false,
|
||||
this.lastInstallDevice,
|
||||
this.lastInstallResult,
|
||||
this.onLogcatRequested,
|
||||
});
|
||||
|
||||
final PendingInstall? pendingInstall;
|
||||
|
|
@ -29,6 +30,7 @@ class DevicesPage extends StatefulWidget {
|
|||
final bool isInstalling;
|
||||
final AdbDevice? lastInstallDevice;
|
||||
final AdbInstallResult? lastInstallResult;
|
||||
final ValueChanged<AdbDevice>? onLogcatRequested;
|
||||
|
||||
@override
|
||||
State<DevicesPage> createState() => _DevicesPageState();
|
||||
|
|
@ -199,6 +201,7 @@ class _DevicesPageState extends State<DevicesPage> {
|
|||
pendingInstall: widget.pendingInstall,
|
||||
isInstalling: widget.isInstalling,
|
||||
installer: widget.installer,
|
||||
onLogcatRequested: widget.onLogcatRequested,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -286,12 +289,14 @@ class _DeviceTile extends StatelessWidget {
|
|||
this.pendingInstall,
|
||||
required this.isInstalling,
|
||||
this.installer,
|
||||
this.onLogcatRequested,
|
||||
});
|
||||
|
||||
final AdbDevice device;
|
||||
final PendingInstall? pendingInstall;
|
||||
final bool isInstalling;
|
||||
final PendingInstallRunner? installer;
|
||||
final ValueChanged<AdbDevice>? onLogcatRequested;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -368,7 +373,9 @@ class _DeviceTile extends StatelessWidget {
|
|||
_CompactIconButton(
|
||||
tooltip: 'logcat',
|
||||
icon: Icons.terminal,
|
||||
onPressed: () {},
|
||||
onPressed: device.isReady && onLogcatRequested != null
|
||||
? () => onLogcatRequested!(device)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_CompactIconButton(
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ typedef AdbProcessStarter =
|
|||
Map<String, String>? environment,
|
||||
});
|
||||
|
||||
typedef AdbLogcatSessionStarter =
|
||||
Future<AdbLogcatSession> Function({
|
||||
String? serial,
|
||||
});
|
||||
|
||||
class AdbService {
|
||||
AdbService({
|
||||
String? adbPath,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,50 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:appsok/src/features/console/console_page.dart';
|
||||
import 'package:appsok/src/models/adb_device.dart';
|
||||
import 'package:appsok/src/services/adb_service.dart';
|
||||
import 'package:appsok/src/theme/app_theme.dart';
|
||||
|
||||
class _FakeProcess implements Process {
|
||||
final _stdout = StreamController<List<int>>();
|
||||
final _stderr = StreamController<List<int>>();
|
||||
final _exitCode = Completer<int>();
|
||||
|
||||
bool killCalled = false;
|
||||
|
||||
void emitStdout(String text) => _stdout.add(utf8.encode(text));
|
||||
void emitStderr(String text) => _stderr.add(utf8.encode(text));
|
||||
|
||||
@override
|
||||
Stream<List<int>> get stdout => _stdout.stream;
|
||||
|
||||
@override
|
||||
Stream<List<int>> get stderr => _stderr.stream;
|
||||
|
||||
@override
|
||||
Future<int> get exitCode => _exitCode.future;
|
||||
|
||||
@override
|
||||
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
|
||||
killCalled = true;
|
||||
if (!_exitCode.isCompleted) _exitCode.complete(-15);
|
||||
_stdout.close();
|
||||
_stderr.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get pid => 4242;
|
||||
|
||||
@override
|
||||
IOSink get stdin => throw UnimplementedError();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('colors sample logcat rows by parsed level', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
|
|
@ -27,6 +68,250 @@ void main() {
|
|||
expect(_textColor(tester, warnLine), const Color(0xFFFFD28A));
|
||||
expect(_textColor(tester, errorLine), const Color(0xFFFFA3A3));
|
||||
});
|
||||
|
||||
testWidgets('appends live stream lines and stops session on dispose', (
|
||||
tester,
|
||||
) async {
|
||||
final fakeProcess = _FakeProcess();
|
||||
final session = AdbLogcatSession(fakeProcess);
|
||||
|
||||
const device = AdbDevice(
|
||||
serial: 'R5CT90A1B2C',
|
||||
state: 'device',
|
||||
model: 'SM_S918N',
|
||||
product: 'dm3q',
|
||||
);
|
||||
|
||||
var startCalls = 0;
|
||||
Future<AdbLogcatSession> fakeStarter({String? serial}) async {
|
||||
startCalls++;
|
||||
expect(serial, device.serial);
|
||||
return session;
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: ConsolePage(device: device, logcatStarter: fakeStarter),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Initial pump to run initState/startSession
|
||||
await tester.pump();
|
||||
|
||||
expect(startCalls, 1);
|
||||
|
||||
// Emit live log lines
|
||||
fakeProcess.emitStdout('I/test: hello live line\n');
|
||||
await tester.pump(Duration.zero);
|
||||
|
||||
expect(find.text('I/test: hello live line'), findsOneWidget);
|
||||
|
||||
// Check dispose kills process
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const Scaffold(body: SizedBox.shrink()),
|
||||
),
|
||||
);
|
||||
|
||||
expect(fakeProcess.killCalled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('discards stale session when device changes during async start', (
|
||||
tester,
|
||||
) async {
|
||||
final completerA = Completer<AdbLogcatSession>();
|
||||
final completerB = Completer<AdbLogcatSession>();
|
||||
|
||||
final fakeProcessA = _FakeProcess();
|
||||
final sessionA = AdbLogcatSession(fakeProcessA);
|
||||
final fakeProcessB = _FakeProcess();
|
||||
final sessionB = AdbLogcatSession(fakeProcessB);
|
||||
|
||||
const deviceA = AdbDevice(
|
||||
serial: 'device-A',
|
||||
state: 'device',
|
||||
model: 'Pixel_8',
|
||||
product: 'shiba',
|
||||
);
|
||||
const deviceB = AdbDevice(
|
||||
serial: 'device-B',
|
||||
state: 'device',
|
||||
model: 'Galaxy_S23',
|
||||
product: 'dm3q',
|
||||
);
|
||||
|
||||
var startCalls = 0;
|
||||
Future<AdbLogcatSession> fakeStarter({String? serial}) async {
|
||||
startCalls++;
|
||||
if (serial == 'device-A') {
|
||||
return completerA.future;
|
||||
} else if (serial == 'device-B') {
|
||||
return completerB.future;
|
||||
}
|
||||
throw Exception('unexpected serial');
|
||||
}
|
||||
|
||||
// 1. Render ConsolePage with deviceA, starter completerA is pending
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: ConsolePage(device: deviceA, logcatStarter: fakeStarter),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Initial pump to trigger initState
|
||||
await tester.pump();
|
||||
expect(startCalls, 1);
|
||||
|
||||
// 2. Update widget to deviceB, starter completerB is pending
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: ConsolePage(device: deviceB, logcatStarter: fakeStarter),
|
||||
),
|
||||
),
|
||||
);
|
||||
// didUpdateWidget triggers start for deviceB
|
||||
await tester.pump();
|
||||
expect(startCalls, 2);
|
||||
|
||||
// 3. Complete starter B first
|
||||
completerB.complete(sessionB);
|
||||
await tester.pump(); // let completerB resolve
|
||||
await tester.pump(Duration.zero); // let stream listen trigger
|
||||
|
||||
// Emit line from B
|
||||
fakeProcessB.emitStdout('I/test: hello from B\n');
|
||||
await tester.pump(Duration.zero);
|
||||
expect(find.text('I/test: hello from B'), findsOneWidget);
|
||||
|
||||
// 4. Now complete starter A (which is stale)
|
||||
completerA.complete(sessionA);
|
||||
await tester.pump(); // let completerA resolve
|
||||
await tester.pump(Duration.zero);
|
||||
|
||||
// A session must be stopped immediately
|
||||
expect(fakeProcessA.killCalled, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('auto-scrolls to the newest log while running', (tester) async {
|
||||
final fakeProcess = _FakeProcess();
|
||||
final session = AdbLogcatSession(fakeProcess);
|
||||
const device = AdbDevice(
|
||||
serial: 'R5CT90A1B2C',
|
||||
state: 'device',
|
||||
model: 'SM_S918N',
|
||||
product: 'dm3q',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: ConsolePage(
|
||||
device: device,
|
||||
logcatStarter: ({serial}) async => session,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Emit enough lines to make the list scrollable (e.g., 50 lines)
|
||||
for (var i = 0; i < 50; i++) {
|
||||
fakeProcess.emitStdout('I/test: line $i\n');
|
||||
}
|
||||
await tester.pump(Duration.zero);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify it auto-scrolled to the bottom
|
||||
final listView = tester.widget<ListView>(find.byType(ListView));
|
||||
final controller = listView.controller;
|
||||
expect(controller, isNotNull);
|
||||
expect(
|
||||
controller!.position.pixels,
|
||||
equals(controller.position.maxScrollExtent),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps user scroll position while paused', (tester) async {
|
||||
final fakeProcess = _FakeProcess();
|
||||
final session = AdbLogcatSession(fakeProcess);
|
||||
const device = AdbDevice(
|
||||
serial: 'R5CT90A1B2C',
|
||||
state: 'device',
|
||||
model: 'SM_S918N',
|
||||
product: 'dm3q',
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: ConsolePage(
|
||||
device: device,
|
||||
logcatStarter: ({serial}) async => session,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// 1. Emit enough lines to scroll
|
||||
for (var i = 0; i < 50; i++) {
|
||||
fakeProcess.emitStdout('I/test: line $i\n');
|
||||
}
|
||||
await tester.pump(Duration.zero);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final listView = tester.widget<ListView>(find.byType(ListView));
|
||||
final controller = listView.controller!;
|
||||
final maxScrollExtentBeforePause = controller.position.maxScrollExtent;
|
||||
expect(controller.position.pixels, equals(maxScrollExtentBeforePause));
|
||||
|
||||
// 2. Press pause button
|
||||
final pauseButtonFinder = find.byTooltip('일시정지');
|
||||
expect(pauseButtonFinder, findsOneWidget);
|
||||
await tester.tap(pauseButtonFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify tooltip and icon changed
|
||||
expect(find.byTooltip('재개'), findsOneWidget);
|
||||
|
||||
// 3. User scrolls up slightly to some offset (e.g., 100.0)
|
||||
controller.jumpTo(100.0);
|
||||
await tester.pumpAndSettle();
|
||||
expect(controller.position.pixels, equals(100.0));
|
||||
|
||||
// 4. Emit more lines while paused
|
||||
for (var i = 50; i < 60; i++) {
|
||||
fakeProcess.emitStdout('I/test: line $i\n');
|
||||
}
|
||||
await tester.pump(Duration.zero);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify offset remains at 100.0 (does not scroll to bottom)
|
||||
expect(controller.position.pixels, equals(100.0));
|
||||
|
||||
// 5. Tap resume (tooltip '재개')
|
||||
final resumeButtonFinder = find.byTooltip('재개');
|
||||
await tester.tap(resumeButtonFinder);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Verify tooltip is '일시정지' again and it scrolled to bottom
|
||||
expect(find.byTooltip('일시정지'), findsOneWidget);
|
||||
expect(
|
||||
controller.position.pixels,
|
||||
equals(controller.position.maxScrollExtent),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Color? _textColor(WidgetTester tester, String text) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
|
@ -1432,4 +1433,192 @@ void main() {
|
|||
expect(find.byKey(const ValueKey('web-login-result')), findsOneWidget);
|
||||
expect(find.textContaining('Toki Lab'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('ready device logcat button opens console for that device', (
|
||||
tester,
|
||||
) async {
|
||||
final fakeProcess = _FakeProcess();
|
||||
final session = AdbLogcatSession(fakeProcess);
|
||||
var startCalls = 0;
|
||||
String? startedSerial;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: AppSokShell(
|
||||
deviceLoader: () async => const [
|
||||
AdbDevice(
|
||||
serial: 'device-ready-logcat',
|
||||
state: 'device',
|
||||
model: 'Pixel_8',
|
||||
product: 'shiba',
|
||||
),
|
||||
],
|
||||
logcatStarter: ({serial}) async {
|
||||
startCalls++;
|
||||
startedSerial = serial;
|
||||
return session;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate to 디바이스
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(NavigationRail),
|
||||
matching: find.text('디바이스'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Pixel 8'), findsOneWidget);
|
||||
|
||||
// Tap logcat button on the ready device
|
||||
final logcatButton = find.byWidgetPredicate(
|
||||
(w) => w is IconButton && w.tooltip == 'logcat',
|
||||
);
|
||||
await tester.tap(logcatButton.first);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Check we navigated to 콘솔
|
||||
expect(startCalls, 1);
|
||||
expect(startedSerial, 'device-ready-logcat');
|
||||
expect(find.text('콘솔'), findsWidgets);
|
||||
|
||||
// Verify selected device identity is displayed on console
|
||||
expect(find.textContaining('Pixel 8'), findsOneWidget);
|
||||
expect(find.textContaining('device-ready-logcat'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('navigation away or device change stops logcat session', (
|
||||
tester,
|
||||
) async {
|
||||
final fakeProcess1 = _FakeProcess();
|
||||
final session1 = AdbLogcatSession(fakeProcess1);
|
||||
final fakeProcess2 = _FakeProcess();
|
||||
final session2 = AdbLogcatSession(fakeProcess2);
|
||||
|
||||
var startCalls = 0;
|
||||
final sessions = [session1, session2];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: AppSokShell(
|
||||
deviceLoader: () async => const [
|
||||
AdbDevice(
|
||||
serial: 'device-1',
|
||||
state: 'device',
|
||||
model: 'Pixel_8',
|
||||
product: 'shiba',
|
||||
),
|
||||
AdbDevice(
|
||||
serial: 'device-2',
|
||||
state: 'device',
|
||||
model: 'Galaxy_S23',
|
||||
product: 'dm3q',
|
||||
),
|
||||
],
|
||||
logcatStarter: ({serial}) async {
|
||||
final s = sessions[startCalls];
|
||||
startCalls++;
|
||||
return s;
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Navigate to 디바이스
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(NavigationRail),
|
||||
matching: find.text('디바이스'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap logcat button on device-1
|
||||
final logcatButtons = find.byWidgetPredicate(
|
||||
(w) => w is IconButton && w.tooltip == 'logcat',
|
||||
);
|
||||
await tester.tap(logcatButtons.at(0));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(startCalls, 1);
|
||||
expect(fakeProcess1.killCalled, isFalse);
|
||||
|
||||
// Navigate to 디바이스 again (leaving ConsolePage)
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(NavigationRail),
|
||||
matching: find.text('디바이스'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(fakeProcess1.killCalled, isTrue);
|
||||
|
||||
// Tap logcat button on device-2
|
||||
await tester.tap(logcatButtons.at(1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(startCalls, 2);
|
||||
expect(fakeProcess2.killCalled, isFalse);
|
||||
|
||||
// Change selected console device by tapping logcat on device-1
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(NavigationRail),
|
||||
matching: find.text('디바이스'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap device-1 logcat
|
||||
await tester.tap(logcatButtons.at(0));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Now session 2 (device-2) must be stopped because device changed
|
||||
expect(fakeProcess2.killCalled, isTrue);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeProcess implements Process {
|
||||
final _stdout = StreamController<List<int>>();
|
||||
final _stderr = StreamController<List<int>>();
|
||||
final _exitCode = Completer<int>();
|
||||
|
||||
bool killCalled = false;
|
||||
|
||||
void emitStdout(String text) => _stdout.add(utf8.encode(text));
|
||||
void emitStderr(String text) => _stderr.add(utf8.encode(text));
|
||||
|
||||
@override
|
||||
Stream<List<int>> get stdout => _stdout.stream;
|
||||
|
||||
@override
|
||||
Stream<List<int>> get stderr => _stderr.stream;
|
||||
|
||||
@override
|
||||
Future<int> get exitCode => _exitCode.future;
|
||||
|
||||
@override
|
||||
bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
|
||||
killCalled = true;
|
||||
if (!_exitCode.isCompleted) _exitCode.complete(-15);
|
||||
_stdout.close();
|
||||
_stderr.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
int get pid => 4242;
|
||||
|
||||
@override
|
||||
IOSink get stdin => throw UnimplementedError();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue