shell_test.go (9952B)
1 package tui 2 3 import ( 4 "math/rand" 5 "regexp" 6 "strings" 7 "testing" 8 "time" 9 10 appconfig "git.prime8.dev/moncheeta/prime8dev/internal/config" 11 "github.com/charmbracelet/bubbles/help" 12 "github.com/charmbracelet/bubbles/viewport" 13 tea "github.com/charmbracelet/bubbletea" 14 "github.com/charmbracelet/lipgloss" 15 "github.com/charmbracelet/x/ansi" 16 "github.com/muesli/termenv" 17 ) 18 19 // testModel builds a model without network or git access. 20 func testModel(t *testing.T, w, h int) portfolioModel { 21 t.Helper() 22 lipgloss.SetColorProfile(termenv.ANSI256) 23 exp := appconfig.GetExperience() 24 projects := appconfig.GetProjects() 25 m := portfolioModel{ 26 user: "visitor", 27 about: appconfig.GetAbout(), 28 currentJob: appconfig.CurrentJob(exp), 29 aboutASCIICache: map[int]string{}, 30 aboutASCIILoads: map[int]bool{}, 31 projects: projects, 32 experience: exp, 33 now: time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC), 34 width: w, 35 height: h, 36 bootRand: rand.New(rand.NewSource(1)), 37 keys: defaultKeyMap(), 38 help: help.New(), 39 viewport: viewport.New(1, 1), 40 shell: newShellState("visitor"), 41 projectReadmes: map[string]string{projects[0].ID: "# janitor\n\nsorts audio."}, 42 projectReadmeErrors: map[string]string{}, 43 projectReadmeLoads: map[string]bool{}, 44 projectReadmeRender: map[string]map[int]string{}, 45 } 46 m.syncViewport() 47 return m 48 } 49 50 func typeString(m *portfolioModel, s string) tea.Cmd { 51 var cmd tea.Cmd 52 for _, r := range s { 53 if r == '\n' { 54 cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyEnter}) 55 continue 56 } 57 if r == ' ' { 58 cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune{' '}}) 59 continue 60 } 61 cmd = m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) 62 } 63 return cmd 64 } 65 66 func isQuit(cmd tea.Cmd) bool { 67 if cmd == nil { 68 return false 69 } 70 _, ok := cmd().(tea.QuitMsg) 71 return ok 72 } 73 74 func plainView(m portfolioModel) string { return ansi.Strip(m.View()) } 75 76 func TestShellPromptRendersOnAboutPage(t *testing.T) { 77 m := testModel(t, 120, 60) 78 if !strings.Contains(plainView(m), "visitor@prime8:~$ █") { 79 t.Fatalf("prompt not rendered:\n%s", plainView(m)) 80 } 81 } 82 83 func TestShellTypingDoesNotTriggerShortcuts(t *testing.T) { 84 m := testModel(t, 120, 60) 85 // q, f, b, h are bindings elsewhere; on the about page they are text. 86 if cmd := typeString(&m, "qfbh"); isQuit(cmd) { 87 t.Fatal("q quit the session while typing") 88 } 89 if m.shell.input != "qfbh" { 90 t.Fatalf("input = %q", m.shell.input) 91 } 92 if !strings.Contains(plainView(m), "visitor@prime8:~$ qfbh█") { 93 t.Fatalf("typed text not shown:\n%s", plainView(m)) 94 } 95 m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyBackspace}) 96 if m.shell.input != "qfb" { 97 t.Fatalf("backspace: input = %q", m.shell.input) 98 } 99 } 100 101 func TestShellTabSwitchingStillWorks(t *testing.T) { 102 m := testModel(t, 120, 60) 103 m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}}) 104 if m.activeTab != tabProjects { 105 t.Fatalf("digit shortcut with empty prompt: tab = %d", m.activeTab) 106 } 107 m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'1'}}) 108 m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab}) 109 if m.activeTab != tabExperience { 110 t.Fatalf("tab key with empty prompt: tab = %d", m.activeTab) 111 } 112 } 113 114 func TestShellExitQuits(t *testing.T) { 115 m := testModel(t, 120, 60) 116 if cmd := typeString(&m, "exit\n"); !isQuit(cmd) { 117 t.Fatal("exit did not quit") 118 } 119 } 120 121 func TestShellCommands(t *testing.T) { 122 m := testModel(t, 120, 80) 123 typeString(&m, "ls\n") 124 typeString(&m, "cd projects\n") 125 typeString(&m, "pwd\n") 126 typeString(&m, "ls\n") 127 typeString(&m, "cat janitor/README.md\n") 128 typeString(&m, "cat nope\n") 129 typeString(&m, "cd ../bio\n") 130 typeString(&m, "rm -rf /\n") 131 typeString(&m, "cat ../../../../etc/passwd\n") 132 out := plainView(m) 133 for _, want := range []string{ 134 "about bio contact experience/ hobbies/ interests/ languages/ projects/", 135 "visitor@prime8:~/projects$ pwd", 136 "/home/visitor/projects", 137 "finger/ janitor/ summarizer/", 138 "sorts audio.", 139 "cat: nope: No such file or directory", 140 "cd: ../bio: Not a directory", 141 "rm: it is dangerous to operate recursively on '/'", 142 "cat: ../../../../etc/passwd: No such file or directory", 143 "visitor@prime8:~/projects$ █", 144 } { 145 if !strings.Contains(out, want) { 146 t.Errorf("missing %q in:\n%s", want, out) 147 } 148 } 149 } 150 151 func TestShellClear(t *testing.T) { 152 m := testModel(t, 120, 60) 153 typeString(&m, "ls\nclear\n") 154 out := plainView(m) 155 if strings.Contains(out, "cat bio") || strings.Contains(out, "experience/") { 156 t.Fatalf("clear left output behind:\n%s", out) 157 } 158 if !strings.Contains(out, "visitor@prime8:~$ █") { 159 t.Fatalf("prompt missing after clear:\n%s", out) 160 } 161 } 162 163 func TestShellCompletion(t *testing.T) { 164 m := testModel(t, 120, 60) 165 typeString(&m, "cat proj") 166 m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab}) 167 if m.shell.input != "cat projects/" { 168 t.Fatalf("completion: %q", m.shell.input) 169 } 170 typeString(&m, "ja") 171 m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab}) 172 if m.shell.input != "cat projects/janitor/" { 173 t.Fatalf("completion: %q", m.shell.input) 174 } 175 } 176 177 func TestShellCompletionRelativeToCwd(t *testing.T) { 178 m := testModel(t, 120, 60) 179 typeString(&m, "cd projects\n") 180 typeString(&m, "cat ja") 181 m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab}) 182 if m.shell.input != "cat janitor/" { 183 t.Fatalf("completion in cwd: %q", m.shell.input) 184 } 185 m.shell.input = "ls /ho" 186 m.handleKeyMsg(tea.KeyMsg{Type: tea.KeyTab}) 187 if m.shell.input != "ls /home/" { 188 t.Fatalf("absolute completion: %q", m.shell.input) 189 } 190 } 191 192 func TestShellUsesSessionUser(t *testing.T) { 193 m := testModel(t, 120, 80) 194 m.user = defaultUser("Dam/ian") 195 m.shell = newShellState(m.user) 196 m.syncViewport() 197 typeString(&m, "whoami\n") 198 typeString(&m, "pwd\n") 199 typeString(&m, "sudo ls\n") 200 typeString(&m, "cd ~/projects\n") 201 out := plainView(m) 202 for _, want := range []string{ 203 "damian@prime8:~$ whoami", 204 "/home/damian", 205 "damian is not in the sudoers file.", 206 "damian@prime8:~/projects$ █", 207 } { 208 if !strings.Contains(out, want) { 209 t.Errorf("missing %q in:\n%s", want, out) 210 } 211 } 212 if strings.Contains(out, "guest") { 213 t.Errorf("guest leaked:\n%s", out) 214 } 215 if got := m.windowTitle(); got != "prime8:~" { 216 t.Errorf("window title = %q", got) 217 } 218 m.shell = newShellState(m.user) 219 m.syncViewport() 220 if out := plainView(m); !strings.Contains(out, "damian@prime8:~$ cat bio") { 221 t.Errorf("intro prompt not using session user:\n%s", out) 222 } 223 } 224 225 func TestWindowTitleIsFull(t *testing.T) { 226 m := testModel(t, 120, 40) 227 m.activateTab(tabExperience) 228 if got := m.windowTitle(); got != "prime8:~/experience" { 229 t.Errorf("experience title = %q", got) 230 } 231 m.activateTab(tabProjects) 232 want := "prime8:~/projects/" + m.selectedProject().ID 233 if got := m.windowTitle(); got != want { 234 t.Errorf("project title = %q, want %q", got, want) 235 } 236 } 237 238 func TestProjectsSharedBorderIsAccent(t *testing.T) { 239 accent := "\x1b[33m" 240 for _, w := range []int{120, 90} { 241 m := testModel(t, w, 30) 242 m.activateTab(tabProjects) 243 for _, readme := range []bool{false, true} { 244 m.projectReadmeScroll = readme 245 view := m.View() 246 lines := strings.Split(view, "\n") 247 // every line holding a divider glyph must draw it in the accent color 248 found := false 249 for _, line := range lines { 250 for _, glyph := range []string{"┬", "┴", "├", "┤"} { 251 i := strings.Index(line, glyph) 252 if i < 0 { 253 continue 254 } 255 found = true 256 if j := strings.LastIndex(line[:i], "\x1b["); j < 0 || !strings.HasPrefix(line[j:], accent) { 257 t.Errorf("w=%d readme=%v: %s not accent-colored", w, readme, glyph) 258 } 259 } 260 } 261 if !found { 262 t.Errorf("w=%d readme=%v: no shared border junction found", w, readme) 263 } 264 for _, l := range strings.Split(ansi.Strip(view), "\n") { 265 if lipgloss.Width(l) > w { 266 t.Errorf("w=%d readme=%v: line overflows (%d)", w, readme, lipgloss.Width(l)) 267 } 268 } 269 } 270 } 271 } 272 273 // The chrome must follow the terminal theme: no fixed 256/truecolor 274 // colors and no painted backgrounds outside tags and the logo. 275 func TestChromeUsesTerminalPalette(t *testing.T) { 276 sgr := regexp.MustCompile(`\x1b\[([0-9;]*)m`) 277 for _, profile := range []termenv.Profile{termenv.TrueColor, termenv.ANSI256} { 278 m := testModel(t, 120, 40) 279 lipgloss.SetColorProfile(profile) // as the SSH handler does 280 m.wakaHours = "1,234 hrs" 281 // tags carry fixed brand fg/bg pairs on purpose; leave them out 282 m.about.Interests, m.about.Hobbies, m.about.Languages = nil, nil, nil 283 for i := range m.experience { 284 m.experience[i].Tech = nil 285 } 286 for _, tab := range []int{tabAbout, tabExperience} { 287 m.activateTab(tab) 288 view := m.View() 289 reverse := false 290 for _, match := range sgr.FindAllStringSubmatch(view, -1) { 291 params := strings.Split(match[1], ";") 292 for k := 0; k < len(params); k++ { 293 p := params[k] 294 switch { 295 case p == "38" || p == "48": 296 t.Errorf("profile %v tab %d: extended color %q", profile, tab, match[0]) 297 k = len(params) 298 case len(p) == 2 && p[0] == '4' && p != "49", len(p) == 3 && p[:2] == "10": 299 t.Errorf("profile %v tab %d: background %q", profile, tab, match[0]) 300 case p == "7": 301 reverse = true 302 } 303 } 304 } 305 if !reverse { 306 t.Errorf("profile %v tab %d: active tab not in reverse video", profile, tab) 307 } 308 } 309 } 310 } 311 312 func TestCursorShownAfterTabSwitch(t *testing.T) { 313 m := testModel(t, 120, 40) 314 m.shell.lastKey = time.Now().Add(-cursorBlink * 3) // mid "off" phase 315 m.activateTab(tabProjects) 316 m.activateTab(tabAbout) 317 if out := plainView(m); !strings.Contains(out, "█") { 318 t.Fatalf("cursor missing after switching back to about") 319 } 320 m.shell.lastKey = time.Now().Add(-cursorBlink * 3) 321 next, _ := m.Update(cursorBlinkMsg{}) 322 if out := plainView(next.(portfolioModel)); strings.Contains(out, "█") { 323 t.Fatalf("blink tick did not re-render the prompt (cursor stuck on)") 324 } 325 }