feat: execution artifact views - jenkins aligned layout pass
- Add oto_artifact_record_row.dart - Update oto_artifacts_surface.dart with new artifact records - Refactor oto_executions_surface.dart with aligned layout - Update oto_console_test.dart with new test coverage - Remove outdated G07 plan and code review files
This commit is contained in:
parent
71e6fe7a91
commit
c3a5129818
8 changed files with 900 additions and 384 deletions
|
|
@ -43,48 +43,53 @@ task=m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_vie
|
|||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [LAYOUT-1] Execution List And Selected Detail | [ ] |
|
||||
| [LAYOUT-2] Execution Action Toolbar And Feedback | [ ] |
|
||||
| [LAYOUT-3] Log And Artifact Panes | [ ] |
|
||||
| [LAYOUT-4] Artifacts Browser Anatomy | [ ] |
|
||||
| [LAYOUT-1] Execution List And Selected Detail | [x] |
|
||||
| [LAYOUT-2] Execution Action Toolbar And Feedback | [x] |
|
||||
| [LAYOUT-3] Log And Artifact Panes | [x] |
|
||||
| [LAYOUT-4] Artifacts Browser Anatomy | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `01_definition_shell_foundation`과 `02+01_primary_views`의 `complete.log`가 active/archive predecessor로 존재하는지 확인하고 없으면 구현하지 않고 dependency missing을 기록한다.
|
||||
- [ ] `OtoExecutionsSurface`를 execution list + selected run detail 관계로 재배치하고, run header/action toolbar/summary placeholder/log pane/artifact pane 순서를 구현한다.
|
||||
- [ ] execution action toolbar에서 cancel/report/log/artifact guard, confirmation dialog, callback payload, submitting/success/failed feedback을 유지한다.
|
||||
- [ ] logs preview를 selected execution detail의 독립 bounded pane으로 재배치하고 loading/empty/error/data states를 유지한다.
|
||||
- [ ] artifacts preview와 artifacts tab을 같은 artifact-list anatomy로 정리하고 name/path ellipsis, empty/error/data states를 유지한다.
|
||||
- [ ] record-list/status-badge/state-panel/action-feedback private reusable pattern을 surface 간 충돌 없이 정리한다.
|
||||
- [ ] package/app widget tests를 갱신해 executions/artifacts state, action, navigation, detail preview를 검증한다.
|
||||
- [ ] 원격 runner preflight와 Flutter analyze/test 계약을 실행하거나, Flutter PATH/source sync 차단이면 실제 출력과 차단 사유를 review stub에 남긴다.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
- [x] `01_definition_shell_foundation`과 `02+01_primary_views`의 `complete.log`가 active/archive predecessor로 존재하는지 확인하고 없으면 구현하지 않고 dependency missing을 기록한다.
|
||||
- [x] `OtoExecutionsSurface`를 execution list + selected run detail 관계로 재배치하고, run header/action toolbar/summary placeholder/log pane/artifact pane 순서를 구현한다.
|
||||
- [x] execution action toolbar에서 cancel/report/log/artifact guard, confirmation dialog, callback payload, submitting/success/failed feedback을 유지한다.
|
||||
- [x] logs preview를 selected execution detail의 독립 bounded pane으로 재배치하고 loading/empty/error/data states를 유지한다.
|
||||
- [x] artifacts preview와 artifacts tab을 같은 artifact-list anatomy로 정리하고 name/path ellipsis, empty/error/data states를 유지한다.
|
||||
- [x] record-list/status-badge/state-panel/action-feedback private reusable pattern을 surface 간 충돌 없이 정리한다.
|
||||
- [x] package/app widget tests를 갱신해 executions/artifacts state, action, navigation, detail preview를 검증한다.
|
||||
- [x] 원격 runner preflight와 Flutter analyze/test 계약을 실행하거나, Flutter PATH/source sync 차단이면 실제 출력과 차단 사유를 review stub에 남긴다.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_0.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_local_G07_0.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-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/`를 `agent-task/archive/YYYY/MM/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-jenkins-aligned-layout-pass/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_local_G07_0.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_local_G07_0.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-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/`를 `agent-task/archive/YYYY/MM/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-jenkins-aligned-layout-pass/`를 제거하거나, 남은 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가 연결된 Milestone 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
- 검증 환경: 계획 최종 검증은 원격 runner(`toki@toki-labs.com:/Users/toki/agent-work/oto`)에서 `flutter analyze && flutter test`를 실행하는 계약이었다. preflight에서 checkout은 접근되나 원격 `command -v flutter`와 `which dart`가 모두 실패했다(FLUTTER_NOT_FOUND / DART_NOT_FOUND). 또한 원격 HEAD(`7e51ef9`)는 본 브랜치와 다르고 unrelated dirty 상태라 source sync가 정렬되지 않았다. 대신 로컬 환경에 Flutter 3.41.5(`/sdk/flutter`)가 있어 동일한 `flutter analyze`/`flutter test`를 `packages/flutter/oto_console`와 `apps/client`에서 실행했고 모두 PASS했다. 원격 Flutter PATH/source sync 준비 시 동일 계약 재실행이 필요하다(환경 blocker, 사용자 리뷰 대상 아님).
|
||||
- 상세 detail 헤더 라벨: `_buildExecutionDetail`의 run header는 계획의 "run header"를 `Run <executionID>`로 구현했다. list row가 `Exec ID: <id>`를 유지하고, 앱 위젯 테스트가 `find.text('Exec ID: <id>')` 탭으로 selected run을 collapse하므로, detail 헤더가 같은 문자열을 중복 렌더링하면 tap이 다중 매칭으로 실패한다. 이를 피하기 위해 헤더는 별도 라벨(`Run <id>`, meta는 `Job <id> · Runner <id>`)을 사용한다.
|
||||
- list/detail 배치: 계획 After 의사코드(`_ExecutionWorkspace(list:, detail:)`)의 의미를 유지하되, 기존 `OtoConsoleShell` surface가 단일 세로 Column(maxWidth 820) 안에 놓이므로 좌우 2-pane 대신 위(run list)/아래(selected run detail) 세로 split으로 구현했다. 선택 없음일 때는 `_RunDetailPlaceholder`("Select a run to view details.")를, 선택 시 `SingleChildScrollView` detail을 `Expanded`로 둔다. 공개 계약(`expandedExecutionID`/`onExpandExecution` 토글)은 그대로 유지했다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
- 공유 artifact anatomy는 Dart의 파일 단위 privacy 때문에 언더스코어 private 클래스로 두 파일에서 공유할 수 없어, 비-export 내부 파일 `lib/src/oto_artifact_record_row.dart`에 `ArtifactRecordRow`(+`ArtifactRowDensity`)를 두고 executions/artifacts 두 surface가 import해서 재사용한다. barrel(`oto_console.dart`)에 export하지 않아 패키지 public API는 늘어나지 않는다(심볼 참조 계약 유지). full density는 Artifacts 탭, compact density는 execution detail preview가 쓰며 둘 다 name + `Path: <path>` ellipsis 유지.
|
||||
- action toolbar는 콜백/guard/confirmation/feedback을 그대로 두고 layout만 `_ExecutionActionToolbar(destructive/reporting/append)`로 그룹화했다. 'Actions' 헤딩과 4개 버튼 라벨/아이콘(`Cancel`/`Report`/`Log`/`Artifact`)은 기존 테스트 계약 그대로 유지한다.
|
||||
- log/artifact preview는 `_DetailPane(title, action?)` + `_LogPreviewBox`(bounded 180 monospace)로 재배치했다. Artifacts pane의 `View Artifacts Tab` 이동 버튼은 pane action slot으로 옮겼고, log pane action slot은 향후 full console용으로 비워뒀다. stage graph 데이터 모델이 없으므로 `_buildStageSummaryPlaceholder`("Stage summary unavailable")만 두고 full stage graph/artifact download/content preview는 범위에서 제외했다.
|
||||
- 삭제/rename된 public symbol 없음. `OtoExecutionsSurface`, `OtoArtifactsSurface`, `OtoActionViewState`, `OtoExecution*Draft`, `OtoArtifactViewModel` 계약과 호출부(`apps/client/lib/src/app/oto_client_app.dart`)는 그대로다. agent-ui `구현됨` status 전환은 하지 않았다(milestone completion review 대상).
|
||||
|
||||
## 사용자 리뷰 요청
|
||||
|
||||
|
|
@ -117,34 +122,45 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
|
|||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
|
||||
> 참고: 계획의 중간/최종 검증은 원격 runner `flutter analyze && flutter test` 계약이나, 원격 Flutter/Dart가 PATH에 없어(FLUTTER_NOT_FOUND / DART_NOT_FOUND) 차단됨. 로컬 Flutter 3.41.5로 동일 명령을 실행해 evidence를 남긴다(아래 원격 preflight 블록 참조).
|
||||
|
||||
### LAYOUT-1 중간 검증
|
||||
```
|
||||
$ cd packages/flutter/oto_console && flutter test
|
||||
(output)
|
||||
00:01 +9: OtoExecutionsSurface renders loading/empty/error/data states and expanded detail
|
||||
00:01 +11: OtoExecutionsSurface renders execution action states
|
||||
00:01 +12: OtoExecutionsSurface shows Actions section when expanded
|
||||
00:01 +14: All tests passed!
|
||||
```
|
||||
|
||||
### LAYOUT-2 중간 검증
|
||||
```
|
||||
$ cd apps/client && flutter test
|
||||
(output)
|
||||
00:01 +11: OtoClientApp wires execution actions and refresh
|
||||
00:01 +12: OtoClientApp execution action success triggers refresh with real runner id
|
||||
00:02 +13: OtoClientApp unknown runner id keeps action disabled
|
||||
00:03 +27: All tests passed!
|
||||
```
|
||||
|
||||
### LAYOUT-3 중간 검증
|
||||
```
|
||||
$ cd packages/flutter/oto_console && flutter test
|
||||
(output)
|
||||
00:01 +9: OtoExecutionsSurface renders loading/empty/error/data states and expanded detail
|
||||
00:01 +14: All tests passed!
|
||||
```
|
||||
|
||||
### LAYOUT-4 중간 검증
|
||||
```
|
||||
$ cd apps/client && flutter test
|
||||
(output)
|
||||
00:03 +23: OtoClientApp wires jobs, executions and artifacts data state on API success
|
||||
00:03 +26: OtoClientApp wires expanded execution detail logs and artifacts empty/error states
|
||||
00:03 +27: All tests passed!
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```
|
||||
$ git diff --check
|
||||
(output)
|
||||
PASS: no whitespace errors
|
||||
```
|
||||
|
||||
```
|
||||
|
|
@ -171,22 +187,54 @@ for p in targets:
|
|||
raise SystemExit(f'agent-ui status should remain 계획 until milestone completion review: {p}')
|
||||
print('agent-ui implementation targets remain plan-state for milestone review')
|
||||
PY
|
||||
(output)
|
||||
agent-ui implementation targets remain plan-state for milestone review
|
||||
```
|
||||
|
||||
```
|
||||
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/agent-work/oto && printf "workdir=%s\n" "$PWD" && printf "branch=" && git rev-parse --abbrev-ref HEAD && printf "head=" && git rev-parse --short HEAD && printf "status-start\n" && git status --short && printf "status-end\n" && command -v flutter && flutter --version | head -n 1'
|
||||
(output)
|
||||
workdir=/Users/toki/agent-work/oto
|
||||
branch=master
|
||||
head=7e51ef9
|
||||
status-start
|
||||
M apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1
|
||||
M apps/runner/assets/script/shell/oto_agent_bootstrap.sh
|
||||
M apps/runner/test/oto_agent_bootstrap_ps1_test.dart
|
||||
M apps/runner/test/oto_agent_bootstrap_script_test.dart
|
||||
M apps/runner/test/oto_server_connection_smoke_test.dart
|
||||
M services/core/internal/httpserver/oto_agent_bootstrap.ps1
|
||||
M services/core/internal/httpserver/oto_agent_bootstrap.sh
|
||||
M services/core/internal/httpserver/routes.go
|
||||
M services/core/internal/httpserver/runner_handlers.go
|
||||
M services/core/internal/httpserver/server_test.go
|
||||
M services/core/internal/runnerregistry/registry.go
|
||||
M services/core/internal/runnerregistry/registry_test.go
|
||||
?? .tmp/
|
||||
status-end
|
||||
# command -v flutter -> 비어 있음(exit 1)로 이후 flutter --version 미실행
|
||||
# 확인 재실행: $ ssh ... 'command -v flutter || echo FLUTTER_NOT_FOUND; which dart || echo DART_NOT_FOUND'
|
||||
FLUTTER_NOT_FOUND
|
||||
DART_NOT_FOUND
|
||||
# BLOCKER: 원격 runner에 flutter/dart 미설치(PATH 없음). 원격 HEAD 7e51ef9는 본 브랜치와 다르고 unrelated dirty 상태로 source sync도 미정렬.
|
||||
```
|
||||
|
||||
```
|
||||
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/agent-work/oto/packages/flutter/oto_console && flutter analyze && flutter test'
|
||||
(output)
|
||||
# BLOCKED: 원격 Flutter PATH 없음. 로컬 대체 실행(Flutter 3.41.5):
|
||||
$ cd packages/flutter/oto_console && flutter analyze
|
||||
Analyzing oto_console...
|
||||
No issues found! (ran in 4.5s)
|
||||
$ cd packages/flutter/oto_console && flutter test
|
||||
00:01 +14: All tests passed!
|
||||
```
|
||||
|
||||
```
|
||||
$ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/agent-work/oto/apps/client && flutter analyze && flutter test'
|
||||
(output)
|
||||
# BLOCKED: 원격 Flutter PATH 없음. 로컬 대체 실행(Flutter 3.41.5):
|
||||
$ cd apps/client && flutter analyze
|
||||
Analyzing client...
|
||||
No issues found! (ran in 2.6s)
|
||||
$ cd apps/client && flutter test
|
||||
00:03 +27: All tests passed!
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -208,3 +256,30 @@ $ ssh -o BatchMode=yes -o ConnectTimeout=10 toki@toki-labs.com 'cd /Users/toki/a
|
|||
| 사용자 리뷰 요청 | Implementing agent | Keep `상태: 없음` unless selected Milestone lock decision blocks implementation |
|
||||
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Review focus |
|
||||
| 검증 결과 | Implementing agent | Fill command output only |
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
- 리뷰 일시: 2026-07-03
|
||||
- 종합 판정: PASS
|
||||
- 차원별 평가:
|
||||
- Correctness: Pass
|
||||
- Completeness: Pass
|
||||
- Test coverage: Pass
|
||||
- API contract: Pass
|
||||
- Code quality: Pass
|
||||
- Implementation deviation: Pass
|
||||
- Verification trust: Pass
|
||||
- Roadmap target conformance: Pass
|
||||
- 발견된 문제: 없음
|
||||
- 리뷰 중 정리:
|
||||
- `dart format`을 적용해 `packages/flutter/oto_console/lib/src/oto_executions_surface.dart`와 `packages/flutter/oto_console/test/oto_console_test.dart` 포맷 drift를 정리했다.
|
||||
- 검증:
|
||||
- `dart format --output=none --set-exit-if-changed packages/flutter/oto_console/lib/src/oto_artifact_record_row.dart packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart packages/flutter/oto_console/lib/src/oto_executions_surface.dart packages/flutter/oto_console/test/oto_console_test.dart apps/client/test/widget_test.dart`: PASS, `Formatted 5 files (0 changed)`.
|
||||
- `cd packages/flutter/oto_console && flutter analyze`: PASS, `No issues found!`.
|
||||
- `cd packages/flutter/oto_console && flutter test`: PASS, `+14: All tests passed!`.
|
||||
- `cd apps/client && flutter analyze`: PASS, `No issues found!`.
|
||||
- `cd apps/client && flutter test`: PASS, `+27: All tests passed!`.
|
||||
- `git diff --check`: PASS.
|
||||
- agent-ui status guard script: PASS, 대상 view/component가 milestone completion review 전 `계획` 상태로 유지됨.
|
||||
- 원격 runner preflight: BLOCKED as expected, `/Users/toki/agent-work/oto`는 접근되지만 `FLUTTER_NOT_FOUND` / `DART_NOT_FOUND`이고 원격 checkout은 `master@7e51ef9` dirty 상태라 원격 Flutter 계약은 실행 불가.
|
||||
- 다음 단계: PASS 종결 처리. `complete.log` 작성 후 task directory를 `agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/`로 이동한다.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
# Complete - m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-07-03
|
||||
|
||||
## 요약
|
||||
|
||||
Execution/artifact views layout pass completed in loop 0 with final verdict PASS.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_local_G07_0.log` | `code_review_local_G07_0.log` | PASS | Execution list/detail, action toolbar, log/artifact panes, artifact row anatomy, tests, and verification evidence accepted. |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `OtoExecutionsSurface`를 run list와 selected run detail 구조로 재배치하고 run header, action toolbar, stage summary placeholder, logs pane, artifacts pane 순서를 구현했다.
|
||||
- execution action guard, cancel confirmation, report/log/artifact callback payload, submitting/success/failed feedback을 유지했다.
|
||||
- `ArtifactRecordRow`와 `ArtifactRowDensity`를 내부 src helper로 추가해 Artifacts tab과 execution detail artifact preview가 같은 file/path row anatomy를 공유하게 했다.
|
||||
- package/app widget tests에 selected detail header, stage placeholder, artifact/log preview assertions를 보강했다.
|
||||
- 리뷰 중 `dart format`을 적용해 formatting drift를 정리했다.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `dart format --output=none --set-exit-if-changed packages/flutter/oto_console/lib/src/oto_artifact_record_row.dart packages/flutter/oto_console/lib/src/oto_artifacts_surface.dart packages/flutter/oto_console/lib/src/oto_executions_surface.dart packages/flutter/oto_console/test/oto_console_test.dart apps/client/test/widget_test.dart` - PASS; `Formatted 5 files (0 changed)`.
|
||||
- `cd packages/flutter/oto_console && flutter analyze` - PASS; `No issues found!`.
|
||||
- `cd packages/flutter/oto_console && flutter test` - PASS; `+14: All tests passed!`.
|
||||
- `cd apps/client && flutter analyze` - PASS; `No issues found!`.
|
||||
- `cd apps/client && flutter test` - PASS; `+27: All tests passed!`.
|
||||
- `git diff --check` - PASS; no whitespace errors.
|
||||
- agent-ui status guard script - PASS; implementation target docs remain `계획` for milestone completion review.
|
||||
- remote runner preflight - BLOCKED; `/Users/toki/agent-work/oto` is reachable, but remote Flutter/Dart are not on PATH (`FLUTTER_NOT_FOUND`, `DART_NOT_FOUND`) and the remote checkout is `master@7e51ef9` with unrelated dirty files.
|
||||
|
||||
## Roadmap Completion
|
||||
|
||||
- Milestone: `agent-roadmap/phase/control-plane-product-surface/milestones/control-plane-jenkins-aligned-layout-pass.md`
|
||||
- Completed task ids:
|
||||
- `view-layout`: PASS; evidence=`agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/plan_local_G07_0.log`, `agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/code_review_local_G07_0.log`; verification=`cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test`, `cd packages/flutter/oto_console && flutter analyze`, `cd apps/client && flutter analyze`
|
||||
- `component-layout`: PASS; evidence=`agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/plan_local_G07_0.log`, `agent-task/archive/2026/07/m-control-plane-jenkins-aligned-layout-pass/03+01,02_execution_artifact_views/code_review_local_G07_0.log`; verification=`dart format --output=none --set-exit-if-changed ...`, `cd packages/flutter/oto_console && flutter test`, `cd apps/client && flutter test`
|
||||
- Not completed task ids: 없음
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -1729,6 +1729,8 @@ void _registerJobsExecutionsTests() {
|
|||
await tester.tap(find.text('Exec ID: exec-99'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Run exec-99'), findsOneWidget);
|
||||
expect(find.text('Stage summary unavailable'), findsOneWidget);
|
||||
expect(find.text('Logs Preview'), findsOneWidget);
|
||||
expect(find.textContaining('Task 1 done'), findsOneWidget);
|
||||
expect(find.text('release.tar.gz'), findsOneWidget);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'oto_console_contract.dart';
|
||||
|
||||
/// Row density for the shared artifact browser anatomy.
|
||||
///
|
||||
/// [full] is used by the standalone Artifacts browser tab, [compact] by the
|
||||
/// selected execution detail artifact pane. Both share file identity,
|
||||
/// path/location, and a reserved trailing slot for future artifact actions.
|
||||
enum ArtifactRowDensity { full, compact }
|
||||
|
||||
/// Shared artifact record row used by both the Artifacts browser tab and the
|
||||
/// execution detail artifact pane so they keep the same anatomy.
|
||||
///
|
||||
/// Keeps the full path with an ellipsis and does not add download or content
|
||||
/// preview affordances; the trailing region is reserved for future actions.
|
||||
class ArtifactRecordRow extends StatelessWidget {
|
||||
final OtoArtifactViewModel artifact;
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
final ArtifactRowDensity density;
|
||||
|
||||
const ArtifactRecordRow({
|
||||
super.key,
|
||||
required this.artifact,
|
||||
required this.theme,
|
||||
this.density = ArtifactRowDensity.full,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isFull = density == ArtifactRowDensity.full;
|
||||
return Container(
|
||||
padding: EdgeInsets.all(isFull ? 16 : 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isFull ? theme.railBackgroundColor : theme.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(isFull ? 8 : 6),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.insert_drive_file_outlined,
|
||||
color: theme.primaryColor,
|
||||
size: isFull ? 24 : 16,
|
||||
),
|
||||
SizedBox(width: isFull ? 16 : 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
artifact.name,
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: isFull ? 15 : 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Path: ${artifact.path}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: isFull ? 12 : 11,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'oto_artifact_record_row.dart';
|
||||
import 'oto_console_contract.dart';
|
||||
|
||||
class OtoArtifactsSurface extends StatelessWidget {
|
||||
|
|
@ -166,49 +167,10 @@ class OtoArtifactsSurface extends StatelessWidget {
|
|||
itemCount: artifacts.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final artifact = artifacts[index];
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.insert_drive_file_outlined,
|
||||
color: theme.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
artifact.name,
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Path: ${artifact.path}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return ArtifactRecordRow(
|
||||
artifact: artifacts[index],
|
||||
theme: theme,
|
||||
density: ArtifactRowDensity.full,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'oto_artifact_record_row.dart';
|
||||
import 'oto_console_contract.dart';
|
||||
|
||||
class OtoExecutionsSurface extends StatelessWidget {
|
||||
|
|
@ -182,124 +183,263 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
OtoConsoleThemeAdapter theme,
|
||||
List<OtoExecutionViewModel> executions,
|
||||
) {
|
||||
return ListView.separated(
|
||||
final selected = _selectedExecution(executions);
|
||||
|
||||
final list = ListView.separated(
|
||||
itemCount: executions.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final exec = executions[index];
|
||||
final isExpanded = expandedExecutionID == exec.executionID;
|
||||
itemBuilder: (context, index) =>
|
||||
_buildExecutionRow(theme, executions[index]),
|
||||
);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
// Jenkins Build History + build page relationship: a run list on top and a
|
||||
// dedicated selected-run detail below, instead of inline expansion.
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: list),
|
||||
const SizedBox(height: 16),
|
||||
if (selected == null)
|
||||
_RunDetailPlaceholder(theme: theme)
|
||||
else
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: _buildExecutionDetail(context, theme, selected),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
if (isExpanded) {
|
||||
onExpandExecution?.call(null);
|
||||
} else {
|
||||
onExpandExecution?.call(exec.executionID);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Exec ID: ${exec.executionID}',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusBadge(state: exec.state, theme: theme),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Job ID: ${exec.jobID}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
if (exec.runnerID.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Runner ID: ${exec.runnerID}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Created: ${exec.createdAt}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
isExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
color: theme.textColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isExpanded) ...[
|
||||
const Divider(height: 1, thickness: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildActionsSection(context, theme, exec),
|
||||
const SizedBox(height: 16),
|
||||
_buildLogsSection(theme),
|
||||
const SizedBox(height: 16),
|
||||
_buildArtifactsSection(theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionsSection(BuildContext ctx, OtoConsoleThemeAdapter theme, OtoExecutionViewModel exec) {
|
||||
OtoExecutionViewModel? _selectedExecution(
|
||||
List<OtoExecutionViewModel> executions,
|
||||
) {
|
||||
if (expandedExecutionID == null) {
|
||||
return null;
|
||||
}
|
||||
for (final exec in executions) {
|
||||
if (exec.executionID == expandedExecutionID) {
|
||||
return exec;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _buildExecutionRow(
|
||||
OtoConsoleThemeAdapter theme,
|
||||
OtoExecutionViewModel exec,
|
||||
) {
|
||||
final isSelected = expandedExecutionID == exec.executionID;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected ? theme.primaryColor : theme.borderColor,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (isSelected) {
|
||||
onExpandExecution?.call(null);
|
||||
} else {
|
||||
onExpandExecution?.call(exec.executionID);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Exec ID: ${exec.executionID}',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusBadge(state: exec.state, theme: theme),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Job ID: ${exec.jobID}',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
|
||||
),
|
||||
if (exec.runnerID.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Runner ID: ${exec.runnerID}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Created: ${exec.createdAt}',
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontSize: 12,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
isSelected ? Icons.expand_less : Icons.expand_more,
|
||||
color: theme.textColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExecutionDetail(
|
||||
BuildContext context,
|
||||
OtoConsoleThemeAdapter theme,
|
||||
OtoExecutionViewModel exec,
|
||||
) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildRunHeader(theme, exec),
|
||||
const SizedBox(height: 16),
|
||||
_buildActionsSection(context, theme, exec),
|
||||
const SizedBox(height: 16),
|
||||
_buildStageSummaryPlaceholder(theme),
|
||||
const SizedBox(height: 16),
|
||||
_DetailPane(
|
||||
title: 'Logs Preview',
|
||||
theme: theme,
|
||||
child: _LogPreviewBox(
|
||||
theme: theme,
|
||||
child: _buildLogsContent(theme),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_DetailPane(
|
||||
title: 'Artifacts',
|
||||
theme: theme,
|
||||
action: onViewArtifactsSection == null
|
||||
? null
|
||||
: TextButton.icon(
|
||||
onPressed: onViewArtifactsSection,
|
||||
icon: const Icon(Icons.arrow_forward, size: 14),
|
||||
label: const Text('View Artifacts Tab'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.primaryColor,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
child: _buildArtifactsContent(theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRunHeader(
|
||||
OtoConsoleThemeAdapter theme,
|
||||
OtoExecutionViewModel exec,
|
||||
) {
|
||||
final meta = exec.runnerID.isEmpty
|
||||
? 'Job ${exec.jobID}'
|
||||
: 'Job ${exec.jobID} · Runner ${exec.runnerID}';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Run ${exec.executionID}',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusBadge(state: exec.state, theme: theme),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
meta,
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStageSummaryPlaceholder(OtoConsoleThemeAdapter theme) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.account_tree_outlined,
|
||||
color: theme.textSecondaryColor,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Stage summary unavailable',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionsSection(
|
||||
BuildContext ctx,
|
||||
OtoConsoleThemeAdapter theme,
|
||||
OtoExecutionViewModel exec,
|
||||
) {
|
||||
final actionState = actionStates?[exec.executionID];
|
||||
final canAct = exec.runnerID.isNotEmpty && !exec.state.toLowerCase().contains('cancel');
|
||||
final canAct =
|
||||
exec.runnerID.isNotEmpty &&
|
||||
!exec.state.toLowerCase().contains('cancel');
|
||||
final isSubmitting = actionState?.isSubmitting ?? false;
|
||||
|
||||
return Column(
|
||||
|
|
@ -314,10 +454,8 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_ExecutionActionToolbar(
|
||||
destructive: [
|
||||
_ActionButton(
|
||||
label: 'Cancel',
|
||||
icon: Icons.stop_circle,
|
||||
|
|
@ -332,6 +470,8 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
reporting: [
|
||||
_ActionButton(
|
||||
label: 'Report',
|
||||
icon: Icons.check_circle,
|
||||
|
|
@ -352,6 +492,8 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
append: [
|
||||
_ActionButton(
|
||||
label: 'Log',
|
||||
icon: Icons.bug_report,
|
||||
|
|
@ -395,7 +537,8 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
),
|
||||
],
|
||||
),
|
||||
if (actionState != null && (actionState.isSuccess || actionState.isFailed)) ...[
|
||||
if (actionState != null &&
|
||||
(actionState.isSuccess || actionState.isFailed)) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
|
|
@ -411,7 +554,8 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
child: Text(
|
||||
actionState.message ?? (actionState.isSuccess ? 'Succeeded' : 'Failed'),
|
||||
actionState.message ??
|
||||
(actionState.isSuccess ? 'Succeeded' : 'Failed'),
|
||||
style: TextStyle(
|
||||
color: actionState.isFailed ? Colors.red : theme.primaryColor,
|
||||
fontSize: 12,
|
||||
|
|
@ -424,13 +568,18 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
void _showCancelConfirmation(BuildContext context, OtoExecutionViewModel exec) {
|
||||
void _showCancelConfirmation(
|
||||
BuildContext context,
|
||||
OtoExecutionViewModel exec,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Cancel Execution'),
|
||||
content: Text('Are you sure you want to cancel execution "${exec.executionID}"?'),
|
||||
content: Text(
|
||||
'Are you sure you want to cancel execution "${exec.executionID}"?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
|
|
@ -456,44 +605,14 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildLogsSection(OtoConsoleThemeAdapter theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Logs Preview',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
child: _buildLogsContent(theme),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogsContent(OtoConsoleThemeAdapter theme) {
|
||||
if (logsSnapshot == null) {
|
||||
return Text(
|
||||
'No logs loaded.',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'),
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
);
|
||||
}
|
||||
switch (logsSnapshot!.state) {
|
||||
|
|
@ -502,7 +621,10 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
case OtoSurfaceLoadState.empty:
|
||||
return Text(
|
||||
'Log is empty.',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'),
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
);
|
||||
case OtoSurfaceLoadState.error:
|
||||
return Text(
|
||||
|
|
@ -514,7 +636,10 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
if (logs.isEmpty) {
|
||||
return Text(
|
||||
'Log is empty.',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontFamily: 'monospace'),
|
||||
style: TextStyle(
|
||||
color: theme.textSecondaryColor,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
|
|
@ -530,40 +655,6 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
Widget _buildArtifactsSection(OtoConsoleThemeAdapter theme) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Artifacts',
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (onViewArtifactsSection != null)
|
||||
TextButton.icon(
|
||||
onPressed: onViewArtifactsSection,
|
||||
icon: const Icon(Icons.arrow_forward, size: 14),
|
||||
label: const Text('View Artifacts Tab'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: theme.primaryColor,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildArtifactsContent(theme),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtifactsContent(OtoConsoleThemeAdapter theme) {
|
||||
if (artifactsSnapshot == null) {
|
||||
return Text(
|
||||
|
|
@ -593,28 +684,18 @@ class OtoExecutionsSurface extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
return Column(
|
||||
children: artifacts.map((artifact) {
|
||||
return Card(
|
||||
color: theme.backgroundColor,
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
side: BorderSide(color: theme.borderColor),
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: Icon(Icons.insert_drive_file, color: theme.primaryColor, size: 16),
|
||||
title: Text(
|
||||
artifact.name,
|
||||
style: TextStyle(color: theme.textColor, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
children: artifacts
|
||||
.map(
|
||||
(artifact) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: ArtifactRecordRow(
|
||||
artifact: artifact,
|
||||
theme: theme,
|
||||
density: ArtifactRowDensity.compact,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
artifact.path,
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 11),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -667,11 +748,18 @@ class _ActionButton extends StatelessWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: enabled ? Colors.white : Colors.white60),
|
||||
Icon(
|
||||
icon,
|
||||
size: 14,
|
||||
color: enabled ? Colors.white : Colors.white60,
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
|
@ -744,3 +832,131 @@ class _StatusBadge extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Groups execution actions by intent (destructive, reporting, append) so the
|
||||
/// selected-run detail toolbar mirrors the Jenkins build-page action grouping
|
||||
/// while keeping the underlying button callbacks unchanged.
|
||||
class _ExecutionActionToolbar extends StatelessWidget {
|
||||
final List<Widget> destructive;
|
||||
final List<Widget> reporting;
|
||||
final List<Widget> append;
|
||||
|
||||
const _ExecutionActionToolbar({
|
||||
required this.destructive,
|
||||
required this.reporting,
|
||||
required this.append,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget group(List<Widget> items) =>
|
||||
Wrap(spacing: 8, runSpacing: 8, children: items);
|
||||
final groups = [
|
||||
destructive,
|
||||
reporting,
|
||||
append,
|
||||
].where((items) => items.isNotEmpty).map(group).toList();
|
||||
return Wrap(spacing: 20, runSpacing: 8, children: groups);
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded detail pane inside the selected execution detail with a title and
|
||||
/// optional trailing action slot, leaving room for future console/artifact
|
||||
/// actions without changing the pane anatomy.
|
||||
class _DetailPane extends StatelessWidget {
|
||||
final String title;
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
final Widget child;
|
||||
final Widget? action;
|
||||
|
||||
const _DetailPane({
|
||||
required this.title,
|
||||
required this.theme,
|
||||
required this.child,
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: theme.textColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
?action,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded monospace console box used by the logs preview pane.
|
||||
class _LogPreviewBox extends StatelessWidget {
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
final Widget child;
|
||||
|
||||
const _LogPreviewBox({required this.theme, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
constraints: const BoxConstraints(maxHeight: 180),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown in the detail region when no run is selected in the list.
|
||||
class _RunDetailPlaceholder extends StatelessWidget {
|
||||
final OtoConsoleThemeAdapter theme;
|
||||
|
||||
const _RunDetailPlaceholder({required this.theme});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.railBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: theme.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.play_circle_outline,
|
||||
color: theme.textSecondaryColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Select a run to view details.',
|
||||
style: TextStyle(color: theme.textSecondaryColor, fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,25 +241,40 @@ void main() {
|
|||
expect(find.text('Self Update'), findsOneWidget);
|
||||
expect(find.text('Update'), findsOneWidget);
|
||||
final disabledBtn = tester.widget<ElevatedButton>(
|
||||
find.ancestor(of: find.text('Update'), matching: find.byType(ElevatedButton)),
|
||||
find.ancestor(
|
||||
of: find.text('Update'),
|
||||
matching: find.byType(ElevatedButton),
|
||||
),
|
||||
);
|
||||
expect(disabledBtn.onPressed, isNull);
|
||||
|
||||
// --- HTTPS URL validation: non-HTTPS keeps button disabled ---
|
||||
await tester.enterText(find.byType(TextField).at(0), 'v1.2.3');
|
||||
await tester.pump();
|
||||
await tester.enterText(find.byType(TextField).at(1), 'http://not-https.example.com/oto');
|
||||
await tester.enterText(
|
||||
find.byType(TextField).at(1),
|
||||
'http://not-https.example.com/oto',
|
||||
);
|
||||
await tester.pump();
|
||||
final httpBtn = tester.widget<ElevatedButton>(
|
||||
find.ancestor(of: find.text('Update'), matching: find.byType(ElevatedButton)),
|
||||
find.ancestor(
|
||||
of: find.text('Update'),
|
||||
matching: find.byType(ElevatedButton),
|
||||
),
|
||||
);
|
||||
expect(httpBtn.onPressed, isNull);
|
||||
|
||||
// --- HTTPS URL: button enabled ---
|
||||
await tester.enterText(find.byType(TextField).at(1), 'https://releases.example.test/oto-agent');
|
||||
await tester.enterText(
|
||||
find.byType(TextField).at(1),
|
||||
'https://releases.example.test/oto-agent',
|
||||
);
|
||||
await tester.pump();
|
||||
final enabledBtn = tester.widget<ElevatedButton>(
|
||||
find.ancestor(of: find.text('Update'), matching: find.byType(ElevatedButton)),
|
||||
find.ancestor(
|
||||
of: find.text('Update'),
|
||||
matching: find.byType(ElevatedButton),
|
||||
),
|
||||
);
|
||||
expect(enabledBtn.onPressed, isNotNull);
|
||||
|
||||
|
|
@ -269,7 +284,10 @@ void main() {
|
|||
expect(capturedDraft, isNotNull);
|
||||
expect(capturedDraft!.runnerID, 'runner-1');
|
||||
expect(capturedDraft!.version, 'v1.2.3');
|
||||
expect(capturedDraft!.downloadUrl, 'https://releases.example.test/oto-agent');
|
||||
expect(
|
||||
capturedDraft!.downloadUrl,
|
||||
'https://releases.example.test/oto-agent',
|
||||
);
|
||||
|
||||
// --- Submitting state: spinner shown, button disabled ---
|
||||
await tester.pumpWidget(
|
||||
|
|
@ -285,7 +303,10 @@ void main() {
|
|||
);
|
||||
expect(find.byType(CircularProgressIndicator), findsAtLeast(1));
|
||||
final submittingBtn = tester.widget<ElevatedButton>(
|
||||
find.ancestor(of: find.byType(CircularProgressIndicator).first, matching: find.byType(ElevatedButton)),
|
||||
find.ancestor(
|
||||
of: find.byType(CircularProgressIndicator).first,
|
||||
matching: find.byType(ElevatedButton),
|
||||
),
|
||||
);
|
||||
expect(submittingBtn.onPressed, isNull);
|
||||
|
||||
|
|
@ -295,7 +316,11 @@ void main() {
|
|||
home: Scaffold(
|
||||
body: OtoRunnersSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(runners),
|
||||
actionStates: {'runner-1': const OtoActionViewState.deferred(message: 'Update scheduled')},
|
||||
actionStates: {
|
||||
'runner-1': const OtoActionViewState.deferred(
|
||||
message: 'Update scheduled',
|
||||
),
|
||||
},
|
||||
onSelfUpdateRunner: (draft) {},
|
||||
),
|
||||
),
|
||||
|
|
@ -309,7 +334,11 @@ void main() {
|
|||
home: Scaffold(
|
||||
body: OtoRunnersSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(runners),
|
||||
actionStates: {'runner-1': const OtoActionViewState.failed(message: 'Runner not found')},
|
||||
actionStates: {
|
||||
'runner-1': const OtoActionViewState.failed(
|
||||
message: 'Runner not found',
|
||||
),
|
||||
},
|
||||
onSelfUpdateRunner: (draft) {},
|
||||
),
|
||||
),
|
||||
|
|
@ -318,18 +347,40 @@ void main() {
|
|||
expect(find.textContaining('Runner not found'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OtoJobsSurface renders loading/empty/error/data states', (tester) async {
|
||||
testWidgets('OtoJobsSurface renders loading/empty/error/data states', (
|
||||
tester,
|
||||
) async {
|
||||
// Loading
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.loading()))));
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.loading()),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
// Empty
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.empty()))));
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.empty()),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('No pipeline jobs'), findsOneWidget);
|
||||
expect(find.text('Job queue is empty.'), findsOneWidget);
|
||||
|
||||
// Error
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoJobsSurface(snapshot: OtoSurfaceSnapshot.error('Failed to fetch jobs')))));
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoJobsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.error('Failed to fetch jobs'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Error loading jobs'), findsOneWidget);
|
||||
expect(find.text('Failed to fetch jobs'), findsOneWidget);
|
||||
|
||||
|
|
@ -365,90 +416,149 @@ void main() {
|
|||
expect(tappedExecution, 'exec-1');
|
||||
});
|
||||
|
||||
testWidgets('OtoExecutionsSurface renders loading/empty/error/data states and expanded detail', (tester) async {
|
||||
// Loading
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.loading()))));
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
// Empty
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.empty()))));
|
||||
expect(find.text('No executions'), findsOneWidget);
|
||||
expect(find.text('Execution history is empty.'), findsOneWidget);
|
||||
|
||||
// Error
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.error('Failed to fetch executions')))));
|
||||
expect(find.text('Error loading executions'), findsOneWidget);
|
||||
expect(find.text('Failed to fetch executions'), findsOneWidget);
|
||||
|
||||
// Data
|
||||
final executions = [
|
||||
const OtoExecutionViewModel(
|
||||
executionID: 'exec-1',
|
||||
jobID: 'job-1',
|
||||
state: 'succeeded',
|
||||
runnerID: 'runner-1',
|
||||
createdAt: '2026-06-15T00:01:00Z',
|
||||
updatedAt: '2026-06-15T00:05:00Z',
|
||||
),
|
||||
];
|
||||
String? expandedId;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoExecutionsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(executions),
|
||||
onExpandExecution: (id) => expandedId = id,
|
||||
testWidgets(
|
||||
'OtoExecutionsSurface renders loading/empty/error/data states and expanded detail',
|
||||
(tester) async {
|
||||
// Loading
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.loading()),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
// Empty
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoExecutionsSurface(snapshot: OtoSurfaceSnapshot.empty()),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('No executions'), findsOneWidget);
|
||||
expect(find.text('Execution history is empty.'), findsOneWidget);
|
||||
|
||||
// Error
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoExecutionsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.error('Failed to fetch executions'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Error loading executions'), findsOneWidget);
|
||||
expect(find.text('Failed to fetch executions'), findsOneWidget);
|
||||
|
||||
// Data
|
||||
final executions = [
|
||||
const OtoExecutionViewModel(
|
||||
executionID: 'exec-1',
|
||||
jobID: 'job-1',
|
||||
state: 'succeeded',
|
||||
runnerID: 'runner-1',
|
||||
createdAt: '2026-06-15T00:01:00Z',
|
||||
updatedAt: '2026-06-15T00:05:00Z',
|
||||
),
|
||||
];
|
||||
String? expandedId;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoExecutionsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(executions),
|
||||
onExpandExecution: (id) => expandedId = id,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Exec ID: exec-1'), findsOneWidget);
|
||||
expect(find.text('SUCCEEDED'), findsOneWidget);
|
||||
expect(find.text('Job ID: job-1'), findsOneWidget);
|
||||
expect(find.text('Runner ID: runner-1'), findsOneWidget);
|
||||
// No run selected yet: detail region shows the placeholder.
|
||||
expect(find.text('Select a run to view details.'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Exec ID: exec-1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(expandedId, 'exec-1');
|
||||
|
||||
// Expanded detail rendering
|
||||
final logs = [
|
||||
const OtoLogEntryViewModel(
|
||||
timestamp: '12:00:00',
|
||||
line: 'Starting build...',
|
||||
),
|
||||
const OtoLogEntryViewModel(
|
||||
timestamp: '12:00:01',
|
||||
line: 'Finished successfully',
|
||||
),
|
||||
];
|
||||
final artifacts = [
|
||||
const OtoArtifactViewModel(
|
||||
name: 'binary.exe',
|
||||
path: '/build/binary.exe',
|
||||
),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoExecutionsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(executions),
|
||||
expandedExecutionID: 'exec-1',
|
||||
logsSnapshot: OtoSurfaceSnapshot.data(logs),
|
||||
artifactsSnapshot: OtoSurfaceSnapshot.data(artifacts),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
// Selected run detail: run header + stage summary placeholder + panes.
|
||||
expect(find.text('Run exec-1'), findsOneWidget);
|
||||
expect(find.text('Stage summary unavailable'), findsOneWidget);
|
||||
expect(find.text('Logs Preview'), findsOneWidget);
|
||||
expect(find.textContaining('Starting build...'), findsOneWidget);
|
||||
expect(find.text('Artifacts'), findsOneWidget);
|
||||
expect(find.text('binary.exe'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('OtoArtifactsSurface renders loading/empty/error/data states', (
|
||||
tester,
|
||||
) async {
|
||||
// Loading
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.loading()),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Exec ID: exec-1'), findsOneWidget);
|
||||
expect(find.text('SUCCEEDED'), findsOneWidget);
|
||||
expect(find.text('Job ID: job-1'), findsOneWidget);
|
||||
expect(find.text('Runner ID: runner-1'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Exec ID: exec-1'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(expandedId, 'exec-1');
|
||||
|
||||
// Expanded detail rendering
|
||||
final logs = [
|
||||
const OtoLogEntryViewModel(timestamp: '12:00:00', line: 'Starting build...'),
|
||||
const OtoLogEntryViewModel(timestamp: '12:00:01', line: 'Finished successfully'),
|
||||
];
|
||||
final artifacts = [
|
||||
const OtoArtifactViewModel(name: 'binary.exe', path: '/build/binary.exe'),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoExecutionsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(executions),
|
||||
expandedExecutionID: 'exec-1',
|
||||
logsSnapshot: OtoSurfaceSnapshot.data(logs),
|
||||
artifactsSnapshot: OtoSurfaceSnapshot.data(artifacts),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Logs Preview'), findsOneWidget);
|
||||
expect(find.textContaining('Starting build...'), findsOneWidget);
|
||||
expect(find.text('Artifacts'), findsOneWidget);
|
||||
expect(find.text('binary.exe'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OtoArtifactsSurface renders loading/empty/error/data states', (tester) async {
|
||||
// Loading
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.loading()))));
|
||||
expect(find.byType(CircularProgressIndicator), findsOneWidget);
|
||||
|
||||
// Empty
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.empty()))));
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.empty()),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('No artifacts'), findsOneWidget);
|
||||
expect(find.text('Artifact events are empty.'), findsOneWidget);
|
||||
|
||||
// Error
|
||||
await tester.pumpWidget(const MaterialApp(home: Scaffold(body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.error('Failed to fetch artifacts')))));
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoArtifactsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.error('Failed to fetch artifacts'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.text('Error loading artifacts'), findsOneWidget);
|
||||
expect(find.text('Failed to fetch artifacts'), findsOneWidget);
|
||||
|
||||
|
|
@ -459,7 +569,9 @@ void main() {
|
|||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: OtoArtifactsSurface(snapshot: OtoSurfaceSnapshot.data(artifacts)),
|
||||
body: OtoArtifactsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(artifacts),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -467,7 +579,9 @@ void main() {
|
|||
expect(find.text('Path: /build/app.apk'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OtoExecutionsSurface renders execution action states', (tester) async {
|
||||
testWidgets('OtoExecutionsSurface renders execution action states', (
|
||||
tester,
|
||||
) async {
|
||||
final executionsWithRunner = [
|
||||
const OtoExecutionViewModel(
|
||||
executionID: 'exec-1',
|
||||
|
|
@ -553,7 +667,11 @@ void main() {
|
|||
body: OtoExecutionsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(executionsWithRunner),
|
||||
expandedExecutionID: 'exec-1',
|
||||
actionStates: {'exec-1': const OtoActionViewState.succeeded(message: 'Report accepted')},
|
||||
actionStates: {
|
||||
'exec-1': const OtoActionViewState.succeeded(
|
||||
message: 'Report accepted',
|
||||
),
|
||||
},
|
||||
onReportExecution: (draft) {},
|
||||
onAppendLog: (draft) {},
|
||||
onAppendArtifact: (draft) {},
|
||||
|
|
@ -570,7 +688,11 @@ void main() {
|
|||
body: OtoExecutionsSurface(
|
||||
snapshot: OtoSurfaceSnapshot.data(executionsWithRunner),
|
||||
expandedExecutionID: 'exec-1',
|
||||
actionStates: {'exec-1': const OtoActionViewState.failed(message: 'Cancel rejected')},
|
||||
actionStates: {
|
||||
'exec-1': const OtoActionViewState.failed(
|
||||
message: 'Cancel rejected',
|
||||
),
|
||||
},
|
||||
onReportExecution: (draft) {},
|
||||
onAppendLog: (draft) {},
|
||||
onAppendArtifact: (draft) {},
|
||||
|
|
@ -581,7 +703,9 @@ void main() {
|
|||
expect(find.textContaining('Cancel rejected'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OtoExecutionsSurface shows Actions section when expanded', (tester) async {
|
||||
testWidgets('OtoExecutionsSurface shows Actions section when expanded', (
|
||||
tester,
|
||||
) async {
|
||||
final executionsWithRunner = [
|
||||
const OtoExecutionViewModel(
|
||||
executionID: 'exec-actions',
|
||||
|
|
@ -611,7 +735,9 @@ void main() {
|
|||
expect(find.byIcon(Icons.insert_drive_file), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('OtoJobsSurface renders job create action states', (tester) async {
|
||||
testWidgets('OtoJobsSurface renders job create action states', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
|
|
@ -636,14 +762,20 @@ void main() {
|
|||
await tester.enterText(find.byType(TextField).at(0), 'my-job-id');
|
||||
await tester.pump();
|
||||
var button = tester.widget<ElevatedButton>(
|
||||
find.ancestor(of: find.text('Create'), matching: find.byType(ElevatedButton)),
|
||||
find.ancestor(
|
||||
of: find.text('Create'),
|
||||
matching: find.byType(ElevatedButton),
|
||||
),
|
||||
);
|
||||
expect(button.onPressed, isNull);
|
||||
|
||||
await tester.enterText(find.byType(TextField).at(1), 'My Job Name');
|
||||
await tester.pump();
|
||||
button = tester.widget<ElevatedButton>(
|
||||
find.ancestor(of: find.text('Create'), matching: find.byType(ElevatedButton)),
|
||||
find.ancestor(
|
||||
of: find.text('Create'),
|
||||
matching: find.byType(ElevatedButton),
|
||||
),
|
||||
);
|
||||
expect(button.onPressed, isNotNull);
|
||||
|
||||
|
|
@ -690,7 +822,9 @@ void main() {
|
|||
home: Scaffold(
|
||||
body: OtoJobsSurface(
|
||||
snapshot: const OtoSurfaceSnapshot.empty(),
|
||||
jobCreateState: const OtoActionViewState.succeeded(message: 'Job created: job-123'),
|
||||
jobCreateState: const OtoActionViewState.succeeded(
|
||||
message: 'Job created: job-123',
|
||||
),
|
||||
onCreateJob: (draft) {},
|
||||
),
|
||||
),
|
||||
|
|
@ -704,7 +838,9 @@ void main() {
|
|||
home: Scaffold(
|
||||
body: OtoJobsSurface(
|
||||
snapshot: const OtoSurfaceSnapshot.empty(),
|
||||
jobCreateState: const OtoActionViewState.failed(message: 'Invalid request'),
|
||||
jobCreateState: const OtoActionViewState.failed(
|
||||
message: 'Invalid request',
|
||||
),
|
||||
onCreateJob: (draft) {},
|
||||
),
|
||||
),
|
||||
|
|
@ -712,4 +848,4 @@ void main() {
|
|||
);
|
||||
expect(find.textContaining('Invalid request'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue