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:
toki 2026-07-03 15:31:01 +09:00
parent 71e6fe7a91
commit c3a5129818
8 changed files with 900 additions and 384 deletions

View file

@ -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/`로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -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);

View file

@ -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,
),
],
),
),
],
),
);
}
}

View file

@ -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,
);
},
);

View file

@ -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),
),
),
],
),
);
}
}

View file

@ -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);
});
}
}