1package app
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "testing"
8
9 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/protocol/wire"
12 "github.com/stretchr/testify/require"
13)
14
15func TestParseEnvArgs(t *testing.T) {
16 tests := []struct {
17 name string
18 args []string
19 want envInvocation
20 wantErr string
21 }{
22 {name: "fish", args: []string{"fish"}, want: envInvocation{shell: "fish"}},
23 {name: "sh with session", args: []string{"sh", "-s", "work"}, want: envInvocation{shell: "sh", session: "work"}},
24 {name: "session before shell", args: []string{"-s", "work", "fish"}, want: envInvocation{shell: "fish", session: "work"}},
25 {name: "help without shell", args: []string{"--help"}, want: envInvocation{help: true}},
26 {name: "missing shell", wantErr: "requires a shell"},
27 {name: "two shells", args: []string{"fish", "sh"}, wantErr: "one shell"},
28 {name: "same shell twice", args: []string{"fish", "fish"}, wantErr: "one shell"},
29 {name: "unsupported shell", args: []string{"nu"}, wantErr: `unsupported shell "nu"`},
30 {name: "json belongs to cmd env", args: []string{"--json"}, wantErr: `unknown flag "--json"`},
31 {name: "old shell flag", args: []string{"--fish"}, wantErr: `unknown flag "--fish"`},
32 {name: "missing session", args: []string{"fish", "-s"}, wantErr: "requires a session name"},
33 }
34 for _, tt := range tests {
35 t.Run(tt.name, func(t *testing.T) {
36 got, err := parseEnvArgs(tt.args)
37 if tt.wantErr != "" {
38 require.ErrorContains(t, err, tt.wantErr)
39 require.Equal(t, 2, exitCode(err))
40 return
41 }
42 require.NoError(t, err)
43 require.Equal(t, tt.want, got)
44 })
45 }
46}
47
48func TestWriteShellEnvironment(t *testing.T) {
49 encoded := `{"WAYLAND_DISPLAY":"wayland-1","SSH_AUTH_SOCK":null,"ODD":"it's \\ $HOME"}`
50 tests := []struct {
51 name string
52 shell string
53 encoded string
54 want string
55 wantErr string
56 }{
57 {
58 name: "fish sets and erases",
59 shell: "fish",
60 encoded: encoded,
61 want: "set -gx ODD 'it\\'s \\\\ $HOME'\nset -e -g SSH_AUTH_SOCK\nset -gx WAYLAND_DISPLAY 'wayland-1'\n",
62 },
63 {
64 name: "sh exports and unsets",
65 shell: "sh",
66 encoded: encoded,
67 want: "export ODD='it'\\''s \\ $HOME'\nunset SSH_AUTH_SOCK\nexport WAYLAND_DISPLAY='wayland-1'\n",
68 },
69 {name: "empty object", shell: "sh", encoded: `{}`, want: ""},
70 {name: "injected name", shell: "sh", encoded: `{"BAD;rm":"x"}`, wantErr: "invalid session environment name"},
71 {name: "leading digit", shell: "sh", encoded: `{"1X":"x"}`, wantErr: "invalid session environment name"},
72 {name: "not json", shell: "sh", encoded: `not json`, wantErr: "decoding session environment"},
73 }
74 for _, tt := range tests {
75 t.Run(tt.name, func(t *testing.T) {
76 var out bytes.Buffer
77 err := writeShellEnvironment(&out, tt.shell, tt.encoded)
78 if tt.wantErr != "" {
79 require.ErrorContains(t, err, tt.wantErr)
80 require.Empty(t, out.String())
81 return
82 }
83 require.NoError(t, err)
84 require.Equal(t, tt.want, out.String())
85 })
86 }
87}
88
89func TestRunEnvWithDeps(t *testing.T) {
90 const reply = `{"SSH_AUTH_SOCK":null,"WAYLAND_DISPLAY":"wayland-1"}` + "\n"
91 tests := []struct {
92 name string
93 invocation envInvocation
94 vev string
95 result protocol.CommandResult
96 want string
97 wantErr string
98 wantCode int
99 wantRequest protocol.CommandRequest
100 }{
101 {
102 name: "inside a pane follows the pane session",
103 invocation: envInvocation{shell: "fish"},
104 vev: "session=work,tab=t_1,pane=p_1",
105 result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: reply},
106 want: "set -e -g SSH_AUTH_SOCK\nset -gx WAYLAND_DISPLAY 'wayland-1'\n",
107 wantRequest: protocol.CommandRequest{Slug: "env", JSON: true, TargetSession: "work", TargetTab: "t_1", TargetPane: "p_1"},
108 },
109 {
110 name: "explicit session outside a pane",
111 invocation: envInvocation{shell: "sh", session: "work"},
112 result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: reply},
113 want: "unset SSH_AUTH_SOCK\nexport WAYLAND_DISPLAY='wayland-1'\n",
114 wantRequest: protocol.CommandRequest{Slug: "env", JSON: true, TargetSession: "work"},
115 },
116 {
117 name: "explicit session inside a pane targets that session only",
118 invocation: envInvocation{shell: "sh", session: "other"},
119 vev: "session=work,tab=t_1,pane=p_1",
120 result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: reply},
121 want: "unset SSH_AUTH_SOCK\nexport WAYLAND_DISPLAY='wayland-1'\n",
122 wantRequest: protocol.CommandRequest{Slug: "env", JSON: true, TargetSession: "other"},
123 },
124 {
125 name: "invalid daemon output prints no shell code",
126 invocation: envInvocation{shell: "sh", session: "work"},
127 result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded, Output: `{"BAD;rm":"x"}`},
128 wantErr: "invalid session environment name",
129 wantCode: 1,
130 },
131 {
132 name: "outside a pane without a session",
133 invocation: envInvocation{shell: "fish"},
134 wantErr: "inside a vev pane",
135 wantCode: 2,
136 },
137 {
138 name: "daemon failure prints no shell code",
139 invocation: envInvocation{shell: "fish", session: "gone"},
140 result: protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no such session: gone"},
141 wantErr: "no such session: gone",
142 wantCode: 1,
143 },
144 {
145 name: "help",
146 invocation: envInvocation{help: true},
147 want: envHelp + "\n",
148 },
149 }
150 for _, tt := range tests {
151 t.Run(tt.name, func(t *testing.T) {
152 stream := newSeamBrokerStream(tt.result)
153 service := newSeamBrokerService(stream)
154 var out bytes.Buffer
155 err := runEnvWithDeps(context.Background(), tt.invocation, cmdDeps{
156 stdout: &out,
157 getenv: func(string) string { return tt.vev },
158 connect: func(context.Context) (ports.BrokerService, error) { return service, nil },
159 dial: func(context.Context, string) (wire.Transport, error) {
160 return nil, errors.New("unexpected dial")
161 },
162 })
163 if tt.wantErr != "" {
164 require.ErrorContains(t, err, tt.wantErr)
165 require.Equal(t, tt.wantCode, exitCode(err))
166 require.Empty(t, out.String())
167 return
168 }
169 require.NoError(t, err)
170 require.Equal(t, tt.want, out.String())
171 if tt.wantRequest.Slug == "" {
172 require.Zero(t, stream.sentCount())
173 return
174 }
175 stream.mu.Lock()
176 require.Len(t, stream.sent, 1)
177 request, ok := stream.sent[0].(protocol.CommandRequest)
178 stream.mu.Unlock()
179 require.True(t, ok)
180 require.Equal(t, tt.wantRequest.Slug, request.Slug)
181 require.Equal(t, tt.wantRequest.JSON, request.JSON)
182 require.Equal(t, tt.wantRequest.TargetSession, request.TargetSession)
183 require.Equal(t, tt.wantRequest.TargetTab, request.TargetTab)
184 require.Equal(t, tt.wantRequest.TargetPane, request.TargetPane)
185 })
186 }
187}