CLI + live dashboard wrapping aws/docker/git/awsmfa for local dev: AWS MFA auth status + expiry, dev compose stack control, container drift vs the dev repo, git repo status, prod-data and DB-access tools. Config-driven (~/.config/mydev/config.yaml). Dashboard runs commands via a command palette with captured/interactive modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
276 lines
8.8 KiB
Go
276 lines
8.8 KiB
Go
package cmd
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"mydev/internal/checks"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
)
|
||
|
||
func TestDashViewRenders(t *testing.T) {
|
||
m := newDash([]string{"/tmp"}, "/x/awsmfa", "/x/docker-compose.yml", 10*time.Second)
|
||
m.auth = checks.AuthStatus{Authed: true, Account: "123", Detail: "account 123 · expires 21:25"}
|
||
m.docker = dockerMsg{list: []checks.Container{{Name: "db", State: "running", Status: "Up 1h"}}}
|
||
m.drift = driftMsg{stale: 2, total: 13}
|
||
m.repos = repoMsg{list: []checks.Repo{{Name: "legacy", Branch: "main", Dirty: true}}}
|
||
|
||
out := m.View()
|
||
for _, want := range []string{"MyDev", "AWS AUTH", "DOCKER", "2/13 stale", "REPOS", "legacy"} {
|
||
if !strings.Contains(out, want) {
|
||
t.Errorf("View() missing %q\n---\n%s", want, out)
|
||
}
|
||
}
|
||
|
||
// Default footer shows the key legend.
|
||
if !strings.Contains(out, "[R]estart") {
|
||
t.Errorf("footer missing key legend\n---\n%s", out)
|
||
}
|
||
|
||
// With a pending action, the footer becomes a confirm prompt instead.
|
||
m.pending = &pendingAction{label: "dev restart", args: []string{"dev", "restart"}}
|
||
confirm := m.View()
|
||
if !strings.Contains(confirm, "Run 'dev restart'?") || !strings.Contains(confirm, "[y] yes") {
|
||
t.Errorf("confirm prompt not rendered\n---\n%s", confirm)
|
||
}
|
||
}
|
||
|
||
func names(items []suggestion) string {
|
||
var b strings.Builder
|
||
for _, it := range items {
|
||
b.WriteString(it.name + " ")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func TestCompletions(t *testing.T) {
|
||
// Empty input → root commands, excluding dash/help/completion.
|
||
label, items, leaf := completions("")
|
||
if leaf {
|
||
t.Fatal("root should not be a leaf")
|
||
}
|
||
got := names(items)
|
||
for _, want := range []string{"dev", "db", "data", "port"} {
|
||
if !strings.Contains(got, want) {
|
||
t.Errorf("root completions missing %q (got: %s)", want, got)
|
||
}
|
||
}
|
||
// Hidden at root: status + bare auth (dashboard-covered), and dash/help/completion.
|
||
for _, no := range []string{"status", "auth", "dash", "help", "completion"} {
|
||
if strings.Contains(got, no) {
|
||
t.Errorf("root completions should not include %q (got: %s)", no, got)
|
||
}
|
||
}
|
||
// But `auth login` is still reachable by typing `auth `.
|
||
if !strings.Contains(names(mustItems(t, "auth ")), "login") {
|
||
t.Error("auth login should still be reachable")
|
||
}
|
||
|
||
// Prefix filter.
|
||
if got := names(mustItems(t, "d")); !strings.Contains(got, "dev") || !strings.Contains(got, "docker") || strings.Contains(got, "auth") {
|
||
t.Errorf("prefix 'd' filter wrong: %s", got)
|
||
}
|
||
|
||
// Descend into a subcommand group.
|
||
if got := names(mustItems(t, "dev ")); !strings.Contains(got, "start") || !strings.Contains(got, "restart") || !strings.Contains(got, "status") {
|
||
t.Errorf("'dev ' subcommands wrong: %s", got)
|
||
}
|
||
|
||
// Leaf command shows usage.
|
||
_, items, leaf = completions("port ")
|
||
if !leaf || len(items) == 0 || !strings.Contains(items[0].name, "port") {
|
||
t.Errorf("'port ' should be a leaf with usage, got leaf=%v items=%v", leaf, items)
|
||
}
|
||
_ = label
|
||
}
|
||
|
||
func mustItems(t *testing.T, in string) []suggestion {
|
||
t.Helper()
|
||
_, items, _ := completions(in)
|
||
return items
|
||
}
|
||
|
||
func TestInteractiveClassification(t *testing.T) {
|
||
cases := []struct {
|
||
args []string
|
||
want bool
|
||
}{
|
||
{[]string{"auth", "login"}, true},
|
||
{[]string{"dev", "start"}, true},
|
||
{[]string{"dev", "restart"}, true},
|
||
{[]string{"dev", "stop"}, false}, // quick, capture output
|
||
{[]string{"dev", "status"}, false}, // read-only
|
||
{[]string{"data", "table", "x"}, true}, // may prompt / stream
|
||
{[]string{"data", "snapshot"}, true},
|
||
{[]string{"db", "grant", "1234"}, false}, // captured → result pane
|
||
{[]string{"port", "3306"}, false},
|
||
{[]string{"auth"}, false},
|
||
}
|
||
for _, c := range cases {
|
||
if got := interactive(c.args); got != c.want {
|
||
t.Errorf("interactive(%v) = %v, want %v", c.args, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDashboardCovered(t *testing.T) {
|
||
cases := []struct {
|
||
args []string
|
||
want bool
|
||
}{
|
||
{[]string{"status"}, true},
|
||
{[]string{"auth"}, true}, // bare auth = status
|
||
{[]string{"auth", "login"}, false}, // login is an action
|
||
{[]string{"docker"}, false}, // kept (more detail than panel)
|
||
{[]string{"dev", "status"}, false}, // kept (per-service detail)
|
||
{[]string{"port", "3306"}, false},
|
||
}
|
||
for _, c := range cases {
|
||
if got := dashboardCovered(c.args); got != c.want {
|
||
t.Errorf("dashboardCovered(%v) = %v, want %v", c.args, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPaletteRefusesCoveredCommand(t *testing.T) {
|
||
m := newDash(nil, "/x/awsmfa", "", 10*time.Second)
|
||
um, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{':'}})
|
||
m = um.(dashModel)
|
||
// Type "status" char by char.
|
||
for _, r := range "status" {
|
||
um, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||
m = um.(dashModel)
|
||
}
|
||
um, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||
m = um.(dashModel)
|
||
if !m.cmdActive {
|
||
t.Error("palette should stay open after refusing a covered command")
|
||
}
|
||
if m.running != "" || m.result != nil {
|
||
t.Error("covered command should not run")
|
||
}
|
||
if !strings.Contains(m.View(), "already shown on the dashboard") {
|
||
t.Errorf("expected refusal note\n---\n%s", m.View())
|
||
}
|
||
}
|
||
|
||
func TestLaunchSetsRunningForCaptured(t *testing.T) {
|
||
m := newDash(nil, "/x/awsmfa", "", 10*time.Second)
|
||
m2, cmd := m.launch([]string{"status"})
|
||
if m2.running != "status" {
|
||
t.Errorf("captured launch should set running=status, got %q", m2.running)
|
||
}
|
||
if cmd == nil {
|
||
t.Error("launch returned nil command")
|
||
}
|
||
// Interactive launch should NOT set running (it suspends the TUI).
|
||
m3, _ := m.launch([]string{"auth", "login"})
|
||
if m3.running != "" {
|
||
t.Errorf("interactive launch should not set running, got %q", m3.running)
|
||
}
|
||
}
|
||
|
||
func TestWrapTextBreaksLongTokens(t *testing.T) {
|
||
// A generated password has no spaces — must still be hard-wrapped.
|
||
pw := "temp password: " + strings.Repeat("Xy9$", 30) // ~135 chars, mostly one token
|
||
const w = 40
|
||
out := wrapText(pw, w)
|
||
for _, line := range strings.Split(out, "\n") {
|
||
if len(line) > w {
|
||
t.Errorf("line exceeds width %d: %q (len %d)", w, line, len(line))
|
||
}
|
||
}
|
||
// Content is preserved (ignoring inserted newlines).
|
||
if strings.ReplaceAll(out, "\n", "") != strings.ReplaceAll(pw, " ", " ") &&
|
||
!strings.Contains(strings.ReplaceAll(out, "\n", ""), strings.Repeat("Xy9$", 30)) {
|
||
t.Errorf("wrap lost content:\n%s", out)
|
||
}
|
||
}
|
||
|
||
func TestRunningBlocksLaunchAndCancels(t *testing.T) {
|
||
m := newDash(nil, "/x/awsmfa", "", 10*time.Second)
|
||
m, _ = m.launch([]string{"status"})
|
||
if m.running != "status" || m.cancel == nil {
|
||
t.Fatalf("captured launch should set running+cancel, got running=%q cancel=%v", m.running, m.cancel != nil)
|
||
}
|
||
|
||
// Footer shows the running state with a cancel hint.
|
||
if !strings.Contains(m.View(), "[c]ancel") {
|
||
t.Errorf("footer missing cancel hint\n---\n%s", m.View())
|
||
}
|
||
|
||
// ":" must NOT open the palette while a command runs.
|
||
um, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{':'}})
|
||
m = um.(dashModel)
|
||
if m.cmdActive {
|
||
t.Error("palette opened while a command was running")
|
||
}
|
||
|
||
// "c" cancels.
|
||
um, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
|
||
m = um.(dashModel)
|
||
if !m.cancelled {
|
||
t.Error("'c' did not mark the command cancelled")
|
||
}
|
||
|
||
// The result message clears the running state and returns to the menu —
|
||
// a cancelled command shows NO result pane.
|
||
um, _ = m.Update(cmdResultMsg{title: "status", body: "partial", err: context.Canceled})
|
||
m = um.(dashModel)
|
||
if m.running != "" || m.cancel != nil {
|
||
t.Error("running state not cleared after result")
|
||
}
|
||
if m.result != nil {
|
||
t.Errorf("cancelled command should not open a result pane, got %+v", m.result)
|
||
}
|
||
if m.cancelled {
|
||
t.Error("cancelled flag not reset")
|
||
}
|
||
}
|
||
|
||
func TestResultPaneRenders(t *testing.T) {
|
||
m := newDash(nil, "/x/awsmfa", "", 10*time.Second)
|
||
sized, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
|
||
m = sized.(dashModel)
|
||
updated, _ := m.Update(cmdResultMsg{title: "db list", body: "tenant-a\ntenant-b", err: nil})
|
||
m = updated.(dashModel)
|
||
|
||
if m.result == nil {
|
||
t.Fatal("cmdResultMsg did not open the result pane")
|
||
}
|
||
out := m.View()
|
||
if !strings.Contains(out, "mydev db list") || !strings.Contains(out, "tenant-a") {
|
||
t.Errorf("result pane missing title/body\n---\n%s", out)
|
||
}
|
||
|
||
// esc closes it.
|
||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||
if updated.(dashModel).result != nil {
|
||
t.Error("esc did not close the result pane")
|
||
}
|
||
}
|
||
|
||
func TestDashCommandPalette(t *testing.T) {
|
||
m := newDash([]string{"/tmp"}, "/x/awsmfa", "/x/docker-compose.yml", 10*time.Second)
|
||
|
||
// ":" opens the palette.
|
||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{':'}})
|
||
m = updated.(dashModel)
|
||
if !m.cmdActive {
|
||
t.Fatal("':' did not open the command palette")
|
||
}
|
||
if !strings.Contains(m.View(), "mydev ❯") {
|
||
t.Errorf("palette prompt not shown\n---\n%s", m.View())
|
||
}
|
||
|
||
// esc closes it without running anything.
|
||
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
|
||
m = updated.(dashModel)
|
||
if m.cmdActive {
|
||
t.Error("esc did not close the palette")
|
||
}
|
||
}
|