prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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 }