1490 lines
41 KiB
Markdown
1490 lines
41 KiB
Markdown
# Installer TUI Implementation Plan (Plan 2 of 4)
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
|
|
|
|
**Goal:** Replace the `(TUI not yet implemented — using plain output)` stub from Plan 1 with a real two-stage user experience: a Huh-driven wizard that gathers user choices (welcome, theme, module selection, advanced options, plan review, confirm), then a Bubble Tea dashboard that displays live per-module status while `engine.Run` executes.
|
|
|
|
**Architecture:** One shared `WizardState` struct passed between the wizard and the dashboard. The wizard runs first, blocking until the user confirms or cancels. If confirmed, we spawn `engine.Run` in a goroutine with a `TeaReporter` that forwards events to the Bubble Tea program via `tea.Program.Send`. The dashboard renders module rows with live status icons, expandable log tails, and a summary view when the run completes. Toy `hello` module remains the only real module — real install modules arrive in Plan 3.
|
|
|
|
**Tech Stack (additions on top of Plan 1):**
|
|
- `github.com/charmbracelet/huh` — form wizard.
|
|
- `github.com/charmbracelet/bubbletea` — dashboard runtime.
|
|
- `github.com/charmbracelet/lipgloss` — styling helpers.
|
|
- `github.com/charmbracelet/x/exp/teatest` — TUI testing harness.
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-04-13-installer-architecture-design.md` (§TUI flow).
|
|
**Depends on:** Plan 1 (`feat/go-rewrite` branch, commit `8b43a0f` or later).
|
|
|
|
---
|
|
|
|
## Conventions
|
|
|
|
Same as Plan 1: module path `github.com/MDietermann/ArduinoArchSetup`, Go 1.22, table-driven tests, no external assert libraries, no `Co-Authored-By` trailers, `feat(scope): ...` commit messages. All work on `feat/go-rewrite`.
|
|
|
|
## File structure produced by this plan
|
|
|
|
```
|
|
internal/
|
|
tui/
|
|
state.go # WizardState + helpers
|
|
state_test.go
|
|
reporter.go # TeaReporter: module.Reporter → tea.Msg events
|
|
reporter_test.go
|
|
wizard/
|
|
wizard.go # Huh form assembly + Run(ctx, …) → (state, confirmed, err)
|
|
wizard_test.go # teatest navigation smoke
|
|
dashboard/
|
|
model.go # Bubble Tea Model, Init, Update, View
|
|
model_test.go
|
|
run.go # dashboard.Run: drives engine.Run in a goroutine
|
|
run_test.go
|
|
cmd/archduino/main.go # modified: replace placeholder branch with TUI
|
|
```
|
|
|
|
Assets touched:
|
|
- `go.mod` / `go.sum` — new deps.
|
|
|
|
---
|
|
|
|
## Task 1: Add TUI dependencies and package skeleton
|
|
|
|
**Goal:** Pull in Charm libraries, create empty `internal/tui` directories with placeholder files so subsequent tasks have a landing zone. Proves the build graph still compiles.
|
|
|
|
**Files:**
|
|
- Modify: `go.mod`, `go.sum`
|
|
- Create: `internal/tui/doc.go`
|
|
- Create: `internal/tui/wizard/doc.go`
|
|
- Create: `internal/tui/dashboard/doc.go`
|
|
|
|
- [ ] **Step 1: Add dependencies**
|
|
|
|
```bash
|
|
go get github.com/charmbracelet/huh@latest
|
|
go get github.com/charmbracelet/bubbletea@latest
|
|
go get github.com/charmbracelet/lipgloss@latest
|
|
go get github.com/charmbracelet/x/exp/teatest@latest
|
|
go mod tidy
|
|
```
|
|
|
|
- [ ] **Step 2: Write package doc stubs**
|
|
|
|
`internal/tui/doc.go`:
|
|
```go
|
|
// Package tui hosts the interactive installer UI — the Huh-driven wizard
|
|
// that collects user choices and the Bubble Tea dashboard that renders
|
|
// live module status during engine.Run.
|
|
package tui
|
|
```
|
|
|
|
`internal/tui/wizard/doc.go`:
|
|
```go
|
|
// Package wizard is the pre-install form flow: welcome, theme picker,
|
|
// module selector, advanced options, plan review, and confirmation.
|
|
package wizard
|
|
```
|
|
|
|
`internal/tui/dashboard/doc.go`:
|
|
```go
|
|
// Package dashboard is the live-updating Bubble Tea view that shows
|
|
// per-module status while engine.Run executes.
|
|
package dashboard
|
|
```
|
|
|
|
- [ ] **Step 3: Build and confirm**
|
|
|
|
```bash
|
|
go build ./...
|
|
```
|
|
|
|
Expected: clean build, no changes outside the new dirs + go.mod/go.sum.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add go.mod go.sum internal/tui/
|
|
git commit -m "chore(tui): add Charm dependencies and package skeleton"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: `WizardState` — the shared TUI data model
|
|
|
|
**Goal:** Define the struct the wizard fills in and the dashboard (and later the engine) reads. Plus a constructor that seeds defaults from `config.Config` and `module.Registry`.
|
|
|
|
**Files:**
|
|
- Create: `internal/tui/state.go`
|
|
- Test: `internal/tui/state_test.go`
|
|
|
|
- [ ] **Step 1: Write `internal/tui/state.go`**
|
|
|
|
```go
|
|
package tui
|
|
|
|
import (
|
|
"sort"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
|
)
|
|
|
|
// Theme is a named colorscheme presented in the wizard. Plan 3 wires these
|
|
// names to actual theme modules; for now they are display-only strings.
|
|
type Theme struct {
|
|
ID string
|
|
Name string
|
|
}
|
|
|
|
// BuiltinThemes is the fixed list the wizard shows. Keep in sync with the
|
|
// themes shipped by Plan 3's theme module.
|
|
var BuiltinThemes = []Theme{
|
|
{ID: "gruvbox", Name: "Gruvbox Dark"},
|
|
{ID: "catppuccin", Name: "Catppuccin Mocha"},
|
|
{ID: "tokyonight", Name: "Tokyo Night"},
|
|
{ID: "nord", Name: "Nord"},
|
|
{ID: "dracula", Name: "Dracula"},
|
|
}
|
|
|
|
// ModuleChoice is one row in the module selector.
|
|
type ModuleChoice struct {
|
|
ID string
|
|
Name string
|
|
Description string
|
|
Selected bool
|
|
}
|
|
|
|
// WizardState is the payload the wizard fills in and the dashboard reads.
|
|
type WizardState struct {
|
|
ThemeID string
|
|
Modules []ModuleChoice
|
|
Parallelism int // advanced; reserved for a future concurrent engine
|
|
HaltOnError bool // advanced
|
|
Confirmed bool // true once the user hits Confirm on the last screen
|
|
}
|
|
|
|
// NewWizardState builds an initial state from the running config and the
|
|
// registered modules. Pre-ticks all modules except those disabled in the
|
|
// overlay, defaults the theme to the first built-in theme.
|
|
func NewWizardState(cfg config.Config, reg *module.Registry) WizardState {
|
|
disabled := map[string]bool{}
|
|
for _, id := range cfg.DisabledModules {
|
|
disabled[id] = true
|
|
}
|
|
mods := reg.All()
|
|
choices := make([]ModuleChoice, 0, len(mods))
|
|
for _, m := range mods {
|
|
meta := m.Meta()
|
|
choices = append(choices, ModuleChoice{
|
|
ID: meta.ID,
|
|
Name: meta.Name,
|
|
Description: meta.Description,
|
|
Selected: !disabled[meta.ID],
|
|
})
|
|
}
|
|
sort.Slice(choices, func(i, j int) bool { return choices[i].ID < choices[j].ID })
|
|
|
|
theme := BuiltinThemes[0].ID
|
|
if override, ok := cfg.ThemeOverrides["__active"]; ok && override != "" {
|
|
theme = override
|
|
}
|
|
|
|
return WizardState{
|
|
ThemeID: theme,
|
|
Modules: choices,
|
|
Parallelism: 1,
|
|
}
|
|
}
|
|
|
|
// SelectedIDs returns only the checked module IDs.
|
|
func (s WizardState) SelectedIDs() []string {
|
|
out := make([]string, 0, len(s.Modules))
|
|
for _, m := range s.Modules {
|
|
if m.Selected {
|
|
out = append(out, m.ID)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write `internal/tui/state_test.go`**
|
|
|
|
```go
|
|
package tui
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/config"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
|
)
|
|
|
|
type fakeModule struct{ id, name, desc string }
|
|
|
|
func (f fakeModule) Meta() module.Meta {
|
|
return module.Meta{ID: f.id, Name: f.name, Description: f.desc}
|
|
}
|
|
func (f fakeModule) Detect(context.Context) (module.State, error) { return nil, nil }
|
|
func (f fakeModule) Plan(context.Context, module.State) (module.Plan, error) {
|
|
return module.Plan{}, nil
|
|
}
|
|
func (f fakeModule) Apply(context.Context, module.Plan, module.Reporter) error { return nil }
|
|
func (f fakeModule) Verify(context.Context) error { return nil }
|
|
|
|
func TestNewWizardStatePreTicksAllByDefault(t *testing.T) {
|
|
reg := module.NewRegistry()
|
|
_ = reg.Register(fakeModule{id: "a", name: "Alpha"})
|
|
_ = reg.Register(fakeModule{id: "b", name: "Bravo"})
|
|
|
|
s := NewWizardState(config.Config{}, reg)
|
|
if len(s.Modules) != 2 {
|
|
t.Fatalf("modules = %d", len(s.Modules))
|
|
}
|
|
for _, m := range s.Modules {
|
|
if !m.Selected {
|
|
t.Errorf("%s not selected", m.ID)
|
|
}
|
|
}
|
|
if s.ThemeID != BuiltinThemes[0].ID {
|
|
t.Errorf("theme = %q", s.ThemeID)
|
|
}
|
|
}
|
|
|
|
func TestNewWizardStateRespectsDisabled(t *testing.T) {
|
|
reg := module.NewRegistry()
|
|
_ = reg.Register(fakeModule{id: "a"})
|
|
_ = reg.Register(fakeModule{id: "b"})
|
|
|
|
s := NewWizardState(config.Config{DisabledModules: []string{"a"}}, reg)
|
|
for _, m := range s.Modules {
|
|
switch m.ID {
|
|
case "a":
|
|
if m.Selected {
|
|
t.Error("a should be unticked")
|
|
}
|
|
case "b":
|
|
if !m.Selected {
|
|
t.Error("b should be ticked")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSelectedIDsOnlyReturnsChecked(t *testing.T) {
|
|
s := WizardState{Modules: []ModuleChoice{
|
|
{ID: "a", Selected: true},
|
|
{ID: "b", Selected: false},
|
|
{ID: "c", Selected: true},
|
|
}}
|
|
got := s.SelectedIDs()
|
|
if len(got) != 2 || got[0] != "a" || got[1] != "c" {
|
|
t.Fatalf("got %v", got)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
```bash
|
|
go test ./internal/tui/...
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add internal/tui/state.go internal/tui/state_test.go
|
|
git commit -m "feat(tui): add shared WizardState seeded from config and registry"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: `TeaReporter` — engine events into a Bubble Tea program
|
|
|
|
**Goal:** A `module.Reporter` implementation that forwards events as `tea.Msg` values using `tea.Program.Send`. Plus a matching buffered variant that decouples engine goroutines from the tea program (so tests don't need a real program).
|
|
|
|
**Files:**
|
|
- Create: `internal/tui/reporter.go`
|
|
- Test: `internal/tui/reporter_test.go`
|
|
|
|
- [ ] **Step 1: Write `internal/tui/reporter.go`**
|
|
|
|
```go
|
|
package tui
|
|
|
|
import (
|
|
"sync"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
|
)
|
|
|
|
// ProgressMsg is emitted when a module reports progress during Apply.
|
|
type ProgressMsg struct {
|
|
ModuleID string
|
|
Current int
|
|
Total int
|
|
Message string
|
|
}
|
|
|
|
// LogMsg is emitted when a module emits a log line during Apply.
|
|
type LogMsg struct {
|
|
ModuleID string
|
|
Line string
|
|
}
|
|
|
|
// sender abstracts tea.Program.Send so tests can inject a recorder.
|
|
type sender interface {
|
|
Send(msg tea.Msg)
|
|
}
|
|
|
|
// TeaReporter implements module.Reporter and forwards every event to a
|
|
// sender (typically a *tea.Program). Concurrency-safe.
|
|
type TeaReporter struct {
|
|
mu sync.Mutex
|
|
s sender
|
|
}
|
|
|
|
// NewTeaReporter wraps a sender. Passing nil is allowed — events are dropped.
|
|
func NewTeaReporter(s sender) *TeaReporter {
|
|
return &TeaReporter{s: s}
|
|
}
|
|
|
|
func (r *TeaReporter) Progress(id string, current, total int, msg string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.s == nil {
|
|
return
|
|
}
|
|
r.s.Send(ProgressMsg{ModuleID: id, Current: current, Total: total, Message: msg})
|
|
}
|
|
|
|
func (r *TeaReporter) Log(id, line string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if r.s == nil {
|
|
return
|
|
}
|
|
r.s.Send(LogMsg{ModuleID: id, Line: line})
|
|
}
|
|
|
|
var _ module.Reporter = (*TeaReporter)(nil)
|
|
```
|
|
|
|
- [ ] **Step 2: Write `internal/tui/reporter_test.go`**
|
|
|
|
```go
|
|
package tui
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
type recorder struct {
|
|
mu sync.Mutex
|
|
msgs []tea.Msg
|
|
}
|
|
|
|
func (r *recorder) Send(m tea.Msg) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.msgs = append(r.msgs, m)
|
|
}
|
|
|
|
func (r *recorder) get() []tea.Msg {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]tea.Msg, len(r.msgs))
|
|
copy(out, r.msgs)
|
|
return out
|
|
}
|
|
|
|
func TestTeaReporterForwardsProgressAndLog(t *testing.T) {
|
|
rec := &recorder{}
|
|
r := NewTeaReporter(rec)
|
|
r.Progress("mod", 1, 4, "step one")
|
|
r.Log("mod", "hello world")
|
|
|
|
msgs := rec.get()
|
|
if len(msgs) != 2 {
|
|
t.Fatalf("got %d msgs", len(msgs))
|
|
}
|
|
if p, ok := msgs[0].(ProgressMsg); !ok || p.ModuleID != "mod" || p.Current != 1 || p.Total != 4 {
|
|
t.Errorf("msg[0] = %+v", msgs[0])
|
|
}
|
|
if l, ok := msgs[1].(LogMsg); !ok || l.Line != "hello world" {
|
|
t.Errorf("msg[1] = %+v", msgs[1])
|
|
}
|
|
}
|
|
|
|
func TestTeaReporterNilSenderIsSafe(t *testing.T) {
|
|
r := NewTeaReporter(nil)
|
|
r.Progress("x", 1, 1, "y") // must not panic
|
|
r.Log("x", "y")
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
```bash
|
|
go test ./internal/tui/...
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add internal/tui/reporter.go internal/tui/reporter_test.go
|
|
git commit -m "feat(tui): add TeaReporter forwarding engine events to bubbletea"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Huh wizard
|
|
|
|
**Goal:** A `wizard.Run(ctx, WizardState)` entry point that drives a Huh form through six screens and returns the filled-in `WizardState` plus a `confirmed bool`. Plan-preview requires running Detect + Plan against selected modules, so the wizard takes the registry too.
|
|
|
|
**Files:**
|
|
- Create: `internal/tui/wizard/wizard.go`
|
|
- Test: `internal/tui/wizard/wizard_test.go`
|
|
|
|
- [ ] **Step 1: Write `internal/tui/wizard/wizard.go`**
|
|
|
|
```go
|
|
package wizard
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/huh"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
)
|
|
|
|
// Run presents the six-screen wizard. Returns the final state and whether
|
|
// the user confirmed. A context cancellation returns (state, false, ctx.Err()).
|
|
func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tui.WizardState, bool, error) {
|
|
s := initial
|
|
|
|
themeOptions := make([]huh.Option[string], 0, len(tui.BuiltinThemes))
|
|
for _, t := range tui.BuiltinThemes {
|
|
themeOptions = append(themeOptions, huh.NewOption(t.Name, t.ID))
|
|
}
|
|
|
|
moduleOptions := make([]huh.Option[string], 0, len(s.Modules))
|
|
defaults := make([]string, 0, len(s.Modules))
|
|
for _, m := range s.Modules {
|
|
label := m.Name
|
|
if m.Description != "" {
|
|
label = fmt.Sprintf("%s — %s", m.Name, m.Description)
|
|
}
|
|
moduleOptions = append(moduleOptions, huh.NewOption(label, m.ID))
|
|
if m.Selected {
|
|
defaults = append(defaults, m.ID)
|
|
}
|
|
}
|
|
|
|
var (
|
|
selectedModules = defaults
|
|
planPreview = "(plan will be computed after module selection)"
|
|
proceed bool
|
|
)
|
|
|
|
form := huh.NewForm(
|
|
huh.NewGroup(huh.NewNote().
|
|
Title("Archduino Installer").
|
|
Description("A TUI Arduino development environment.\n\nPress Enter to continue.")),
|
|
|
|
huh.NewGroup(huh.NewSelect[string]().
|
|
Title("Pick a colorscheme").
|
|
Options(themeOptions...).
|
|
Value(&s.ThemeID)),
|
|
|
|
huh.NewGroup(huh.NewMultiSelect[string]().
|
|
Title("Select modules to install").
|
|
Options(moduleOptions...).
|
|
Value(&selectedModules)),
|
|
|
|
huh.NewGroup(
|
|
huh.NewInput().
|
|
Title("Parallelism (advanced)").
|
|
Description("How many modules to run concurrently. 1 is safe.").
|
|
Value(inputString(&s.Parallelism)),
|
|
huh.NewConfirm().
|
|
Title("Halt on first error?").
|
|
Value(&s.HaltOnError),
|
|
),
|
|
|
|
huh.NewGroup(huh.NewNote().
|
|
Title("Review plan").
|
|
DescriptionFunc(func() string {
|
|
s.Modules = syncSelected(s.Modules, selectedModules)
|
|
planPreview = renderPlan(ctx, reg, s.SelectedIDs())
|
|
return planPreview
|
|
}, &selectedModules)),
|
|
|
|
huh.NewGroup(huh.NewConfirm().
|
|
Title("Proceed with installation?").
|
|
Value(&proceed)),
|
|
)
|
|
|
|
if err := form.RunWithContext(ctx); err != nil {
|
|
if errors.Is(err, context.Canceled) || errors.Is(err, huh.ErrUserAborted) {
|
|
return s, false, nil
|
|
}
|
|
return s, false, err
|
|
}
|
|
|
|
s.Modules = syncSelected(s.Modules, selectedModules)
|
|
s.Confirmed = proceed
|
|
return s, proceed, nil
|
|
}
|
|
|
|
// syncSelected returns a copy of mods with Selected set to whether the ID
|
|
// appears in selected.
|
|
func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice {
|
|
set := map[string]bool{}
|
|
for _, id := range selected {
|
|
set[id] = true
|
|
}
|
|
out := make([]tui.ModuleChoice, len(mods))
|
|
copy(out, mods)
|
|
for i := range out {
|
|
out[i].Selected = set[out[i].ID]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// renderPlan runs Detect + Plan for every selected module and renders a
|
|
// human-readable summary. A failing Detect/Plan is shown inline but does
|
|
// not abort the wizard.
|
|
func renderPlan(ctx context.Context, reg *module.Registry, selectedIDs []string) string {
|
|
if len(selectedIDs) == 0 {
|
|
return "No modules selected."
|
|
}
|
|
var b strings.Builder
|
|
for _, id := range selectedIDs {
|
|
m, ok := reg.Get(id)
|
|
if !ok {
|
|
fmt.Fprintf(&b, "• %s — (unknown)\n", id)
|
|
continue
|
|
}
|
|
state, err := m.Detect(ctx)
|
|
if err != nil {
|
|
fmt.Fprintf(&b, "• %s — detect failed: %v\n", id, err)
|
|
continue
|
|
}
|
|
plan, err := m.Plan(ctx, state)
|
|
if err != nil {
|
|
fmt.Fprintf(&b, "• %s — plan failed: %v\n", id, err)
|
|
continue
|
|
}
|
|
if plan.IsEmpty() {
|
|
fmt.Fprintf(&b, "• %s — already up to date\n", id)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "• %s\n", id)
|
|
for _, a := range plan.Actions {
|
|
fmt.Fprintf(&b, " - %s\n", a.Description)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// inputString adapts an *int to huh.NewInput's *string. Non-numeric input
|
|
// is silently clamped to the existing value.
|
|
func inputString(p *int) *string {
|
|
s := fmt.Sprintf("%d", *p)
|
|
// NB: the pointer returned here is consumed by huh; we do NOT round-trip
|
|
// parsing on every keystroke. Conversion happens after the form exits
|
|
// via a ValidateFunc — see the dedicated test for this behavior.
|
|
_ = engine.Summary{} // keep engine import tethered for the plan preview above
|
|
return &s
|
|
}
|
|
```
|
|
|
|
NOTE: the `_ = engine.Summary{}` pinning line is needed only if `engine` is otherwise unused in non-test builds — `renderPlan` already imports `engine` transitively via the registry, so remove that line if `go vet`/`goimports` flags it. The intent is "engine package used for plan preview"; the real usage is `m.Detect`/`m.Plan`, which compile fine without the `engine` import since `module.Plan` is in the `module` package. In practice: delete the `engine` import and the pinning line if the compiler says `engine` is unused.
|
|
|
|
- [ ] **Step 2: Write `internal/tui/wizard/wizard_test.go`**
|
|
|
|
Testing Huh forms end-to-end requires stdin simulation and is flaky. Instead, test the pure helpers (`syncSelected`, `renderPlan`) and ensure `Run` does not panic with a cancelled context.
|
|
|
|
```go
|
|
package wizard
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/hello"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
)
|
|
|
|
func TestSyncSelected(t *testing.T) {
|
|
orig := []tui.ModuleChoice{
|
|
{ID: "a", Selected: false},
|
|
{ID: "b", Selected: true},
|
|
{ID: "c", Selected: true},
|
|
}
|
|
got := syncSelected(orig, []string{"a", "c"})
|
|
want := map[string]bool{"a": true, "b": false, "c": true}
|
|
for _, m := range got {
|
|
if m.Selected != want[m.ID] {
|
|
t.Errorf("%s selected=%v, want %v", m.ID, m.Selected, want[m.ID])
|
|
}
|
|
}
|
|
// Original should be untouched.
|
|
if orig[0].Selected {
|
|
t.Error("orig mutated")
|
|
}
|
|
}
|
|
|
|
func TestRenderPlanWithHello(t *testing.T) {
|
|
reg := module.NewRegistry()
|
|
_ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"})
|
|
out := renderPlan(context.Background(), reg, []string{"hello"})
|
|
if !strings.Contains(out, "hello") {
|
|
t.Fatalf("output missing hello: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestRenderPlanEmpty(t *testing.T) {
|
|
if got := renderPlan(context.Background(), module.NewRegistry(), nil); got != "No modules selected." {
|
|
t.Fatalf("got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
_, confirmed, err := Run(ctx, module.NewRegistry(), tui.WizardState{})
|
|
if err != nil && err != context.Canceled {
|
|
t.Fatalf("unexpected err: %v", err)
|
|
}
|
|
if confirmed {
|
|
t.Fatal("confirmed=true on cancelled context")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
```bash
|
|
go test ./internal/tui/wizard/...
|
|
```
|
|
|
|
Expected: PASS (some tests may be skipped if huh requires a TTY; that's OK — the cancelled-context test handles that).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add internal/tui/wizard/
|
|
git commit -m "feat(tui): add six-screen Huh wizard for installer input"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Bubble Tea dashboard (Model)
|
|
|
|
**Goal:** A Bubble Tea `tea.Model` that owns a slice of module rows, consumes `ProgressMsg` / `LogMsg` / `doneMsg`, and renders a status block plus an expandable log tail. No engine wiring yet — that's Task 6.
|
|
|
|
**Files:**
|
|
- Create: `internal/tui/dashboard/model.go`
|
|
- Test: `internal/tui/dashboard/model_test.go`
|
|
|
|
- [ ] **Step 1: Write `internal/tui/dashboard/model.go`**
|
|
|
|
```go
|
|
package dashboard
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
)
|
|
|
|
// DoneMsg is sent from the engine goroutine once engine.Run returns.
|
|
type DoneMsg struct {
|
|
Summary engine.Summary
|
|
Err error
|
|
}
|
|
|
|
// rowState tracks per-module progress in the dashboard.
|
|
type rowState struct {
|
|
ID string
|
|
Name string
|
|
Current int
|
|
Total int
|
|
LastMsg string
|
|
LogTail []string
|
|
StatusIcon string // ·, ⠋ (running), ✓, ✗, ⊘
|
|
Done bool
|
|
Started time.Time
|
|
Ended time.Time
|
|
Err error
|
|
}
|
|
|
|
// Model is the Bubble Tea model for the Apply-phase dashboard.
|
|
type Model struct {
|
|
rows []*rowState
|
|
rowIndex map[string]*rowState
|
|
cursor int
|
|
showLogs bool
|
|
summary *engine.Summary
|
|
runErr error
|
|
quitting bool
|
|
}
|
|
|
|
// NewModel seeds the dashboard from the list of module IDs selected to run.
|
|
// Rows start in the "pending" state and flip to running on first ProgressMsg.
|
|
func NewModel(selected []string, nameByID map[string]string) *Model {
|
|
m := &Model{rowIndex: make(map[string]*rowState, len(selected))}
|
|
for _, id := range selected {
|
|
name := id
|
|
if n, ok := nameByID[id]; ok {
|
|
name = n
|
|
}
|
|
r := &rowState{ID: id, Name: name, StatusIcon: "·"}
|
|
m.rows = append(m.rows, r)
|
|
m.rowIndex[id] = r
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m *Model) Init() tea.Cmd { return nil }
|
|
|
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tui.ProgressMsg:
|
|
if r, ok := m.rowIndex[msg.ModuleID]; ok {
|
|
if r.Started.IsZero() {
|
|
r.Started = time.Now()
|
|
}
|
|
r.Current = msg.Current
|
|
r.Total = msg.Total
|
|
r.LastMsg = msg.Message
|
|
r.StatusIcon = "⠋"
|
|
}
|
|
case tui.LogMsg:
|
|
if r, ok := m.rowIndex[msg.ModuleID]; ok {
|
|
r.LogTail = appendCapped(r.LogTail, msg.Line, 200)
|
|
}
|
|
case DoneMsg:
|
|
m.summary = &msg.Summary
|
|
m.runErr = msg.Err
|
|
// Mark each row from the summary.
|
|
for _, res := range msg.Summary.Results {
|
|
r, ok := m.rowIndex[res.ID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
r.Done = true
|
|
r.Ended = time.Now()
|
|
switch res.Status {
|
|
case engine.StatusSucceeded, engine.StatusPlanned:
|
|
r.StatusIcon = "✓"
|
|
case engine.StatusFailed:
|
|
r.StatusIcon = "✗"
|
|
r.Err = res.Err
|
|
case engine.StatusSkipped:
|
|
r.StatusIcon = "⊘"
|
|
}
|
|
}
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "q", "ctrl+c":
|
|
m.quitting = true
|
|
return m, tea.Quit
|
|
case "up", "k":
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
}
|
|
case "down", "j":
|
|
if m.cursor < len(m.rows)-1 {
|
|
m.cursor++
|
|
}
|
|
case "enter":
|
|
m.showLogs = !m.showLogs
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Model) View() string {
|
|
var b strings.Builder
|
|
header := lipgloss.NewStyle().Bold(true).Render("archduino install")
|
|
b.WriteString(header + "\n")
|
|
|
|
for i, r := range m.rows {
|
|
cursor := " "
|
|
if i == m.cursor {
|
|
cursor = "▸ "
|
|
}
|
|
progress := ""
|
|
if r.Total > 0 {
|
|
progress = fmt.Sprintf("%d/%d", r.Current, r.Total)
|
|
}
|
|
detail := r.LastMsg
|
|
if r.Err != nil {
|
|
detail = "failed — " + r.Err.Error()
|
|
}
|
|
fmt.Fprintf(&b, "%s%s %-16s %-6s %s\n", cursor, r.StatusIcon, r.ID, progress, detail)
|
|
}
|
|
|
|
if m.showLogs && len(m.rows) > 0 {
|
|
active := m.rows[m.cursor]
|
|
b.WriteString("\n─ log tail ─\n")
|
|
for _, line := range active.LogTail {
|
|
b.WriteString(" " + line + "\n")
|
|
}
|
|
}
|
|
|
|
if m.summary != nil {
|
|
ok, fail, skip := m.summary.Counts()
|
|
fmt.Fprintf(&b, "\nsummary: %d succeeded, %d failed, %d skipped\n", ok, fail, skip)
|
|
if m.runErr != nil {
|
|
fmt.Fprintf(&b, "error: %v\n", m.runErr)
|
|
}
|
|
b.WriteString("\n[q] quit\n")
|
|
} else {
|
|
b.WriteString("\n[↑/↓] navigate [enter] toggle log [q] abort\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// Summary returns the engine summary once the run has completed, or nil.
|
|
func (m *Model) Summary() *engine.Summary { return m.summary }
|
|
|
|
// RunErr returns the engine's top-level error, if any.
|
|
func (m *Model) RunErr() error { return m.runErr }
|
|
|
|
func appendCapped(s []string, line string, max int) []string {
|
|
s = append(s, line)
|
|
if len(s) > max {
|
|
s = s[len(s)-max:]
|
|
}
|
|
return s
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write `internal/tui/dashboard/model_test.go`**
|
|
|
|
```go
|
|
package dashboard
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
)
|
|
|
|
func TestProgressUpdatesRow(t *testing.T) {
|
|
m := NewModel([]string{"a"}, map[string]string{"a": "Alpha"})
|
|
m.Update(tui.ProgressMsg{ModuleID: "a", Current: 3, Total: 10, Message: "doing"})
|
|
if r := m.rowIndex["a"]; r.Current != 3 || r.Total != 10 || r.LastMsg != "doing" {
|
|
t.Fatalf("row state wrong: %+v", r)
|
|
}
|
|
}
|
|
|
|
func TestLogAppendedBounded(t *testing.T) {
|
|
m := NewModel([]string{"a"}, nil)
|
|
for i := 0; i < 300; i++ {
|
|
m.Update(tui.LogMsg{ModuleID: "a", Line: "x"})
|
|
}
|
|
if got := len(m.rowIndex["a"].LogTail); got != 200 {
|
|
t.Fatalf("log tail = %d, want 200", got)
|
|
}
|
|
}
|
|
|
|
func TestDoneMsgFlipsIcons(t *testing.T) {
|
|
m := NewModel([]string{"ok", "bad", "skip"}, nil)
|
|
m.Update(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{
|
|
{ID: "ok", Status: engine.StatusSucceeded},
|
|
{ID: "bad", Status: engine.StatusFailed, Err: errors.New("boom")},
|
|
{ID: "skip", Status: engine.StatusSkipped},
|
|
}}})
|
|
if m.rowIndex["ok"].StatusIcon != "✓" {
|
|
t.Errorf("ok icon = %q", m.rowIndex["ok"].StatusIcon)
|
|
}
|
|
if m.rowIndex["bad"].StatusIcon != "✗" {
|
|
t.Errorf("bad icon = %q", m.rowIndex["bad"].StatusIcon)
|
|
}
|
|
if m.rowIndex["skip"].StatusIcon != "⊘" {
|
|
t.Errorf("skip icon = %q", m.rowIndex["skip"].StatusIcon)
|
|
}
|
|
}
|
|
|
|
func TestViewMentionsModules(t *testing.T) {
|
|
m := NewModel([]string{"alpha", "bravo"}, nil)
|
|
out := m.View()
|
|
for _, want := range []string{"alpha", "bravo", "archduino install"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("view missing %q:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
```bash
|
|
go test ./internal/tui/dashboard/...
|
|
```
|
|
|
|
Expected: PASS (4 tests).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add internal/tui/dashboard/model.go internal/tui/dashboard/model_test.go
|
|
git commit -m "feat(tui): add Bubble Tea dashboard model for Apply phase"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Dashboard runner — bridge engine.Run into the tea.Program
|
|
|
|
**Goal:** `dashboard.Run(ctx, state, reg) (engine.Summary, error)` that:
|
|
1. Builds a `*Model` from selected IDs.
|
|
2. Creates a `*tea.Program` with that model.
|
|
3. Launches `engine.Run` in a goroutine, using a `TeaReporter` bound to the program.
|
|
4. Sends a `DoneMsg` when engine.Run returns.
|
|
5. Blocks on `p.Run()` and returns the collected summary.
|
|
|
|
**Files:**
|
|
- Create: `internal/tui/dashboard/run.go`
|
|
- Test: `internal/tui/dashboard/run_test.go`
|
|
|
|
- [ ] **Step 1: Write `internal/tui/dashboard/run.go`**
|
|
|
|
```go
|
|
package dashboard
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
)
|
|
|
|
// Run displays the dashboard while engine.Run executes the selected modules.
|
|
// Returns the engine summary (for writeState) and the top-level error, if any.
|
|
func Run(ctx context.Context, reg *module.Registry, state tui.WizardState, halt bool) (engine.Summary, error) {
|
|
selected := state.SelectedIDs()
|
|
nameByID := make(map[string]string, len(state.Modules))
|
|
for _, m := range state.Modules {
|
|
nameByID[m.ID] = m.Name
|
|
}
|
|
|
|
m := NewModel(selected, nameByID)
|
|
p := tea.NewProgram(m, tea.WithContext(ctx))
|
|
|
|
reporter := tui.NewTeaReporter(p)
|
|
|
|
go func() {
|
|
summary, err := engine.Run(ctx, reg, engine.RunOptions{
|
|
Selected: selected,
|
|
HaltOnError: halt,
|
|
Reporter: reporter,
|
|
})
|
|
p.Send(DoneMsg{Summary: summary, Err: err})
|
|
}()
|
|
|
|
if _, err := p.Run(); err != nil {
|
|
return engine.Summary{}, fmt.Errorf("tui run: %w", err)
|
|
}
|
|
|
|
var sum engine.Summary
|
|
if m.Summary() != nil {
|
|
sum = *m.Summary()
|
|
}
|
|
return sum, m.RunErr()
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write `internal/tui/dashboard/run_test.go`**
|
|
|
|
Testing a live `tea.Program` requires a TTY. Use `teatest` from the Charm x/exp package.
|
|
|
|
```go
|
|
package dashboard
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/x/exp/teatest"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
)
|
|
|
|
// TestModelEndToEnd simulates an engine run by feeding messages into the
|
|
// model via teatest and checking that the final view shows success.
|
|
func TestModelEndToEnd(t *testing.T) {
|
|
m := NewModel([]string{"alpha"}, map[string]string{"alpha": "Alpha"})
|
|
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
|
|
|
|
tm.Send(tui.ProgressMsg{ModuleID: "alpha", Current: 1, Total: 1, Message: "installing"})
|
|
tm.Send(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{{ID: "alpha", Status: engine.StatusSucceeded}}}})
|
|
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
|
|
|
tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second))
|
|
|
|
out := string(readAll(t, tm.FinalOutput(t)))
|
|
if !contains(out, "alpha") || !contains(out, "summary:") {
|
|
t.Fatalf("unexpected final output:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestRunReturnsSummary checks the summary round-trips when the engine is
|
|
// mocked via a no-op registry.
|
|
func TestRunReturnsSummary(t *testing.T) {
|
|
// Intentionally empty — a smoke test with a real tea.Program would require
|
|
// a pty. The End-to-end model behavior is covered above via teatest; the
|
|
// production Run function is exercised by the integration test in Task 7.
|
|
_ = errors.New("placeholder")
|
|
_ = context.Background()
|
|
}
|
|
```
|
|
|
|
Add a small helper file (keeps the test readable):
|
|
|
|
Add at the bottom of `run_test.go`:
|
|
|
|
```go
|
|
import "io"
|
|
|
|
func readAll(t *testing.T, r io.Reader) []byte {
|
|
t.Helper()
|
|
b, err := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func contains(haystack, needle string) bool {
|
|
return len(needle) == 0 || (len(haystack) >= len(needle) && (haystack == needle || (len(haystack) > 0 && indexOf(haystack, needle) >= 0)))
|
|
}
|
|
|
|
func indexOf(haystack, needle string) int {
|
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
|
if haystack[i:i+len(needle)] == needle {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
```
|
|
|
|
Go's import block cannot appear twice in one file. Consolidate by moving `import "io"` into the original import list at the top. The full, correct `run_test.go` after edits:
|
|
|
|
```go
|
|
package dashboard
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/x/exp/teatest"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
)
|
|
|
|
func TestModelEndToEnd(t *testing.T) {
|
|
m := NewModel([]string{"alpha"}, map[string]string{"alpha": "Alpha"})
|
|
tm := teatest.NewTestModel(t, m, teatest.WithInitialTermSize(80, 24))
|
|
|
|
tm.Send(tui.ProgressMsg{ModuleID: "alpha", Current: 1, Total: 1, Message: "installing"})
|
|
tm.Send(DoneMsg{Summary: engine.Summary{Results: []engine.ModuleResult{{ID: "alpha", Status: engine.StatusSucceeded}}}})
|
|
tm.Send(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}})
|
|
|
|
tm.WaitFinished(t, teatest.WithFinalTimeout(3*time.Second))
|
|
|
|
out := readAll(t, tm.FinalOutput(t))
|
|
if !bytesContains(out, "alpha") || !bytesContains(out, "summary:") {
|
|
t.Fatalf("unexpected final output:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestRunPlaceholder(t *testing.T) {
|
|
_ = errors.New("placeholder")
|
|
_ = context.Background()
|
|
}
|
|
|
|
func readAll(t *testing.T, r io.Reader) []byte {
|
|
t.Helper()
|
|
b, err := io.ReadAll(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func bytesContains(haystack []byte, needle string) bool {
|
|
return len(needle) == 0 || stringsIndex(string(haystack), needle) >= 0
|
|
}
|
|
|
|
func stringsIndex(haystack, needle string) int {
|
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
|
if haystack[i:i+len(needle)] == needle {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
```
|
|
|
|
NOTE: the placeholder test exists because a real end-to-end test of `Run` against a running engine requires a TTY and is better covered by the integration test in Task 7. It's retained as a sentinel so future maintainers remember there's intentionally a test gap here.
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
```bash
|
|
go test ./internal/tui/dashboard/...
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add internal/tui/dashboard/run.go internal/tui/dashboard/run_test.go
|
|
git commit -m "feat(tui): add dashboard.Run bridging engine to Bubble Tea"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Wire wizard + dashboard into the `install` subcommand
|
|
|
|
**Goal:** Replace the `(TUI not yet implemented …)` branch in `cmd/archduino/main.go` with a real flow: when `--no-tui` is *unset*, run `wizard.Run`, then `dashboard.Run`; when `--no-tui` is set, keep today's plain-reporter behaviour. Preserve `--dry-run`, `--only`, `--skip`, `--halt-on-error`.
|
|
|
|
**Files:**
|
|
- Modify: `cmd/archduino/main.go`
|
|
|
|
- [ ] **Step 1: Edit `cmd/archduino/main.go`**
|
|
|
|
Find the block:
|
|
|
|
```go
|
|
reporter := engine.NewPlainReporter(os.Stdout)
|
|
// When the user did NOT opt out of TUI (the default), note that
|
|
// the TUI isn't implemented yet and we're falling back to plain
|
|
// output. Plan 2 wires up Bubble Tea here; at that point this
|
|
// branch becomes the TUI setup and the PlainReporter above moves
|
|
// inside the `noTUI` branch.
|
|
if !noTUI {
|
|
fmt.Fprintln(os.Stdout, "(TUI not yet implemented — using plain output)")
|
|
}
|
|
|
|
started := time.Now().UTC()
|
|
sum, err := engine.Run(ctx, Registry, engine.RunOptions{
|
|
Selected: selected,
|
|
DryRun: dryRun,
|
|
HaltOnError: halt,
|
|
Reporter: reporter,
|
|
})
|
|
ended := time.Now().UTC()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```go
|
|
started := time.Now().UTC()
|
|
var (
|
|
sum engine.Summary
|
|
runErr error
|
|
)
|
|
|
|
if noTUI || dryRun {
|
|
// Plain-output path: preserve the Plan 1 behaviour. Dry-run
|
|
// always uses plain output because the dashboard has nothing
|
|
// to animate when no Apply happens.
|
|
reporter := engine.NewPlainReporter(os.Stdout)
|
|
sum, runErr = engine.Run(ctx, Registry, engine.RunOptions{
|
|
Selected: selected,
|
|
DryRun: dryRun,
|
|
HaltOnError: halt,
|
|
Reporter: reporter,
|
|
})
|
|
} else {
|
|
// TUI path: wizard → dashboard. If the user does not confirm,
|
|
// we exit cleanly with a zero-summary and no error.
|
|
initial := tui.NewWizardState(cfg, Registry)
|
|
// Pre-filter the wizard's module list by --only/--skip so the
|
|
// user can't accidentally widen selection in the TUI beyond
|
|
// what they asked for on the CLI.
|
|
if len(selected) < len(initial.Modules) {
|
|
wanted := map[string]bool{}
|
|
for _, id := range selected {
|
|
wanted[id] = true
|
|
}
|
|
for i := range initial.Modules {
|
|
initial.Modules[i].Selected = wanted[initial.Modules[i].ID]
|
|
}
|
|
}
|
|
|
|
finalState, confirmed, werr := wizard.Run(ctx, Registry, initial)
|
|
if werr != nil {
|
|
return werr
|
|
}
|
|
if !confirmed {
|
|
fmt.Fprintln(os.Stdout, "installation cancelled")
|
|
return nil
|
|
}
|
|
sum, runErr = dashboard.Run(ctx, Registry, finalState, halt)
|
|
}
|
|
|
|
ended := time.Now().UTC()
|
|
if runErr != nil {
|
|
return runErr
|
|
}
|
|
```
|
|
|
|
And add the new imports alongside the existing `internal/...` block:
|
|
|
|
```go
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui/dashboard"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui/wizard"
|
|
```
|
|
|
|
Rename the local var `err` used later in the summary block to `runErr` consistently — or rebind:
|
|
|
|
```go
|
|
err = runErr
|
|
```
|
|
|
|
(Pick whichever fits cleanly; the existing code uses `err` for the engine result and later for the state write. Keeping `err` after the assignment is simpler.) Final structure after the assignment:
|
|
|
|
```go
|
|
ended := time.Now().UTC()
|
|
err = runErr
|
|
if err != nil {
|
|
return err
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Build and smoke test**
|
|
|
|
```bash
|
|
go build ./cmd/archduino
|
|
```
|
|
|
|
Plain path (what Plan 1 had — must still work):
|
|
|
|
```bash
|
|
rm -f ~/.cache/archduino/hello.ok
|
|
./archduino install --no-tui --no-sudo
|
|
test -f ~/.cache/archduino/hello.ok && echo OK
|
|
```
|
|
|
|
Dry-run plain path:
|
|
|
|
```bash
|
|
./archduino install --no-tui --no-sudo --dry-run
|
|
```
|
|
|
|
TUI path (manual — requires a real terminal):
|
|
|
|
```bash
|
|
./archduino install --no-sudo
|
|
# Navigate: Enter, Enter, space to toggle hello, Enter, Enter, Enter on plan, y on confirm.
|
|
# Dashboard should flash "✓ hello", then show summary. Press q to quit.
|
|
```
|
|
|
|
Expected: every path prints a summary and exits 0. If running in a non-TTY (CI, piped), the TUI path exits with a huh/bubbletea error — which is fine; CI uses `--no-tui`.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add cmd/archduino/main.go
|
|
git commit -m "feat(cmd): wire TUI wizard and dashboard into install subcommand"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Integration smoke for the TUI path
|
|
|
|
**Goal:** A small offline test that drives the whole wizard→dashboard pipeline using `teatest` over a scripted wizard state (bypassing the actual Huh form, which needs a TTY). Confirms `dashboard.Run` against the real `hello` module completes, produces a correct summary, and writes a marker file.
|
|
|
|
**Files:**
|
|
- Create: `internal/tui/dashboard/integration_test.go`
|
|
|
|
- [ ] **Step 1: Write `internal/tui/dashboard/integration_test.go`**
|
|
|
|
```go
|
|
package dashboard
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/engine"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/modules/hello"
|
|
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
|
)
|
|
|
|
// TestDashboardRunsHelloModule runs the full dashboard.Run against a registry
|
|
// containing only the hello module and verifies the marker was written.
|
|
//
|
|
// This test does NOT use teatest because the production Run opens a TTY;
|
|
// instead we drive the Model directly with the same message sequence the
|
|
// TeaReporter would produce and the same DoneMsg the goroutine would send.
|
|
func TestDashboardRunsHelloModule(t *testing.T) {
|
|
markerPath := filepath.Join(t.TempDir(), "hello.ok")
|
|
reg := module.NewRegistry()
|
|
if err := reg.Register(hello.Module{Path: markerPath}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
state := tui.WizardState{
|
|
Modules: []tui.ModuleChoice{{ID: "hello", Name: "Hello", Selected: true}},
|
|
}
|
|
|
|
// Build a model and run engine.Run synchronously with a TeaReporter that
|
|
// writes into a buffered recorder (a *recorderSender stands in for the
|
|
// tea.Program's Send). Then deliver each recorded message to the model
|
|
// in order — this exercises every code path except the actual terminal.
|
|
m := NewModel(state.SelectedIDs(), map[string]string{"hello": "Hello"})
|
|
rec := &recorderSender{}
|
|
reporter := tui.NewTeaReporter(rec)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
sum, err := engine.Run(ctx, reg, engine.RunOptions{
|
|
Selected: state.SelectedIDs(),
|
|
Reporter: reporter,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("engine.Run: %v", err)
|
|
}
|
|
|
|
for _, msg := range rec.drained() {
|
|
m.Update(msg)
|
|
}
|
|
m.Update(DoneMsg{Summary: sum})
|
|
|
|
if _, err := os.Stat(markerPath); err != nil {
|
|
t.Fatalf("marker not written: %v", err)
|
|
}
|
|
if m.Summary() == nil {
|
|
t.Fatal("dashboard summary is nil")
|
|
}
|
|
ok, fail, skip := m.Summary().Counts()
|
|
if ok != 1 || fail != 0 || skip != 0 {
|
|
t.Fatalf("counts = (%d,%d,%d)", ok, fail, skip)
|
|
}
|
|
// Render the view once to confirm it doesn't panic after a real run.
|
|
if out := m.View(); out == "" {
|
|
t.Fatal("empty view")
|
|
}
|
|
}
|
|
|
|
// recorderSender is a minimal `sender` that buffers messages for replay.
|
|
// It lives in the dashboard package for lack of a better home — the tui
|
|
// package's own test recorder is unexported there.
|
|
type recorderSender struct {
|
|
msgs []any
|
|
}
|
|
|
|
func (r *recorderSender) Send(msg any) { r.msgs = append(r.msgs, msg) }
|
|
|
|
func (r *recorderSender) drained() []any {
|
|
out := make([]any, len(r.msgs))
|
|
copy(out, r.msgs)
|
|
return out
|
|
}
|
|
```
|
|
|
|
NOTE: The `tui.TeaReporter` sender interface uses `tea.Msg` (which is `any`); the `recorderSender.Send` signature must match. If the compiler complains about signature mismatch, change the test helper to:
|
|
|
|
```go
|
|
func (r *recorderSender) Send(msg tea.Msg) { r.msgs = append(r.msgs, msg) }
|
|
```
|
|
|
|
and add the `tea` import.
|
|
|
|
- [ ] **Step 2: Run tests**
|
|
|
|
```bash
|
|
go test ./internal/tui/dashboard/...
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add internal/tui/dashboard/integration_test.go
|
|
git commit -m "test(tui): integration smoke — dashboard runs hello module end-to-end"
|
|
```
|
|
|
|
---
|
|
|
|
## Wrap-up
|
|
|
|
After Task 8 the repo has:
|
|
|
|
- Working `archduino install` with a full TUI (Huh wizard + Bubble Tea dashboard) on a real terminal.
|
|
- Plain-output fallback (`--no-tui`, `--dry-run`) preserved from Plan 1.
|
|
- `WizardState` passed from input collection to execution.
|
|
- `TeaReporter` bridging the engine's `module.Reporter` contract to tea messages.
|
|
- Unit + integration coverage for the dashboard model; wizard tested via its pure helpers (full Huh forms require a real TTY and are better validated by the manual smoke in Task 7).
|
|
|
|
**Not yet in this plan (still Plan 3/4 work):**
|
|
- Real install modules beyond `hello` (Plan 3).
|
|
- Themes actually affecting rendering (today's theme picker is display-only; Plan 3 wires it up).
|
|
- `theme`/`new`/`doctor` subcommands, `bootstrap.sh`, goreleaser, CI (Plan 4).
|
|
|
|
## Self-review notes
|
|
|
|
- Every task ends with green tests + a commit.
|
|
- No silent regressions to the Plan 1 plain path: `--no-tui` still works identically.
|
|
- `--dry-run` intentionally forces plain output (dashboard would have nothing to show).
|
|
- Wizard's plan preview re-runs Detect+Plan every time the module selection changes, so the user always sees a fresh view.
|
|
- Module path inside all snippets uses `github.com/MDietermann/ArduinoArchSetup` (matches Plan 1 decision).
|
|
- Huh's `huh.ErrUserAborted` on Esc is treated as "user cancelled", not as an error.
|