package tui import ( "strings" "testing" "bee/audit/internal/app" "bee/audit/internal/platform" "bee/audit/internal/runtimeenv" tea "github.com/charmbracelet/bubbletea" ) func newTestModel() model { return newModel(app.New(platform.New()), runtimeenv.ModeLocal) } func sendKey(t *testing.T, m model, key tea.KeyType) model { t.Helper() next, _ := m.Update(tea.KeyMsg{Type: key}) return next.(model) } func TestUpdateMainMenuCursorNavigation(t *testing.T) { t.Parallel() m := newTestModel() m = sendKey(t, m, tea.KeyDown) if m.cursor != 1 { t.Fatalf("cursor=%d want 1 after down", m.cursor) } m = sendKey(t, m, tea.KeyDown) if m.cursor != 2 { t.Fatalf("cursor=%d want 2 after second down", m.cursor) } m = sendKey(t, m, tea.KeyUp) if m.cursor != 1 { t.Fatalf("cursor=%d want 1 after up", m.cursor) } } func TestUpdateMainMenuEnterActions(t *testing.T) { t.Parallel() tests := []struct { name string cursor int wantScreen screen wantBusy bool wantCmd bool }{ {name: "health_check", cursor: 0, wantScreen: screenHealthCheck, wantCmd: true}, {name: "export", cursor: 1, wantScreen: screenMain, wantBusy: true, wantCmd: true}, {name: "settings", cursor: 2, wantScreen: screenSettings, wantCmd: true}, {name: "exit", cursor: 3, wantScreen: screenMain, wantCmd: true}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() m := newTestModel() m.cursor = test.cursor next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) got := next.(model) if got.screen != test.wantScreen { t.Fatalf("screen=%q want %q", got.screen, test.wantScreen) } if got.busy != test.wantBusy { t.Fatalf("busy=%v want %v", got.busy, test.wantBusy) } if (cmd != nil) != test.wantCmd { t.Fatalf("cmd present=%v want %v", cmd != nil, test.wantCmd) } }) } } func TestUpdateConfirmCancelViaKeys(t *testing.T) { t.Parallel() m := newTestModel() m.screen = screenConfirm m.pendingAction = actionRunMemorySAT next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) got := next.(model) if got.cursor != 1 { t.Fatalf("cursor=%d want 1 after right", got.cursor) } next, _ = got.Update(tea.KeyMsg{Type: tea.KeyEnter}) got = next.(model) if got.screen != screenHealthCheck { t.Fatalf("screen=%q want %q", got.screen, screenHealthCheck) } if got.cursor != 0 { t.Fatalf("cursor=%d want 0 after cancel", got.cursor) } } func TestMainMenuSimpleTransitions(t *testing.T) { t.Parallel() tests := []struct { name string cursor int wantScreen screen }{ {name: "health_check", cursor: 0, wantScreen: screenHealthCheck}, {name: "settings", cursor: 2, wantScreen: screenSettings}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() m := newTestModel() m.cursor = test.cursor next, cmd := m.handleMainMenu() got := next.(model) if cmd != nil { t.Fatalf("expected nil cmd for %s", test.name) } if got.screen != test.wantScreen { t.Fatalf("screen=%q want %q", got.screen, test.wantScreen) } if got.cursor != 0 { t.Fatalf("cursor=%d want 0", got.cursor) } }) } } func TestMainMenuExportSetsBusy(t *testing.T) { t.Parallel() m := newTestModel() m.cursor = 1 // Export support bundle next, cmd := m.handleMainMenu() got := next.(model) if !got.busy { t.Fatal("busy=false for export") } if cmd == nil { t.Fatal("expected async cmd for export") } } func TestMainViewRendersTwoColumns(t *testing.T) { t.Parallel() m := newTestModel() m.cursor = 1 view := m.View() for _, want := range []string{ "bee", "Health Check", "> Export support bundle", "Settings", "Exit", "│", "[↑↓] move", } { if !strings.Contains(view, want) { t.Fatalf("view missing %q\nview:\n%s", want, view) } } } func TestEscapeNavigation(t *testing.T) { t.Parallel() tests := []struct { name string screen screen wantScreen screen }{ {name: "network to settings", screen: screenNetwork, wantScreen: screenSettings}, {name: "services to settings", screen: screenServices, wantScreen: screenSettings}, {name: "settings to main", screen: screenSettings, wantScreen: screenMain}, {name: "service action to services", screen: screenServiceAction, wantScreen: screenServices}, {name: "export targets to main", screen: screenExportTargets, wantScreen: screenMain}, {name: "interface pick to network", screen: screenInterfacePick, wantScreen: screenNetwork}, } for _, test := range tests { test := test t.Run(test.name, func(t *testing.T) { t.Parallel() m := newTestModel() m.screen = test.screen m.cursor = 3 next, _ := m.updateKey(tea.KeyMsg{Type: tea.KeyEsc}) got := next.(model) if got.screen != test.wantScreen { t.Fatalf("screen=%q want %q", got.screen, test.wantScreen) } if got.cursor != 0 { t.Fatalf("cursor=%d want 0", got.cursor) } }) } } func TestHealthCheckEscReturnsToMain(t *testing.T) { t.Parallel() m := newTestModel() m.screen = screenHealthCheck m.hcCursor = 3 next, _ := m.updateHealthCheck(tea.KeyMsg{Type: tea.KeyEsc}) got := next.(model) if got.screen != screenMain { t.Fatalf("screen=%q want %q", got.screen, screenMain) } if got.cursor != 0 { t.Fatalf("cursor=%d want 0", got.cursor) } } func TestOutputScreenReturnsToPreviousScreen(t *testing.T) { t.Parallel() m := newTestModel() m.screen = screenOutput m.prevScreen = screenNetwork m.title = "title" m.body = "body" next, _ := m.updateKey(tea.KeyMsg{Type: tea.KeyEnter}) got := next.(model) if got.screen != screenNetwork { t.Fatalf("screen=%q want %q", got.screen, screenNetwork) } if got.title != "" || got.body != "" { t.Fatalf("expected output state cleared, got title=%q body=%q", got.title, got.body) } } func TestHealthCheckGPUOpensNvidiaSATSetup(t *testing.T) { t.Parallel() m := newTestModel() m.screen = screenHealthCheck m.hcInitialized = true m.hcSel = [4]bool{true, true, true, true} next, cmd := m.hcRunSingle(hcGPU) got := next.(model) if cmd == nil { t.Fatal("expected non-nil cmd (GPU list loader)") } if got.screen != screenNvidiaSATSetup { t.Fatalf("screen=%q want %q", got.screen, screenNvidiaSATSetup) } // esc from setup returns to health check next, _ = got.updateNvidiaSATSetup(tea.KeyMsg{Type: tea.KeyEsc}) got = next.(model) if got.screen != screenHealthCheck { t.Fatalf("screen after esc=%q want %q", got.screen, screenHealthCheck) } } func TestHealthCheckRunSingleMapsActions(t *testing.T) { t.Parallel() tests := []struct { idx int want actionKind }{ {idx: hcMemory, want: actionRunMemorySAT}, {idx: hcStorage, want: actionRunStorageSAT}, } for _, test := range tests { m := newTestModel() m.screen = screenHealthCheck m.hcInitialized = true next, _ := m.hcRunSingle(test.idx) got := next.(model) if got.pendingAction != test.want { t.Fatalf("idx=%d pendingAction=%q want %q", test.idx, got.pendingAction, test.want) } if got.screen != screenConfirm { t.Fatalf("idx=%d screen=%q want %q", test.idx, got.screen, screenConfirm) } } } func TestExportTargetSelectionOpensConfirm(t *testing.T) { t.Parallel() m := newTestModel() m.screen = screenExportTargets m.targets = []platform.RemovableTarget{{Device: "/dev/sdb1", FSType: "vfat", Size: "16G"}} next, cmd := m.handleExportTargetsMenu() got := next.(model) if cmd != nil { t.Fatal("expected nil cmd") } if got.screen != screenConfirm { t.Fatalf("screen=%q want %q", got.screen, screenConfirm) } if got.pendingAction != actionExportBundle { t.Fatalf("pendingAction=%q want %q", got.pendingAction, actionExportBundle) } if got.selectedTarget == nil || got.selectedTarget.Device != "/dev/sdb1" { t.Fatalf("selectedTarget=%+v want /dev/sdb1", got.selectedTarget) } } func TestInterfacePickStaticIPv4OpensForm(t *testing.T) { t.Parallel() m := newTestModel() m.pendingAction = actionStaticIPv4 m.interfaces = []platform.InterfaceInfo{{Name: "eth0"}} next, cmd := m.handleInterfacePickMenu() got := next.(model) if cmd != nil { t.Fatal("expected nil cmd") } if got.screen != screenStaticForm { t.Fatalf("screen=%q want %q", got.screen, screenStaticForm) } if got.selectedIface != "eth0" { t.Fatalf("selectedIface=%q want eth0", got.selectedIface) } if len(got.formFields) != 4 { t.Fatalf("len(formFields)=%d want 4", len(got.formFields)) } } func TestResultMsgUsesExplicitBackScreen(t *testing.T) { t.Parallel() m := newTestModel() m.screen = screenConfirm next, _ := m.Update(resultMsg{title: "done", body: "ok", back: screenNetwork}) got := next.(model) if got.screen != screenOutput { t.Fatalf("screen=%q want %q", got.screen, screenOutput) } if got.prevScreen != screenNetwork { t.Fatalf("prevScreen=%q want %q", got.prevScreen, screenNetwork) } } func TestConfirmCancelTarget(t *testing.T) { t.Parallel() m := newTestModel() m.pendingAction = actionExportBundle if got := m.confirmCancelTarget(); got != screenExportTargets { t.Fatalf("export cancel target=%q want %q", got, screenExportTargets) } m.pendingAction = actionRunAll if got := m.confirmCancelTarget(); got != screenHealthCheck { t.Fatalf("run all cancel target=%q want %q", got, screenHealthCheck) } m.pendingAction = actionRunMemorySAT if got := m.confirmCancelTarget(); got != screenHealthCheck { t.Fatalf("memory sat cancel target=%q want %q", got, screenHealthCheck) } m.pendingAction = actionRunStorageSAT if got := m.confirmCancelTarget(); got != screenHealthCheck { t.Fatalf("storage sat cancel target=%q want %q", got, screenHealthCheck) } m.pendingAction = actionNone if got := m.confirmCancelTarget(); got != screenMain { t.Fatalf("default cancel target=%q want %q", got, screenMain) } } func TestViewBusyStateIsMinimal(t *testing.T) { t.Parallel() m := newTestModel() m.busy = true view := m.View() want := "bee\n\nWorking...\n\n[ctrl+c] quit\n" if view != want { t.Fatalf("busy view mismatch\nwant:\n%s\ngot:\n%s", want, view) } } func TestViewBusyStateUsesBusyTitle(t *testing.T) { t.Parallel() m := newTestModel() m.busy = true m.busyTitle = "Export support bundle" view := m.View() for _, want := range []string{ "Export support bundle", "Working...", "[ctrl+c] quit", } { if !strings.Contains(view, want) { t.Fatalf("view missing %q\nview:\n%s", want, view) } } } func TestViewOutputScreenRendersBodyAndBackHint(t *testing.T) { t.Parallel() m := newTestModel() m.screen = screenOutput m.title = "Run audit" m.body = "audit output: /appdata/bee/export/bee-audit.json\n" view := m.View() for _, want := range []string{ "Run audit", "audit output: /appdata/bee/export/bee-audit.json", "[enter/esc] back [ctrl+c] quit", } { if !strings.Contains(view, want) { t.Fatalf("view missing %q\nview:\n%s", want, view) } } } func TestViewRendersBannerModuleAboveScreenBody(t *testing.T) { t.Parallel() m := newTestModel() m.banner = "System: Demo Server\nIP: 10.0.0.10" m.width = 60 view := m.View() for _, want := range []string{ "┌ MOTD ", "System: Demo Server", "IP: 10.0.0.10", "Health Check", "Export support bundle", } { if !strings.Contains(view, want) { t.Fatalf("view missing %q\nview:\n%s", want, view) } } } func TestSnapshotMsgUpdatesBannerAndPanel(t *testing.T) { t.Parallel() m := newTestModel() next, cmd := m.Update(snapshotMsg{ banner: "System: Demo", panel: app.HardwarePanelData{ Header: []string{"Demo header"}, Rows: []app.ComponentRow{ {Key: "CPU", Status: "PASS", Detail: "ok"}, }, }, }) got := next.(model) if cmd != nil { t.Fatal("expected nil cmd") } if got.banner != "System: Demo" { t.Fatalf("banner=%q want %q", got.banner, "System: Demo") } if len(got.panel.Rows) != 1 || got.panel.Rows[0].Key != "CPU" { t.Fatalf("panel rows=%+v", got.panel.Rows) } } func TestViewExportTargetsRendersDeviceMetadata(t *testing.T) { t.Parallel() m := newTestModel() m.screen = screenExportTargets m.targets = []platform.RemovableTarget{ { Device: "/dev/sdb1", FSType: "vfat", Size: "29G", Label: "BEEUSB", Mountpoint: "/media/bee", }, } view := m.View() for _, want := range []string{ "Export support bundle", "Select removable filesystem", "> /dev/sdb1 [vfat 29G] label=BEEUSB mounted=/media/bee", } { if !strings.Contains(view, want) { t.Fatalf("view missing %q\nview:\n%s", want, view) } } } func TestViewStaticFormRendersFields(t *testing.T) { t.Parallel() m := newTestModel() m.screen = screenStaticForm m.selectedIface = "enp1s0" m.formFields = []formField{ {Label: "Address", Value: "192.0.2.10/24"}, {Label: "Gateway", Value: "192.0.2.1"}, {Label: "DNS", Value: "1.1.1.1"}, } m.formIndex = 1 view := m.View() for _, want := range []string{ "Static IPv4: enp1s0", " Address: 192.0.2.10/24", "> Gateway: 192.0.2.1", " DNS: 1.1.1.1", "[tab/↑/↓] move [enter] next/submit [backspace] delete [esc] cancel", } { if !strings.Contains(view, want) { t.Fatalf("view missing %q\nview:\n%s", want, view) } } } func TestViewConfirmScreenMatchesPendingExport(t *testing.T) { t.Parallel() m := newTestModel() m.screen = screenConfirm m.pendingAction = actionExportBundle m.selectedTarget = &platform.RemovableTarget{Device: "/dev/sdb1"} view := m.View() for _, want := range []string{ "Export support bundle", "Copy support bundle to /dev/sdb1?", "> Confirm", " Cancel", } { if !strings.Contains(view, want) { t.Fatalf("view missing %q\nview:\n%s", want, view) } } } func TestResultMsgClearsBusyAndPendingAction(t *testing.T) { t.Parallel() m := newTestModel() m.busy = true m.busyTitle = "Export support bundle" m.pendingAction = actionExportBundle m.screen = screenConfirm next, _ := m.Update(resultMsg{title: "Export support bundle", body: "done", back: screenMain}) got := next.(model) if got.busy { t.Fatal("busy=true want false") } if got.busyTitle != "" { t.Fatalf("busyTitle=%q want empty", got.busyTitle) } if got.pendingAction != actionNone { t.Fatalf("pendingAction=%q want empty", got.pendingAction) } } func TestResultMsgErrorWithoutBodyFormatsCleanly(t *testing.T) { t.Parallel() m := newTestModel() next, _ := m.Update(resultMsg{title: "Export support bundle", err: assertErr("boom"), back: screenMain}) got := next.(model) if got.body != "ERROR: boom" { t.Fatalf("body=%q want %q", got.body, "ERROR: boom") } } type assertErr string func (e assertErr) Error() string { return string(e) }