feat: ejecutar tests desde el dashboard TUI

Se añade opción "Run Tests" al menú del servidor en el dashboard TUI.
Ejecuta `go test -tags goolm -count=1 ./...` y muestra los resultados
en una pantalla dedicada (ScreenTestOutput) con scroll y opción de re-ejecutar.

Cambios:
- pkg/tui: nuevo MsgTestsDone, ScreenTestOutput, IntentRunTests, updateTestOutput
- pkg/tui/view.go: viewTestOutput con scroll y controles (↑↓ r 0)
- shell/tui/adapter.go: runTests() ejecuta go test con el env del manager
- shell/process/manager.go: buildEnv → BuildEnv (exportado) para que el adapter
  pueda construir el env completo con las variables de .env

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 02:21:17 +00:00
parent 828eb175fe
commit b6fe4f9135
6 changed files with 114 additions and 5 deletions
+6
View File
@@ -38,5 +38,11 @@ type MsgRebuildDone struct {
Err error
}
// MsgTestsDone reports the result of running tests.
type MsgTestsDone struct {
Passed bool
Output []string // lines of test output
}
// MsgTick triggers a periodic refresh.
type MsgTick struct{}
+3
View File
@@ -11,6 +11,7 @@ const (
ScreenAgentActions // actions for a selected agent
ScreenLogs // tail log output
ScreenServer // server-wide process management
ScreenTestOutput // test run output
)
// Model is the complete TUI state — pure data.
@@ -74,11 +75,13 @@ func ServerMenuOptions(running bool) []MenuOption {
{Label: "Kill", Desc: "SIGKILL forzado"},
{Label: "Rebuild & Restart", Desc: "Build + reiniciar"},
{Label: "Logs", Desc: "Ver log del launcher"},
{Label: "Run Tests", Desc: "Ejecutar todos los tests"},
}
}
return []MenuOption{
{Label: "Start", Desc: "Iniciar el launcher unificado"},
{Label: "Rebuild & Restart", Desc: "Build + iniciar"},
{Label: "Run Tests", Desc: "Ejecutar todos los tests"},
}
}
+38
View File
@@ -21,6 +21,7 @@ const (
IntentRestartLauncher IntentKind = "restart_launcher"
IntentKillLauncher IntentKind = "kill_launcher"
IntentRebuildRestart IntentKind = "rebuild_restart"
IntentRunTests IntentKind = "run_tests"
)
// Intent is pure data describing a side effect to execute.
@@ -98,6 +99,18 @@ func Update(model Model, msg interface{}) (Model, []Intent) {
model.LogScroll = max(0, len(m.Lines)-visibleLogLines(model))
return model, nil
case MsgTestsDone:
model.Screen = ScreenTestOutput
model.LogLines = m.Output
model.LogScroll = 0
model.Cursor = 0
if m.Passed {
model.StatusMsg = "Tests PASSED"
} else {
model.StatusMsg = "Tests FAILED"
}
return model, nil
case MsgTick:
return model, []Intent{{Kind: IntentLoadAgents}}
@@ -127,6 +140,8 @@ func updateKey(model Model, key KeyMsg) (Model, []Intent) {
return updateLogs(model, key)
case ScreenServer:
return updateServerScreen(model, key)
case ScreenTestOutput:
return updateTestOutput(model, key)
}
return model, nil
}
@@ -283,6 +298,9 @@ func executeServerAction(model Model, action string) (Model, []Intent) {
case "Rebuild & Restart":
model.StatusMsg = "Building & restarting..."
return model, []Intent{{Kind: IntentRebuildRestart}}
case "Run Tests":
model.StatusMsg = "Running tests..."
return model, []Intent{{Kind: IntentRunTests}}
case "Logs":
model.Screen = ScreenLogs
model.LogLines = nil
@@ -294,6 +312,26 @@ func executeServerAction(model Model, action string) (Model, []Intent) {
return model, nil
}
func updateTestOutput(model Model, key KeyMsg) (Model, []Intent) {
switch key.Str {
case "0":
model.Screen = ScreenServer
model.Cursor = 0
model.LogLines = nil
model.LogScroll = 0
model.StatusMsg = ""
case "up", "k":
model.LogScroll = max(0, model.LogScroll-1)
case "down", "j":
maxScroll := max(0, len(model.LogLines)-visibleLogLines(model))
model.LogScroll = min(model.LogScroll+1, maxScroll)
case "r":
model.StatusMsg = "Running tests..."
return model, []Intent{{Kind: IntentRunTests}}
}
return model, nil
}
// ── pure helpers ─────────────────────────────────────────────────────────
func visibleLogLines(m Model) int {
+36
View File
@@ -18,6 +18,8 @@ func View(model Model) string {
return viewLogs(model)
case ScreenServer:
return viewServer(model)
case ScreenTestOutput:
return viewTestOutput(model)
default:
return ""
}
@@ -236,6 +238,40 @@ func viewServer(m Model) string {
return b.String()
}
func viewTestOutput(m Model) string {
var b strings.Builder
b.WriteString("\n Test Results\n")
b.WriteString(" " + strings.Repeat("─", 60) + "\n")
if m.StatusMsg != "" {
b.WriteString(" " + m.StatusMsg + "\n\n")
}
if len(m.LogLines) == 0 {
b.WriteString(" Running tests...\n")
} else {
visible := visibleLogLines(m)
end := m.LogScroll + visible
if end > len(m.LogLines) {
end = len(m.LogLines)
}
start := m.LogScroll
if start >= len(m.LogLines) {
start = max(0, len(m.LogLines)-1)
}
for _, line := range m.LogLines[start:end] {
if len(line) > m.WindowWidth-4 && m.WindowWidth > 10 {
line = line[:m.WindowWidth-7] + "..."
}
b.WriteString(" " + line + "\n")
}
}
b.WriteString("\n ↑↓ scroll r re-ejecutar 0 volver\n")
return b.String()
}
func countStatuses(agents []AgentView) (running, stopped, disabled int) {
for _, a := range agents {
switch {
+5 -5
View File
@@ -174,7 +174,7 @@ func (m *Manager) Start(info AgentInfo) error {
cmd = exec.Command(bin, "-c", info.ConfigPath)
}
cmd.Env = m.buildEnv()
cmd.Env = m.BuildEnv()
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
@@ -385,7 +385,7 @@ func (m *Manager) LogPath(id string) string { return m.logPath(id) }
// Returns the combined output and any error.
func (m *Manager) Build() (string, error) {
cmd := exec.Command("bash", "build.sh")
cmd.Env = m.buildEnv()
cmd.Env = m.BuildEnv()
out, err := cmd.CombinedOutput()
return string(out), err
}
@@ -416,7 +416,7 @@ func (m *Manager) StartUnified() error {
cmd = exec.Command(bin, "--log-level", "info")
}
cmd.Env = m.buildEnv()
cmd.Env = m.BuildEnv()
cmd.Stdout = logFile
cmd.Stderr = logFile
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
@@ -654,8 +654,8 @@ func (m *Manager) removePID(id string) {
_ = os.Remove(m.pidPath(id))
}
// buildEnv returns the environment for child processes: current env + .env file vars.
func (m *Manager) buildEnv() []string {
// BuildEnv returns the environment for child processes: current env + .env file vars.
func (m *Manager) BuildEnv() []string {
env := os.Environ()
if m.envFile == "" {
return env
+26
View File
@@ -4,6 +4,7 @@ package tui
import (
"fmt"
"os/exec"
"strings"
"time"
@@ -54,6 +55,9 @@ func (a *Adapter) RunIntent(intent puretui.Intent) tea.Cmd {
case puretui.IntentRebuildRestart:
return a.rebuildRestart()
case puretui.IntentRunTests:
return a.runTests()
case puretui.IntentTick:
return a.tick()
@@ -217,6 +221,28 @@ func (a *Adapter) loadLogs(id string) tea.Cmd {
}
}
func (a *Adapter) runTests() tea.Cmd {
return func() tea.Msg {
goBin, err := exec.LookPath("go")
if err != nil {
// Fallback to known location
goBin = "/usr/local/go/bin/go"
}
cmd := exec.Command(goBin, "test", "-tags", "goolm", "-count=1", "./...")
cmd.Env = a.mgr.BuildEnv()
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if output == "" && err != nil {
output = "Error: " + err.Error()
}
lines := strings.Split(output, "\n")
passed := err == nil
return puretui.MsgTestsDone{Passed: passed, Output: lines}
}
}
func (a *Adapter) tick() tea.Cmd {
return tea.Tick(3*time.Second, func(time.Time) tea.Msg {
return puretui.MsgTick{}