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/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} } }