1package daemon
2
3import (
4 "context"
5 "errors"
6 "slices"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/bnema/vev/internal/ports"
12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
13 "github.com/stretchr/testify/mock"
14)
15
16func TestBarScriptSanitizeOutput(t *testing.T) {
17 tests := []struct {
18 name string
19 raw string
20 limit int
21 want string
22 }{
23 {name: "first line only", raw: "one\ntwo\n", limit: barScriptOutputLimit, want: "one"},
24 {name: "ansi stripped", raw: "\x1b[31mred\x1b[0m", limit: barScriptOutputLimit, want: "red"},
25 {name: "controls stripped", raw: "a\x00b\x07c", limit: barScriptOutputLimit, want: "abc"},
26 {name: "osc stripped", raw: "a\x1b]0;bad\ab", limit: barScriptOutputLimit, want: "ab"},
27 {name: "charset escape stripped", raw: "a\x1b(Bb\x1b)0c", limit: barScriptOutputLimit, want: "abc"},
28 {name: "non csi escape stripped", raw: "a\x1b7b\x1b8c", limit: barScriptOutputLimit, want: "abc"},
29 {name: "c1 controls stripped", raw: "a\u009b31mb\u009d0;bad\ac", limit: barScriptOutputLimit, want: "abc"},
30 {name: "utf8 and nerd font preserved", raw: " 14:32 ↑3", limit: barScriptOutputLimit, want: " 14:32 ↑3"},
31 {name: "display capped", raw: strings.Repeat("a", barScriptDisplayLimit+10), limit: barScriptOutputLimit, want: strings.Repeat("a", barScriptDisplayLimit)},
32 {name: "capture capped before first line", raw: strings.Repeat("a", 8) + "\nignored", limit: 4, want: "aaaa"},
33 }
34
35 for _, tt := range tests {
36 t.Run(tt.name, func(t *testing.T) {
37 if got := sanitizeBarScriptOutput([]byte(tt.raw), tt.limit); got != tt.want {
38 t.Fatalf("sanitizeBarScriptOutput() = %q, want %q", got, tt.want)
39 }
40 })
41 }
42}
43
44func TestBarScriptContextEnv(t *testing.T) {
45 base := []string{
46 "PATH=/bin",
47 "VEV_ANCHOR=old",
48 "VEV_SESSION=old",
49 "VEV_TAB=old",
50 "VEV_PANE=old",
51 "VEV_PANE_CWD=/old",
52 "VEV_COLS=1",
53 }
54 ctx := barScriptContext{Anchor: "top-right", Session: "work", Tab: "2", Pane: "pane-3", PaneCWD: "/repo", Cols: 120}
55 got := ctx.env(base)
56
57 want := map[string]string{
58 "VEV_ANCHOR": "top-right",
59 "VEV_SESSION": "work",
60 "VEV_TAB": "2",
61 "VEV_PANE": "pane-3",
62 "VEV_PANE_CWD": "/repo",
63 "VEV_COLS": "120",
64 }
65 seen := map[string]int{}
66 for _, entry := range got {
67 key, val, ok := strings.Cut(entry, "=")
68 if !ok {
69 continue
70 }
71 if _, isVEV := want[key]; isVEV {
72 seen[key]++
73 if val != want[key] {
74 t.Fatalf("%s = %q, want %q in env %v", key, val, want[key], got)
75 }
76 }
77 }
78 for key := range want {
79 if seen[key] != 1 {
80 t.Fatalf("%s appears %d times, want 1 in env %v", key, seen[key], got)
81 }
82 }
83}
84
85func TestBarScriptRunner(t *testing.T) {
86 t.Run("nil runner returns explicit error for command", func(t *testing.T) {
87 runner := barScriptRunner{}
88 got, err := runner.run(context.Background(), "date", nil, barScriptContext{})
89 if err == nil || err.Error() != "bar script command runner is nil" {
90 t.Fatalf("run err = %v, want explicit nil runner error", err)
91 }
92 if got != "" {
93 t.Fatalf("run output = %q, want empty", got)
94 }
95 })
96
97 t.Run("empty command skips port runner", func(t *testing.T) {
98 runner := barScriptRunner{}
99 got, err := runner.run(context.Background(), " \t\n ", nil, barScriptContext{})
100 if err != nil {
101 t.Fatalf("run err = %v, want nil", err)
102 }
103 if got != "" {
104 t.Fatalf("run output = %q, want empty", got)
105 }
106 })
107
108 t.Run("success sanitizes first line and passes command spec", func(t *testing.T) {
109 portRunner := portsmocks.NewMockShellCommandRunner(t)
110 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
111 return spec.Command == "printf 'ok\\nignored'" &&
112 spec.Timeout == 50*time.Millisecond &&
113 spec.StdoutLimit == barScriptOutputLimit &&
114 containsEnv(spec.Env, "PATH=/bin") &&
115 containsEnv(spec.Env, "VEV_ANCHOR=top-right") &&
116 containsEnv(spec.Env, "VEV_SESSION=work") &&
117 containsEnv(spec.Env, "VEV_TAB=tab-1") &&
118 containsEnv(spec.Env, "VEV_PANE=pane-1") &&
119 containsEnv(spec.Env, "VEV_PANE_CWD=/repo") &&
120 containsEnv(spec.Env, "VEV_COLS=120") &&
121 !containsEnv(spec.Env, "VEV_ANCHOR=old")
122 })).Return(ports.ShellCommandResult{Stdout: []byte("\x1b[31mok\x1b[0m\nignored")}, nil)
123 runner := barScriptRunner{runner: portRunner, timeout: 50 * time.Millisecond}
124
125 got, err := runner.run(context.Background(), "printf 'ok\\nignored'",
126 []string{"PATH=/bin", "VEV_ANCHOR=old"},
127 barScriptContext{Anchor: "top-right", Session: "work", Tab: "tab-1", Pane: "pane-1", PaneCWD: "/repo", Cols: 120})
128 if err != nil {
129 t.Fatalf("run err = %v, want nil", err)
130 }
131 if got != "ok" {
132 t.Fatalf("run output = %q, want %q", got, "ok")
133 }
134 })
135
136 t.Run("port failure returns sanitized partial stdout and error", func(t *testing.T) {
137 wantErr := errors.New("exit 1")
138 portRunner := portsmocks.NewMockShellCommandRunner(t)
139 portRunner.EXPECT().Run(mock.Anything, mock.Anything).
140 Return(ports.ShellCommandResult{Stdout: []byte("partial\x00 output\nignored"), ExitCode: 1}, wantErr)
141 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
142
143 got, err := runner.run(context.Background(), "false", nil, barScriptContext{})
144 if !errors.Is(err, wantErr) {
145 t.Fatalf("run err = %v, want %v", err, wantErr)
146 }
147 if got != "partial output" {
148 t.Fatalf("run output = %q, want %q", got, "partial output")
149 }
150 })
151
152 t.Run("timeout propagates deadline", func(t *testing.T) {
153 portRunner := portsmocks.NewMockShellCommandRunner(t)
154 portRunner.EXPECT().Run(mock.Anything, mock.Anything).RunAndReturn(
155 func(ctx context.Context, _ ports.CommandSpec) (ports.ShellCommandResult, error) {
156 <-ctx.Done()
157 return ports.ShellCommandResult{ExitCode: -1}, ctx.Err()
158 })
159 runner := barScriptRunner{runner: portRunner, timeout: time.Nanosecond}
160
161 _, err := runner.run(context.Background(), "sleep 1", nil, barScriptContext{})
162 if !errors.Is(err, context.DeadlineExceeded) {
163 t.Fatalf("run err = %v, want %v", err, context.DeadlineExceeded)
164 }
165 })
166}
167
168func TestBarScriptRunnerBoundsStdoutBeforeSanitize(t *testing.T) {
169 portRunner := portsmocks.NewMockShellCommandRunner(t)
170 portRunner.EXPECT().Run(mock.Anything, mock.MatchedBy(func(spec ports.CommandSpec) bool {
171 return spec.StdoutLimit == barScriptOutputLimit
172 })).Return(ports.ShellCommandResult{Stdout: []byte(strings.Repeat("a", 4096))}, nil)
173 runner := barScriptRunner{runner: portRunner, timeout: time.Second}
174
175 got, err := runner.run(context.Background(), "long-output", nil, barScriptContext{})
176 if err != nil {
177 t.Fatalf("run bounded output: %v", err)
178 }
179 if len(got) > barScriptDisplayLimit {
180 t.Fatalf("output length = %d, want <= %d", len(got), barScriptDisplayLimit)
181 }
182}
183
184func containsEnv(env []string, entry string) bool {
185 return slices.Contains(env, entry)
186}