From 8c0bb0a6b0951ef97916221015127ff26fe82619 Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 14 Jun 2026 06:49:05 +0900 Subject: [PATCH] feat: logcat console milestone completion - archive subtasks, update console features --- .../usable-mvp/milestones/logcat-console.md | 4 + .../code_review_local_G06_0.log} | 66 ++-- .../code_review_local_G07_1.log} | 98 ++++-- .../02+01_console_stream/complete.log | 46 +++ .../plan_local_G06_0.log} | 4 +- .../02+01_console_stream/plan_local_G07_1.log | 96 ++++++ .../code_review_local_G05_0.log | 167 ++++++++++ .../03+02_auto_scroll/complete.log | 43 +++ .../03+02_auto_scroll/plan_local_G05_0.log} | 0 lib/src/app.dart | 3 + lib/src/features/app_shell.dart | 18 +- lib/src/features/console/console_page.dart | 228 ++++++++++++-- lib/src/features/devices/devices_page.dart | 9 +- lib/src/services/adb_service.dart | 5 + test/console_page_test.dart | 285 ++++++++++++++++++ test/widget_test.dart | 189 ++++++++++++ 16 files changed, 1179 insertions(+), 82 deletions(-) rename agent-task/{m-logcat-console/02+01_console_stream/CODE_REVIEW-local-G06.md => archive/2026/06/m-logcat-console/02+01_console_stream/code_review_local_G06_0.log} (65%) rename agent-task/{m-logcat-console/03+02_auto_scroll/CODE_REVIEW-local-G05.md => archive/2026/06/m-logcat-console/02+01_console_stream/code_review_local_G07_1.log} (57%) create mode 100644 agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/complete.log rename agent-task/{m-logcat-console/02+01_console_stream/PLAN-local-G06.md => archive/2026/06/m-logcat-console/02+01_console_stream/plan_local_G06_0.log} (96%) create mode 100644 agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/plan_local_G07_1.log create mode 100644 agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/code_review_local_G05_0.log create mode 100644 agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/complete.log rename agent-task/{m-logcat-console/03+02_auto_scroll/PLAN-local-G05.md => archive/2026/06/m-logcat-console/03+02_auto_scroll/plan_local_G05_0.log} (100%) diff --git a/agent-roadmap/phase/usable-mvp/milestones/logcat-console.md b/agent-roadmap/phase/usable-mvp/milestones/logcat-console.md index 52cdc8a..6f61bd1 100644 --- a/agent-roadmap/phase/usable-mvp/milestones/logcat-console.md +++ b/agent-roadmap/phase/usable-mvp/milestones/logcat-console.md @@ -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` 완료 대기. - 확인 필요: 없음 diff --git a/agent-task/m-logcat-console/02+01_console_stream/CODE_REVIEW-local-G06.md b/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/code_review_local_G06_0.log similarity index 65% rename from agent-task/m-logcat-console/02+01_console_stream/CODE_REVIEW-local-G06.md rename to agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/code_review_local_G06_0.log index 208a0e4..c4cf5f9 100644 --- a/agent-task/m-logcat-console/02+01_console_stream/CODE_REVIEW-local-G06.md +++ b/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/code_review_local_G06_0.log @@ -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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 문제만 좁게 수정한다. diff --git a/agent-task/m-logcat-console/03+02_auto_scroll/CODE_REVIEW-local-G05.md b/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/code_review_local_G07_1.log similarity index 57% rename from agent-task/m-logcat-console/03+02_auto_scroll/CODE_REVIEW-local-G05.md rename to agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/code_review_local_G07_1.log index a05ea16..e862c3e 100644 --- a/agent-task/m-logcat-console/03+02_auto_scroll/CODE_REVIEW-local-G05.md +++ b/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/code_review_local_G07_1.log @@ -1,6 +1,6 @@ - + -# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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로 이동한다. diff --git a/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/complete.log b/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/complete.log new file mode 100644 index 0000000..e3ea76d --- /dev/null +++ b/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-logcat-console/02+01_console_stream/PLAN-local-G06.md b/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/plan_local_G06_0.log similarity index 96% rename from agent-task/m-logcat-console/02+01_console_stream/PLAN-local-G06.md rename to agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/plan_local_G06_0.log index 4d09279..ebf3fad 100644 --- a/agent-task/m-logcat-console/02+01_console_stream/PLAN-local-G06.md +++ b/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/plan_local_G06_0.log @@ -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 diff --git a/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/plan_local_G07_1.log b/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/plan_local_G07_1.log new file mode 100644 index 0000000..18f7e3c --- /dev/null +++ b/agent-task/archive/2026/06/m-logcat-console/02+01_console_stream/plan_local_G07_1.log @@ -0,0 +1,96 @@ + + +# 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로 기록한다. diff --git a/agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/code_review_local_G05_0.log b/agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/code_review_local_G05_0.log new file mode 100644 index 0000000..0a47b58 --- /dev/null +++ b/agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/code_review_local_G05_0.log @@ -0,0 +1,167 @@ + + +# 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-`이면 완료 이벤트 메타데이터를 보고한다. 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-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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/`로 이동한다. diff --git a/agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/complete.log b/agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/complete.log new file mode 100644 index 0000000..405366e --- /dev/null +++ b/agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/complete.log @@ -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 + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/m-logcat-console/03+02_auto_scroll/PLAN-local-G05.md b/agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/plan_local_G05_0.log similarity index 100% rename from agent-task/m-logcat-console/03+02_auto_scroll/PLAN-local-G05.md rename to agent-task/archive/2026/06/m-logcat-console/03+02_auto_scroll/plan_local_G05_0.log diff --git a/lib/src/app.dart b/lib/src/app.dart index 41f8163..01e63f5 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -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 createState() => _AppSokAppState(); @@ -156,6 +158,7 @@ class _AppSokAppState extends State { ), adbDiagnosticsLoader: widget.adbDiagnosticsLoader ?? _adbService.collectDiagnostics, + logcatStarter: widget.adbLogcatStarter ?? _adbService.startLogcat, webLoginLauncher: widget.webLoginLauncher, onWebLoginCompleted: widget.onWebLoginCompleted, onSessionSaved: _onSessionSaved, diff --git a/lib/src/features/app_shell.dart b/lib/src/features/app_shell.dart index 25c4fa2..265b226 100644 --- a/lib/src/features/app_shell.dart +++ b/lib/src/features/app_shell.dart @@ -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 createState() => _AppSokShellState(); @@ -66,6 +68,14 @@ class AppSokShell extends StatefulWidget { class _AppSokShellState extends State { 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 { 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: '설정', diff --git a/lib/src/features/console/console_page.dart b/lib/src/features/console/console_page.dart index 8dd30d3..f9701b5 100644 --- a/lib/src/features/console/console_page.dart +++ b/lib/src/features/console/console_page.dart @@ -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 createState() => _ConsolePageState(); +} + +class _ConsolePageState extends State { + final List _logLines = []; + AdbLogcatSession? _session; + StreamSubscription? _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 _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 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', diff --git a/lib/src/features/devices/devices_page.dart b/lib/src/features/devices/devices_page.dart index 3a68ef6..851110d 100644 --- a/lib/src/features/devices/devices_page.dart +++ b/lib/src/features/devices/devices_page.dart @@ -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? onLogcatRequested; @override State createState() => _DevicesPageState(); @@ -199,6 +201,7 @@ class _DevicesPageState extends State { 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? 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( diff --git a/lib/src/services/adb_service.dart b/lib/src/services/adb_service.dart index 1db527a..8c09c84 100644 --- a/lib/src/services/adb_service.dart +++ b/lib/src/services/adb_service.dart @@ -20,6 +20,11 @@ typedef AdbProcessStarter = Map? environment, }); +typedef AdbLogcatSessionStarter = + Future Function({ + String? serial, + }); + class AdbService { AdbService({ String? adbPath, diff --git a/test/console_page_test.dart b/test/console_page_test.dart index 13364e3..2fb60fb 100644 --- a/test/console_page_test.dart +++ b/test/console_page_test.dart @@ -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>(); + final _stderr = StreamController>(); + final _exitCode = Completer(); + + bool killCalled = false; + + void emitStdout(String text) => _stdout.add(utf8.encode(text)); + void emitStderr(String text) => _stderr.add(utf8.encode(text)); + + @override + Stream> get stdout => _stdout.stream; + + @override + Stream> get stderr => _stderr.stream; + + @override + Future 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 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(); + final completerB = Completer(); + + 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 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(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(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) { diff --git a/test/widget_test.dart b/test/widget_test.dart index 380c3fe..883b2a4 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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>(); + final _stderr = StreamController>(); + final _exitCode = Completer(); + + bool killCalled = false; + + void emitStdout(String text) => _stdout.add(utf8.encode(text)); + void emitStderr(String text) => _stderr.add(utf8.encode(text)); + + @override + Stream> get stdout => _stdout.stream; + + @override + Stream> get stderr => _stderr.stream; + + @override + Future 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(); }