feat: pantalla de tests en el dashboard TUI

Nueva seccion "Tests" en el menu principal del dashboard que permite
ejecutar Go tests, E2E tests (headless y headed), y todos secuencialmente.

- ScreenTests con menu de seleccion de tipo de test
- TestKind enum para identificar el tipo de test ejecutado
- Nuevos intents: IntentRunGoTests, IntentRunE2ETests, IntentRunE2EHeadTests, IntentRunAllTests
- LastTestKind en Model para re-ejecucion con "r"
- runGoTests, runE2ETests, runAllTests en adapter
- "Run Tests" en Server menu reemplazado por navegacion a ScreenTests
- Test output muestra tipo de test en titulo y vuelve a ScreenTests con "0"

Issue: 0023

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 15:43:51 +00:00
parent 89acbe02c8
commit 509d456275
5 changed files with 246 additions and 15 deletions
+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"},
}
}
+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}
}
}