shell_cmds_test.go (7380B)
1 package tui 2 3 import ( 4 "strings" 5 "testing" 6 7 "github.com/charmbracelet/x/ansi" 8 ) 9 10 // run executes lines in a fresh session on the test model and returns the 11 // plain output of the last one. 12 func runShell(t *testing.T, m *portfolioModel, lines ...string) string { 13 t.Helper() 14 var res shellResult 15 for _, l := range lines { 16 res = m.shell.exec(m.shellEnv(), l) 17 } 18 return ansi.Strip(res.output) 19 } 20 21 func TestShellWritesStayInSessionOverlay(t *testing.T) { 22 m := testModel(t, 120, 60) 23 out := runShell(t, &m, 24 "mkdir -p notes/deep/er", 25 "echo hello > notes/a", 26 "echo world >> notes/a", 27 "cat notes/a", 28 ) 29 if out != "hello\nworld" { 30 t.Fatalf("cat after redirects = %q", out) 31 } 32 // a second session (another SSH user) sees none of it 33 other := testModel(t, 120, 60) 34 if got := runShell(t, &other, "ls notes"); !strings.Contains(got, "No such file or directory") { 35 t.Fatalf("overlay leaked into another session: %q", got) 36 } 37 // and the generated base tree is untouched 38 if m.shellEnv().root.Lookup(m.shell.home+"/notes") != nil { 39 t.Fatal("base vfs was mutated") 40 } 41 } 42 43 func TestShellFileCommands(t *testing.T) { 44 m := testModel(t, 120, 60) 45 cases := []struct{ line, want string }{ 46 {"touch f && ls -1 | grep '^f$'", "f"}, 47 {"cp bio bio2 && wc -l bio2 | grep -c bio2", "1"}, 48 {"mv bio2 bio3; ls bio2", "ls: cannot access 'bio2': No such file or directory"}, 49 {"cat bio3 | head -n 1 | wc -l", "1"}, 50 {"rm bio3 && ls bio3 2>&1", "ls: cannot access 'bio3': No such file or directory"}, 51 {"mkdir d && rmdir d && echo ok", "ok"}, 52 {"mkdir d2 && touch d2/x && rmdir d2", "rmdir: failed to remove 'd2': Directory not empty"}, 53 {"rm d2", "rm: cannot remove 'd2': Is a directory"}, 54 {"rm -rf d2 && echo gone", "gone"}, 55 {"rm projects/janitor/info 2>/dev/null; ls projects/janitor", "README.md"}, 56 {"rm -r /etc", "rm: cannot remove '/etc': No such file or directory"}, 57 {"touch /root", "touch: cannot touch '/root': Permission denied"}, 58 {"echo x > /home/x", "bash: /home/x: Permission denied"}, 59 {"echo hi > /tmp/t; cat /tmp/t", "hi"}, 60 {"printf x", "printf: command not found"}, 61 {"false || echo fallback", "fallback"}, 62 {"false && echo no; echo $?", "1"}, 63 {"FOO=bar; echo \"$FOO baz\" '$FOO'", "bar baz $FOO"}, 64 {"echo -n a; echo b", "ab"}, 65 {"cp -r projects p2 && ls p2 | wc -l", "3"}, 66 {"mv p2 p2/inner", "mv: cannot move 'p2' to a subdirectory of itself, '~/p2/inner'"}, 67 } 68 for _, c := range cases { 69 got := runShell(t, &m, c.line) 70 if !strings.Contains(got, c.want) { 71 t.Errorf("%s\n got: %q\nwant: %q", c.line, got, c.want) 72 } 73 } 74 // rm on base files is a whiteout in the overlay, not a real delete 75 if got := runShell(t, &m, "ls projects/janitor"); strings.Contains(got, "info") { 76 t.Errorf("whiteout not applied: %q", got) 77 } 78 runShell(t, &m, "restore") 79 if got := runShell(t, &m, "ls projects/janitor"); !strings.Contains(got, "info") { 80 t.Errorf("restore did not bring back base files: %q", got) 81 } 82 } 83 84 func TestShellTextCommands(t *testing.T) { 85 m := testModel(t, 120, 60) 86 runShell(t, &m, `echo -e "b\na\nb\nc" > l`) 87 cases := []struct{ line, want string }{ 88 {"sort l | uniq", "a\nb\nc"}, 89 {"sort -r l | head -2", "c\nb"}, 90 {"tail -n 1 l", "c"}, 91 {"wc l", "4 4 8 l"}, 92 {"wc -l < l", "4"}, 93 {"grep -n b l", "1:b\n3:b"}, 94 {"grep -c b l", "2"}, 95 {"grep -v b l | tr", "tr: command not found"}, 96 {"sort l | uniq -c | head -1", " 1 a"}, 97 {"cat -n l | head -1", " 1\tb"}, 98 {"grep -ri janitor projects | head -1", "projects/janitor/"}, 99 } 100 for _, c := range cases { 101 if got := runShell(t, &m, c.line); got != c.want && !strings.Contains(got, c.want) { 102 t.Errorf("%s\n got: %q\nwant: %q", c.line, got, c.want) 103 } 104 } 105 } 106 107 func TestShellSystemCommands(t *testing.T) { 108 m := testModel(t, 120, 60) 109 cases := []struct{ line, want string }{ 110 {"hostname", "prime8"}, 111 {"uname", "Linux"}, 112 {"uname -a", "Linux prime8 6.12.0-prime8 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux"}, 113 {"date", "Wed Oct 21 12:00:00 UTC 2026"}, 114 {"uptime", "up "}, 115 {"whoami", "visitor"}, 116 {"id", "uid=1000(visitor)"}, 117 {"man tree", "TREE(1)\n\n tree [-a] [path]"}, 118 {"man nope", "No manual entry for nope"}, 119 {"help", "fetch whoami id hostname uname date uptime"}, 120 {"fetch | head -2", "@prime8\n---"}, 121 {"which ls cd", "/usr/bin/ls\ncd: shell built-in command"}, 122 {"echo $HOME", "/home/visitor"}, 123 {"cd projects; cd -", "/home/visitor"}, 124 {"echo 'unterminated", "unexpected EOF while looking for matching"}, 125 {"ls |", "syntax error: unexpected end of file"}, 126 } 127 for _, c := range cases { 128 if got := runShell(t, &m, c.line); !strings.Contains(got, c.want) { 129 t.Errorf("%s\n got: %q\nwant: %q", c.line, got, c.want) 130 } 131 } 132 got := runShell(t, &m, "history") 133 if !strings.Contains(got, " 1 hostname") || !strings.Contains(got, "history") { 134 t.Errorf("history = %q", got) 135 } 136 runShell(t, &m, "history -c") 137 if got := runShell(t, &m, "history"); got != " 1 history" { 138 t.Errorf("history after -c = %q", got) 139 } 140 } 141 142 func TestShellTree(t *testing.T) { 143 m := testModel(t, 120, 60) 144 runShell(t, &m, "mkdir -p t/a/b && touch t/a/b/c t/z t/.hidden") 145 got := runShell(t, &m, "tree t") 146 want := "t\n├── a\n│ └── b\n│ └── c\n└── z\n\n2 directories, 2 files" 147 if got != want { 148 t.Fatalf("tree:\n%s\nwant:\n%s", got, want) 149 } 150 if got := runShell(t, &m, "tree -a t"); !strings.Contains(got, "├── .hidden") || !strings.Contains(got, "2 directories, 3 files") { 151 t.Fatalf("tree -a:\n%s", got) 152 } 153 } 154 155 func TestShellGlobs(t *testing.T) { 156 m := testModel(t, 120, 60) 157 if got := runShell(t, &m, "echo projects/j*"); got != "projects/janitor" { 158 t.Errorf("glob = %q", got) 159 } 160 if got := runShell(t, &m, "echo 'projects/j*'"); got != "projects/j*" { 161 t.Errorf("quoted glob = %q", got) 162 } 163 if got := runShell(t, &m, "echo nomatch*"); got != "nomatch*" { 164 t.Errorf("unmatched glob = %q", got) 165 } 166 } 167 168 func TestShellOverlayQuota(t *testing.T) { 169 m := testModel(t, 120, 60) 170 runShell(t, &m, "cat bio bio bio bio bio bio bio bio > big") 171 var got string 172 for i := 0; i < 40; i++ { 173 got = runShell(t, &m, "cat big big > big2 && mv big2 big") 174 if strings.Contains(got, "No space left on device") { 175 break 176 } 177 } 178 if !strings.Contains(got, "No space left on device") { 179 t.Fatalf("quota never hit: %q (used %d)", got, m.shell.overlay.Used()) 180 } 181 } 182 183 func TestShellClearThenOutput(t *testing.T) { 184 m := testModel(t, 120, 60) 185 typeString(&m, "ls\n") 186 typeString(&m, "clear; echo fresh\n") 187 out := plainView(m) 188 if strings.Contains(out, "experience/") || strings.Contains(out, "$ clear") { 189 t.Fatalf("clear left output behind:\n%s", out) 190 } 191 if !strings.Contains(out, "fresh") { 192 t.Fatalf("output after clear missing:\n%s", out) 193 } 194 } 195 196 func TestShellCommandCompletion(t *testing.T) { 197 m := testModel(t, 120, 60) 198 for in, want := range map[string]string{ 199 "tre": "tree ", 200 "ls | gr": "ls | grep ", 201 "ls; una": "ls; uname ", 202 "echo x > bi": "echo x > bio ", 203 "cat bio && h": "cat bio && h", // head/help/history/hostname: ambiguous 204 } { 205 m.shell.input = in 206 m.shell.complete(m.shellFS().Root()) 207 if m.shell.input != want { 208 t.Errorf("complete(%q) = %q, want %q", in, m.shell.input, want) 209 } 210 } 211 runShell(t, &m, "mkdir mynotes") 212 m.shell.input = "cd myn" 213 m.shell.complete(m.shellFS().Root()) 214 if m.shell.input != "cd mynotes/" { 215 t.Errorf("overlay dir completion = %q", m.shell.input) 216 } 217 }