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