merge: issue/0023-dashboard-tests — seccion de tests en dashboard TUI

Nueva pantalla Tests en el menu principal del dashboard para ejecutar
Go tests, E2E tests (headless/headed) y todos secuencialmente.
Reemplaza el "Run Tests" del menu Server por navegacion a la nueva pantalla.
This commit is contained in:
2026-03-08 15:44:53 +00:00
8 changed files with 726 additions and 15 deletions
+1
View File
@@ -30,3 +30,4 @@ afectados y notas de implementacion.
| 22a | E2E: Infraestructura base | [0022a-e2e-infra.md](completed/0022a-e2e-infra.md) | completado |
| 22b | E2E: Auth fixtures y helpers | [0022b-e2e-auth-helpers.md](completed/0022b-e2e-auth-helpers.md) | completado |
| 22c | E2E: Tests de agentes + docs | [0022c-e2e-agent-tests.md](completed/0022c-e2e-agent-tests.md) | completado |
| 23 | Seccion de tests en dashboard | [0023-dashboard-tests.md](completed/0023-dashboard-tests.md) | completado |
@@ -0,0 +1,132 @@
# 0023 — Seccion de tests en el dashboard
## Objetivo
Añadir una opcion "Tests" al menu principal del dashboard TUI que permita ejecutar tests de Go (`go test`) y tests E2E (Playwright) de forma independiente, con salida en tiempo real y resumen de resultados.
## Contexto
- El dashboard actual (`cmd/dashboard/`) tiene un "Run Tests" en el menu Server que solo ejecuta `go test -tags goolm ./...`
- Los tests E2E existen en `e2e/` y se ejecutan con `./dev-scripts/e2e/run.sh`
- No hay forma de ejecutar E2E desde el dashboard ni de elegir que tipo de tests correr
- El dashboard sigue el patron pure core (`pkg/tui/`) + impure shell (`shell/tui/adapter.go`)
## Arquitectura
```
pkg/tui/model.go — nuevo ScreenTests, TestKind, campos de estado
pkg/tui/update.go — logica pura para pantalla Tests (navegacion, seleccion)
pkg/tui/view.go — render de la pantalla Tests (menu + output)
pkg/tui/messages.go — nuevos mensajes: MsgTestsRunning, MsgTestOutput (streaming)
shell/tui/adapter.go — nuevos intents: IntentRunGoTests, IntentRunE2ETests, IntentRunAllTests
```
### Patron pure core / impure shell
- `pkg/tui/` — tipos de pantalla, opciones de menu, logica de navegacion, formateo de output. Todo puro.
- `shell/tui/` — ejecucion real de `go test` y `./dev-scripts/e2e/run.sh`. Impuro.
- No se necesitan cambios en `agents/`, `tools/`, ni `shell/` fuera de `shell/tui/`.
## Tareas
### Fase 1: Menu principal — nueva opcion "Tests"
- [ ] **1.1** Añadir `ScreenTests` al enum de screens en `pkg/tui/model.go`
- [ ] **1.2** Añadir opcion "Tests" al `MainMenuOptions()` (entre "Server" y "Quit")
- [ ] **1.3** Manejar seleccion de "Tests" en `updateMainScreen` — navegar a `ScreenTests`
### Fase 2: Pantalla de tests — menu de seleccion
- [ ] **2.1** Crear `TestMenuOptions()` en `model.go` con las opciones:
- "Go Tests" — `go test -tags goolm -count=1 ./...`
- "E2E Tests" — `./dev-scripts/e2e/run.sh`
- "E2E Tests (headed)" — `./dev-scripts/e2e/run.sh --headed`
- "All Tests" — Go tests + E2E secuencial
- [ ] **2.2** Crear `updateTestsScreen` en `update.go` — navegacion y seleccion de tipo de test
- [ ] **2.3** Crear `viewTests` en `view.go` — menu con las opciones y ultimo resultado (PASSED/FAILED/no ejecutado)
### Fase 3: Ejecucion y output
- [ ] **3.1** Añadir intents nuevos: `IntentRunGoTests`, `IntentRunE2ETests`, `IntentRunAllTests`
- [ ] **3.2** Refactorizar el `runTests()` actual del adapter para que sea `runGoTests()`, reutilizable
- [ ] **3.3** Implementar `runE2ETests(headed bool)` en el adapter — ejecuta `./dev-scripts/e2e/run.sh [--headed]`
- [ ] **3.4** Implementar `runAllTests()` — ejecuta Go tests primero, luego E2E, combina output
- [ ] **3.5** Reutilizar `ScreenTestOutput` existente para mostrar resultados (ya tiene scroll y re-run)
- [ ] **3.6** Adaptar `updateTestOutput` para que "r" re-ejecute el mismo tipo de test (no siempre Go)
### Fase 4: Estado y UX
- [ ] **4.1** Añadir campo `LastTestKind` al Model para saber que re-ejecutar con "r"
- [ ] **4.2** Mostrar indicador "Running..." mientras se ejecutan los tests
- [ ] **4.3** El boton "0" desde test output vuelve a `ScreenTests` (no a Server)
### Fase 5: Limpiar intent antiguo
- [ ] **5.1** Eliminar `IntentRunTests` del menu Server y reemplazar por navegacion a `ScreenTests`
- [ ] **5.2** Mantener retrocompatibilidad: "Run Tests" en Server menu ahora navega a la pantalla Tests
### Fase 6: Tests
- [ ] **6.1** Tests unitarios para `TestMenuOptions()` — verifica opciones correctas
- [ ] **6.2** Tests unitarios para `updateTestsScreen` — navegacion, seleccion, generacion de intents
- [ ] **6.3** Tests unitarios para `viewTests` — render correcto con distintos estados
- [ ] **6.4** Verificar que `go build -tags goolm ./...` compila
### Fase 7: Cleanup
- [ ] **7.1** Actualizar seccion del dashboard en `CLAUDE.md` si es necesario
---
## Ejemplo de uso
```
Bot Server Dashboard
────────────────────────────────────
2 agents (2 running, 0 stopped, 0 disabled)
Agents Gestionar agentes
Server Gestionar launcher unificado
> Tests Ejecutar tests
Quit Salir
[enter]
Tests
────────────────────────────────────
> Go Tests go test ./...
E2E Tests Playwright headless
E2E Tests (headed) Playwright con browser
All Tests Go + E2E secuencial
Last run: Go Tests — PASSED
↑↓ navegar enter ejecutar 0 volver
[enter en "E2E Tests"]
Test Results — E2E Tests
────────────────────────────────────────────────────────
Running tests...
(output va apareciendo)
↑↓ scroll r re-ejecutar 0 volver
```
## Decisiones de diseno
- **Menu separado en vez de submenu de Server**: los tests son una actividad frecuente e independiente del estado del servidor. Merecen acceso directo desde el menu principal.
- **Reutilizar ScreenTestOutput**: ya existe toda la logica de scroll, re-run y visualizacion. Solo hay que parametrizar el tipo de test.
- **E2E headed como opcion separada**: util para debugging, pero no es el caso comun. Opcion explicita evita flags ocultos.
- **"All Tests" secuencial**: Go tests son rapidos, E2E lentos. Ejecutar Go primero permite fail-fast.
## Prerequisitos
- Dashboard funcional (ya existe)
- E2E tests configurados (`e2e/.env` con credenciales) — si no estan configurados, el E2E fallara con mensaje claro
## Riesgos
- **E2E sin configurar**: si `e2e/.env` no existe, el script fallara. Mitigacion: capturar el error y mostrar mensaje descriptivo en el output ("E2E not configured — run ./dev-scripts/e2e/install.sh").
- **E2E headed sin display**: en servidores sin X/Wayland, `--headed` fallara. Mitigacion: el error de Playwright es claro, se muestra en el output.
+1
View File
@@ -40,6 +40,7 @@ type MsgRebuildDone struct {
// MsgTestsDone reports the result of running tests.
type MsgTestsDone struct {
Kind TestKind // which test suite was executed
Passed bool
Output []string // lines of test output
}
+28 -2
View File
@@ -11,9 +11,21 @@ const (
ScreenAgentActions // actions for a selected agent
ScreenLogs // tail log output
ScreenServer // server-wide process management
ScreenTests // test type selection menu
ScreenTestOutput // test run output
)
// TestKind identifies which test suite to run.
type TestKind int
const (
TestKindNone TestKind = iota
TestKindGo // go test -tags goolm -count=1 ./...
TestKindE2E // ./dev-scripts/e2e/run.sh
TestKindE2EHead // ./dev-scripts/e2e/run.sh --headed
TestKindAll // Go tests + E2E sequential
)
// Model is the complete TUI state — pure data.
type Model struct {
Screen Screen
@@ -33,6 +45,9 @@ type Model struct {
LauncherMemory string
LauncherCPU string
LauncherLogSize string
// Test state
LastTestKind TestKind // which test to re-run with "r"
}
// AgentView is a pre-formatted projection of an agent for display.
@@ -62,10 +77,21 @@ func MainMenuOptions() []MenuOption {
return []MenuOption{
{Label: "Agents", Desc: "Gestionar agentes"},
{Label: "Server", Desc: "Gestionar launcher unificado"},
{Label: "Tests", Desc: "Ejecutar tests"},
{Label: "Quit", Desc: "Salir"},
}
}
// TestMenuOptions returns the available test types.
func TestMenuOptions() []MenuOption {
return []MenuOption{
{Label: "Go Tests", Desc: "go test ./..."},
{Label: "E2E Tests", Desc: "Playwright headless"},
{Label: "E2E Tests (headed)", Desc: "Playwright con browser"},
{Label: "All Tests", Desc: "Go + E2E secuencial"},
}
}
// ServerMenuOptions returns the available server-wide actions.
func ServerMenuOptions(running bool) []MenuOption {
if running {
@@ -75,13 +101,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"},
{Label: "Tests", Desc: "Ir a pantalla de tests"},
}
}
return []MenuOption{
{Label: "Start", Desc: "Iniciar el launcher unificado"},
{Label: "Rebuild & Restart", Desc: "Build + iniciar"},
{Label: "Run Tests", Desc: "Ejecutar todos los tests"},
{Label: "Tests", Desc: "Ir a pantalla de tests"},
}
}
+347
View File
@@ -0,0 +1,347 @@
package tui
import (
"strings"
"testing"
)
// ── TestMenuOptions ─────────────────────────────────────────────────────
func TestTestMenuOptions_Count(t *testing.T) {
opts := TestMenuOptions()
if len(opts) != 4 {
t.Fatalf("expected 4 test menu options, got %d", len(opts))
}
}
func TestTestMenuOptions_Labels(t *testing.T) {
opts := TestMenuOptions()
expected := []string{"Go Tests", "E2E Tests", "E2E Tests (headed)", "All Tests"}
for i, want := range expected {
if opts[i].Label != want {
t.Errorf("option[%d]: expected label %q, got %q", i, want, opts[i].Label)
}
}
}
func TestMainMenuOptions_IncludesTests(t *testing.T) {
opts := MainMenuOptions()
found := false
for _, opt := range opts {
if opt.Label == "Tests" {
found = true
break
}
}
if !found {
t.Error("MainMenuOptions should include 'Tests'")
}
}
func TestMainMenuOptions_TestsBeforeQuit(t *testing.T) {
opts := MainMenuOptions()
testsIdx, quitIdx := -1, -1
for i, opt := range opts {
if opt.Label == "Tests" {
testsIdx = i
}
if opt.Label == "Quit" {
quitIdx = i
}
}
if testsIdx < 0 || quitIdx < 0 {
t.Fatal("expected both Tests and Quit in menu")
}
if testsIdx >= quitIdx {
t.Errorf("Tests (index %d) should come before Quit (index %d)", testsIdx, quitIdx)
}
}
func TestServerMenuOptions_NoRunTests(t *testing.T) {
for _, running := range []bool{true, false} {
opts := ServerMenuOptions(running)
for _, opt := range opts {
if opt.Label == "Run Tests" {
t.Errorf("ServerMenuOptions(running=%v) should not have 'Run Tests', found it", running)
}
}
}
}
func TestServerMenuOptions_HasTests(t *testing.T) {
for _, running := range []bool{true, false} {
opts := ServerMenuOptions(running)
found := false
for _, opt := range opts {
if opt.Label == "Tests" {
found = true
}
}
if !found {
t.Errorf("ServerMenuOptions(running=%v) should have 'Tests'", running)
}
}
}
// ── updateTestsScreen ───────────────────────────────────────────────────
func TestUpdateTestsScreen_Navigation(t *testing.T) {
m := Model{Screen: ScreenTests, Cursor: 0, WindowHeight: 40}
m, _ = Update(m, KeyMsg{Str: "down"})
if m.Cursor != 1 {
t.Errorf("expected cursor 1 after down, got %d", m.Cursor)
}
m, _ = Update(m, KeyMsg{Str: "up"})
if m.Cursor != 0 {
t.Errorf("expected cursor 0 after up, got %d", m.Cursor)
}
// Can't go below 0
m, _ = Update(m, KeyMsg{Str: "up"})
if m.Cursor != 0 {
t.Errorf("expected cursor 0 clamped, got %d", m.Cursor)
}
}
func TestUpdateTestsScreen_Back(t *testing.T) {
m := Model{Screen: ScreenTests, Cursor: 2}
m, _ = Update(m, KeyMsg{Str: "0"})
if m.Screen != ScreenMain {
t.Errorf("expected ScreenMain, got %d", m.Screen)
}
if m.Cursor != 0 {
t.Errorf("expected cursor reset to 0, got %d", m.Cursor)
}
}
func TestUpdateTestsScreen_SelectGoTests(t *testing.T) {
m := Model{Screen: ScreenTests, Cursor: 0}
m, intents := Update(m, KeyMsg{Str: "enter"})
if len(intents) != 1 || intents[0].Kind != IntentRunGoTests {
t.Errorf("expected IntentRunGoTests, got %v", intents)
}
if m.LastTestKind != TestKindGo {
t.Errorf("expected LastTestKind=TestKindGo, got %d", m.LastTestKind)
}
}
func TestUpdateTestsScreen_SelectE2ETests(t *testing.T) {
m := Model{Screen: ScreenTests, Cursor: 1}
_, intents := Update(m, KeyMsg{Str: "enter"})
if len(intents) != 1 || intents[0].Kind != IntentRunE2ETests {
t.Errorf("expected IntentRunE2ETests, got %v", intents)
}
}
func TestUpdateTestsScreen_SelectE2EHeaded(t *testing.T) {
m := Model{Screen: ScreenTests, Cursor: 2}
_, intents := Update(m, KeyMsg{Str: "enter"})
if len(intents) != 1 || intents[0].Kind != IntentRunE2EHeadTests {
t.Errorf("expected IntentRunE2EHeadTests, got %v", intents)
}
}
func TestUpdateTestsScreen_SelectAllTests(t *testing.T) {
m := Model{Screen: ScreenTests, Cursor: 3}
_, intents := Update(m, KeyMsg{Str: "enter"})
if len(intents) != 1 || intents[0].Kind != IntentRunAllTests {
t.Errorf("expected IntentRunAllTests, got %v", intents)
}
}
// ── MsgTestsDone ────────────────────────────────────────────────────────
func TestMsgTestsDone_SetsKindAndStatus(t *testing.T) {
m := Model{Screen: ScreenTests}
m, _ = Update(m, MsgTestsDone{Kind: TestKindE2E, Passed: true, Output: []string{"ok"}})
if m.Screen != ScreenTestOutput {
t.Errorf("expected ScreenTestOutput, got %d", m.Screen)
}
if m.LastTestKind != TestKindE2E {
t.Errorf("expected LastTestKind=TestKindE2E, got %d", m.LastTestKind)
}
if !strings.Contains(m.StatusMsg, "PASSED") {
t.Errorf("expected PASSED in status, got %q", m.StatusMsg)
}
if !strings.Contains(m.StatusMsg, "E2E") {
t.Errorf("expected E2E in status, got %q", m.StatusMsg)
}
}
func TestMsgTestsDone_Failed(t *testing.T) {
m := Model{}
m, _ = Update(m, MsgTestsDone{Kind: TestKindGo, Passed: false, Output: []string{"FAIL"}})
if !strings.Contains(m.StatusMsg, "FAILED") {
t.Errorf("expected FAILED in status, got %q", m.StatusMsg)
}
}
// ── updateTestOutput ────────────────────────────────────────────────────
func TestUpdateTestOutput_BackGoesToTests(t *testing.T) {
m := Model{Screen: ScreenTestOutput, LastTestKind: TestKindGo}
m, _ = Update(m, KeyMsg{Str: "0"})
if m.Screen != ScreenTests {
t.Errorf("expected ScreenTests, got %d", m.Screen)
}
}
func TestUpdateTestOutput_RerunUsesLastKind(t *testing.T) {
m := Model{Screen: ScreenTestOutput, LastTestKind: TestKindE2E, WindowHeight: 40}
_, intents := Update(m, KeyMsg{Str: "r"})
if len(intents) != 1 || intents[0].Kind != IntentRunE2ETests {
t.Errorf("expected IntentRunE2ETests, got %v", intents)
}
}
func TestUpdateTestOutput_RerunDefaultsToGo(t *testing.T) {
m := Model{Screen: ScreenTestOutput, LastTestKind: TestKindNone, WindowHeight: 40}
_, intents := Update(m, KeyMsg{Str: "r"})
if len(intents) != 1 || intents[0].Kind != IntentRunGoTests {
t.Errorf("expected IntentRunGoTests as default, got %v", intents)
}
}
// ── viewTests ───────────────────────────────────────────────────────────
func TestViewTests_ShowsOptions(t *testing.T) {
m := Model{Screen: ScreenTests, Cursor: 0, WindowWidth: 80, WindowHeight: 40}
out := View(m)
if !strings.Contains(out, "Go Tests") {
t.Error("expected 'Go Tests' in view")
}
if !strings.Contains(out, "E2E Tests") {
t.Error("expected 'E2E Tests' in view")
}
if !strings.Contains(out, "All Tests") {
t.Error("expected 'All Tests' in view")
}
}
func TestViewTests_ShowsCursor(t *testing.T) {
m := Model{Screen: ScreenTests, Cursor: 1, WindowWidth: 80, WindowHeight: 40}
out := View(m)
// Cursor on E2E Tests (index 1)
lines := strings.Split(out, "\n")
foundCursor := false
for _, line := range lines {
if strings.Contains(line, "> ") && strings.Contains(line, "E2E Tests") && !strings.Contains(line, "(headed)") {
foundCursor = true
}
}
if !foundCursor {
t.Error("expected cursor on E2E Tests")
}
}
func TestViewTests_ShowsLastRun(t *testing.T) {
m := Model{
Screen: ScreenTests,
Cursor: 0,
WindowWidth: 80,
WindowHeight: 40,
LastTestKind: TestKindGo,
StatusMsg: "Go Tests PASSED",
}
out := View(m)
if !strings.Contains(out, "Last run:") {
t.Error("expected 'Last run:' in view")
}
if !strings.Contains(out, "PASSED") {
t.Error("expected PASSED in last run")
}
}
func TestViewTestOutput_ShowsKindInTitle(t *testing.T) {
m := Model{
Screen: ScreenTestOutput,
LastTestKind: TestKindE2E,
StatusMsg: "E2E Tests PASSED",
LogLines: []string{"ok"},
WindowWidth: 80,
WindowHeight: 40,
}
out := View(m)
if !strings.Contains(out, "Test Results — E2E Tests") {
t.Errorf("expected 'Test Results — E2E Tests' in view, got:\n%s", out)
}
}
// ── Main menu Tests navigation ──────────────────────────────────────────
func TestMainMenu_TestsNavigation(t *testing.T) {
m := Model{Screen: ScreenMain, Cursor: 2} // Tests is at index 2
m, intents := Update(m, KeyMsg{Str: "enter"})
if m.Screen != ScreenTests {
t.Errorf("expected ScreenTests, got %d", m.Screen)
}
if len(intents) != 0 {
t.Errorf("expected no intents for Tests nav, got %v", intents)
}
}
// ── Server menu Tests navigation ────────────────────────────────────────
func TestServerMenu_TestsNavigation(t *testing.T) {
// "Tests" is the last option in both running/stopped menus
opts := ServerMenuOptions(false)
testsIdx := -1
for i, o := range opts {
if o.Label == "Tests" {
testsIdx = i
}
}
if testsIdx < 0 {
t.Fatal("Tests not found in server menu")
}
m := Model{Screen: ScreenServer, Cursor: testsIdx}
m, _ = Update(m, KeyMsg{Str: "enter"})
if m.Screen != ScreenTests {
t.Errorf("expected ScreenTests, got %d", m.Screen)
}
}
// ── testKindLabel ───────────────────────────────────────────────────────
func TestTestKindLabel(t *testing.T) {
cases := []struct {
kind TestKind
want string
}{
{TestKindGo, "Go Tests"},
{TestKindE2E, "E2E Tests"},
{TestKindE2EHead, "E2E Tests (headed)"},
{TestKindAll, "All Tests"},
{TestKindNone, "Tests"},
}
for _, tc := range cases {
got := testKindLabel(tc.kind)
if got != tc.want {
t.Errorf("testKindLabel(%d) = %q, want %q", tc.kind, got, tc.want)
}
}
}
// ── testKindIntent ──────────────────────────────────────────────────────
func TestTestKindIntent(t *testing.T) {
cases := []struct {
kind TestKind
want IntentKind
}{
{TestKindGo, IntentRunGoTests},
{TestKindE2E, IntentRunE2ETests},
{TestKindE2EHead, IntentRunE2EHeadTests},
{TestKindAll, IntentRunAllTests},
{TestKindNone, ""},
}
for _, tc := range cases {
got := testKindIntent(tc.kind)
if got != tc.want {
t.Errorf("testKindIntent(%d) = %q, want %q", tc.kind, got, tc.want)
}
}
}
+101 -7
View File
@@ -23,6 +23,10 @@ const (
IntentKillLauncher IntentKind = "kill_launcher"
IntentRebuildRestart IntentKind = "rebuild_restart"
IntentRunTests IntentKind = "run_tests"
IntentRunGoTests IntentKind = "run_go_tests"
IntentRunE2ETests IntentKind = "run_e2e_tests"
IntentRunE2EHeadTests IntentKind = "run_e2e_head_tests"
IntentRunAllTests IntentKind = "run_all_tests"
)
// Intent is pure data describing a side effect to execute.
@@ -107,10 +111,12 @@ func Update(model Model, msg interface{}) (Model, []Intent) {
model.LogLines = m.Output
model.LogScroll = 0
model.Cursor = 0
model.LastTestKind = m.Kind
label := testKindLabel(m.Kind)
if m.Passed {
model.StatusMsg = "Tests PASSED"
model.StatusMsg = label + " PASSED"
} else {
model.StatusMsg = "Tests FAILED"
model.StatusMsg = label + " FAILED"
}
return model, nil
@@ -143,6 +149,8 @@ func updateKey(model Model, key KeyMsg) (Model, []Intent) {
return updateLogs(model, key)
case ScreenServer:
return updateServerScreen(model, key)
case ScreenTests:
return updateTestsScreen(model, key)
case ScreenTestOutput:
return updateTestOutput(model, key)
}
@@ -167,6 +175,11 @@ func updateMainScreen(model Model, key KeyMsg) (Model, []Intent) {
model.Cursor = 0
model.StatusMsg = ""
return model, []Intent{{Kind: IntentLoadAgents}}
case "Tests":
model.Screen = ScreenTests
model.Cursor = 0
model.StatusMsg = ""
return model, nil
case "Quit":
return model, []Intent{{Kind: IntentQuit}}
}
@@ -304,9 +317,11 @@ 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 "Tests":
model.Screen = ScreenTests
model.Cursor = 0
model.StatusMsg = ""
return model, nil
case "Logs":
model.Screen = ScreenLogs
model.LogLines = nil
@@ -321,7 +336,7 @@ func executeServerAction(model Model, action string) (Model, []Intent) {
func updateTestOutput(model Model, key KeyMsg) (Model, []Intent) {
switch key.Str {
case "0":
model.Screen = ScreenServer
model.Screen = ScreenTests
model.Cursor = 0
model.LogLines = nil
model.LogScroll = 0
@@ -332,12 +347,91 @@ func updateTestOutput(model Model, key KeyMsg) (Model, []Intent) {
maxScroll := max(0, len(model.LogLines)-visibleLogLines(model))
model.LogScroll = min(model.LogScroll+1, maxScroll)
case "r":
intent := testKindIntent(model.LastTestKind)
if intent == "" {
intent = IntentRunGoTests
}
model.StatusMsg = "Running tests..."
return model, []Intent{{Kind: IntentRunTests}}
model.LogLines = nil
model.LogScroll = 0
return model, []Intent{{Kind: intent}}
}
return model, nil
}
func updateTestsScreen(model Model, key KeyMsg) (Model, []Intent) {
opts := TestMenuOptions()
switch key.Str {
case "0":
model.Screen = ScreenMain
model.Cursor = 0
model.StatusMsg = ""
case "up", "k":
model.Cursor = clamp(model.Cursor-1, 0, len(opts)-1)
case "down", "j":
model.Cursor = clamp(model.Cursor+1, 0, len(opts)-1)
case "enter":
if model.Cursor < len(opts) {
return executeTestAction(model, opts[model.Cursor].Label)
}
}
return model, nil
}
func executeTestAction(model Model, action string) (Model, []Intent) {
model.StatusMsg = "Running tests..."
model.LogLines = nil
model.LogScroll = 0
switch action {
case "Go Tests":
model.LastTestKind = TestKindGo
return model, []Intent{{Kind: IntentRunGoTests}}
case "E2E Tests":
model.LastTestKind = TestKindE2E
return model, []Intent{{Kind: IntentRunE2ETests}}
case "E2E Tests (headed)":
model.LastTestKind = TestKindE2EHead
return model, []Intent{{Kind: IntentRunE2EHeadTests}}
case "All Tests":
model.LastTestKind = TestKindAll
return model, []Intent{{Kind: IntentRunAllTests}}
}
return model, nil
}
// testKindIntent maps a TestKind to its corresponding IntentKind.
func testKindIntent(k TestKind) IntentKind {
switch k {
case TestKindGo:
return IntentRunGoTests
case TestKindE2E:
return IntentRunE2ETests
case TestKindE2EHead:
return IntentRunE2EHeadTests
case TestKindAll:
return IntentRunAllTests
default:
return ""
}
}
// testKindLabel returns a human-readable label for a TestKind.
func testKindLabel(k TestKind) string {
switch k {
case TestKindGo:
return "Go Tests"
case TestKindE2E:
return "E2E Tests"
case TestKindE2EHead:
return "E2E Tests (headed)"
case TestKindAll:
return "All Tests"
default:
return "Tests"
}
}
// ── pure helpers ─────────────────────────────────────────────────────────
func visibleLogLines(m Model) int {
+38 -1
View File
@@ -18,6 +18,8 @@ func View(model Model) string {
return viewLogs(model)
case ScreenServer:
return viewServer(model)
case ScreenTests:
return viewTests(model)
case ScreenTestOutput:
return viewTestOutput(model)
default:
@@ -238,10 +240,45 @@ func viewServer(m Model) string {
return b.String()
}
func viewTests(m Model) string {
var b strings.Builder
b.WriteString("\n Tests\n")
b.WriteString(" " + strings.Repeat("─", 44) + "\n")
for i, opt := range TestMenuOptions() {
cursor := " "
if i == m.Cursor {
cursor = "> "
}
b.WriteString(fmt.Sprintf(" %s%-22s %s\n", cursor, opt.Label, opt.Desc))
}
if m.LastTestKind != TestKindNone {
b.WriteString(fmt.Sprintf("\n Last run: %s", testKindLabel(m.LastTestKind)))
if m.StatusMsg != "" && (strings.HasSuffix(m.StatusMsg, "PASSED") || strings.HasSuffix(m.StatusMsg, "FAILED")) {
// Extract result from status
if strings.HasSuffix(m.StatusMsg, "PASSED") {
b.WriteString(" — PASSED")
} else {
b.WriteString(" — FAILED")
}
}
b.WriteString("\n")
}
b.WriteString("\n ↑↓ navegar enter ejecutar 0 volver\n")
return b.String()
}
func viewTestOutput(m Model) string {
var b strings.Builder
b.WriteString("\n Test Results\n")
title := "Test Results"
if m.LastTestKind != TestKindNone {
title = "Test Results — " + testKindLabel(m.LastTestKind)
}
b.WriteString("\n " + title + "\n")
b.WriteString(" " + strings.Repeat("─", 60) + "\n")
if m.StatusMsg != "" {
+78 -5
View File
@@ -59,7 +59,19 @@ func (a *Adapter) RunIntent(intent puretui.Intent) tea.Cmd {
return a.rebuildRestart()
case puretui.IntentRunTests:
return a.runTests()
return a.runGoTests()
case puretui.IntentRunGoTests:
return a.runGoTests()
case puretui.IntentRunE2ETests:
return a.runE2ETests(false)
case puretui.IntentRunE2EHeadTests:
return a.runE2ETests(true)
case puretui.IntentRunAllTests:
return a.runAllTests()
case puretui.IntentTick:
return a.tick()
@@ -236,11 +248,10 @@ func (a *Adapter) loadLogs(id string) tea.Cmd {
}
}
func (a *Adapter) runTests() tea.Cmd {
func (a *Adapter) runGoTests() 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", "./...")
@@ -252,9 +263,71 @@ func (a *Adapter) runTests() tea.Cmd {
output = "Error: " + err.Error()
}
lines := strings.Split(output, "\n")
passed := err == nil
return puretui.MsgTestsDone{Kind: puretui.TestKindGo, Passed: err == nil, Output: lines}
}
}
return puretui.MsgTestsDone{Passed: passed, Output: lines}
func (a *Adapter) runE2ETests(headed bool) tea.Cmd {
return func() tea.Msg {
args := []string{"./dev-scripts/e2e/run.sh"}
if headed {
args = append(args, "--headed")
}
cmd := exec.Command("bash", args...)
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")
kind := puretui.TestKindE2E
if headed {
kind = puretui.TestKindE2EHead
}
return puretui.MsgTestsDone{Kind: kind, Passed: err == nil, Output: lines}
}
}
func (a *Adapter) runAllTests() tea.Cmd {
return func() tea.Msg {
var allLines []string
// Go tests first
goBin, err := exec.LookPath("go")
if err != nil {
goBin = "/usr/local/go/bin/go"
}
goCmd := exec.Command(goBin, "test", "-tags", "goolm", "-count=1", "./...")
goCmd.Env = a.mgr.BuildEnv()
goOut, goErr := goCmd.CombinedOutput()
allLines = append(allLines, "═══ Go Tests ═══")
goOutput := strings.TrimSpace(string(goOut))
if goOutput == "" && goErr != nil {
goOutput = "Error: " + goErr.Error()
}
allLines = append(allLines, strings.Split(goOutput, "\n")...)
if goErr != nil {
allLines = append(allLines, "", "Go tests FAILED — skipping E2E")
return puretui.MsgTestsDone{Kind: puretui.TestKindAll, Passed: false, Output: allLines}
}
// E2E tests
allLines = append(allLines, "", "═══ E2E Tests ═══")
e2eCmd := exec.Command("bash", "./dev-scripts/e2e/run.sh")
e2eCmd.Env = a.mgr.BuildEnv()
e2eOut, e2eErr := e2eCmd.CombinedOutput()
e2eOutput := strings.TrimSpace(string(e2eOut))
if e2eOutput == "" && e2eErr != nil {
e2eOutput = "Error: " + e2eErr.Error()
}
allLines = append(allLines, strings.Split(e2eOutput, "\n")...)
return puretui.MsgTestsDone{Kind: puretui.TestKindAll, Passed: e2eErr == nil, Output: allLines}
}
}