diff --git a/dev/issues/README.md b/dev/issues/README.md index d8e290b..0b13b31 100644 --- a/dev/issues/README.md +++ b/dev/issues/README.md @@ -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 | diff --git a/dev/issues/completed/0023-dashboard-tests.md b/dev/issues/completed/0023-dashboard-tests.md new file mode 100644 index 0000000..62646af --- /dev/null +++ b/dev/issues/completed/0023-dashboard-tests.md @@ -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. diff --git a/pkg/tui/messages.go b/pkg/tui/messages.go index 7ab1f78..f7ce3cc 100644 --- a/pkg/tui/messages.go +++ b/pkg/tui/messages.go @@ -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 } diff --git a/pkg/tui/model.go b/pkg/tui/model.go index f33d54f..3c36aa6 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -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"}, } } diff --git a/pkg/tui/tui_test.go b/pkg/tui/tui_test.go new file mode 100644 index 0000000..b9083fb --- /dev/null +++ b/pkg/tui/tui_test.go @@ -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) + } + } +} diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 0c78e35..ee5d794 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -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 { diff --git a/pkg/tui/view.go b/pkg/tui/view.go index 8751715..c8580f9 100644 --- a/pkg/tui/view.go +++ b/pkg/tui/view.go @@ -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 != "" { diff --git a/shell/tui/adapter.go b/shell/tui/adapter.go index 87ab944..fd7248b 100644 --- a/shell/tui/adapter.go +++ b/shell/tui/adapter.go @@ -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} } }