v0.8.1 1f54f61

socket_test.go RawHistoryFolder

1package uidriver
2
3import (
4	"bufio"
5	"bytes"
6	"context"
7	"encoding/json"
8	"net"
9	"os"
10	"path/filepath"
11	"runtime"
12	"strings"
13	"testing"
14	"time"
15
16	"github.com/bnema/vev/internal/ports"
17	portsmocks "github.com/bnema/vev/internal/ports/mocks"
18	"github.com/bnema/vev/pkg/safedir"
19	"github.com/stretchr/testify/mock"
20	"github.com/stretchr/testify/require"
21)
22
23func TestValidateSocketPathUsesPlatformLimit(t *testing.T) {
24	const (
25		expectedDarwinSocketMax = 103
26		expectedLinuxSocketMax  = 107
27	)
28	expectedMax := expectedLinuxSocketMax
29	switch runtime.GOOS {
30	case "darwin", "freebsd", "openbsd", "netbsd", "dragonfly":
31		expectedMax = expectedDarwinSocketMax
32	}
33	tests := []struct {
34		name string
35		path string
36		want bool
37	}{
38		{name: "at limit", path: "/" + strings.Repeat("a", expectedMax-1), want: true},
39		{name: "above limit", path: "/" + strings.Repeat("a", expectedMax), want: false},
40	}
41	for _, test := range tests {
42		t.Run(test.name, func(t *testing.T) {
43			err := validateSocketPath(test.path)
44			if test.want {
45				require.NoError(t, err)
46			} else {
47				require.Error(t, err)
48			}
49		})
50	}
51}
52
53func TestListenUnixServesPrivateAttachmentAndCleansOwnedSocket(t *testing.T) {
54	service := portsmocks.NewMockUIService(t)
55	service.EXPECT().Capture("attachment").Return(testSnapshot(), nil).Once()
56	directory := shortDir(t)
57	path := filepath.Join(directory, "ui.sock")
58	server := New(service, nil)
59	endpoint, err := ListenUnix(path, server, func() Ready {
60		return testReady(false)
61	})
62	require.NoError(t, err)
63	require.Equal(t, os.FileMode(0o600), fileMode(t, path))
64	defer func() { _ = endpoint.Close() }()
65
66	dialer := &net.Dialer{Timeout: time.Second}
67	conn, err := dialer.DialContext(context.Background(), "unix", path)
68	require.NoError(t, err)
69	defer func() { _ = conn.Close() }()
70	decoder := json.NewDecoder(bufio.NewReader(conn))
71	var ready envelope
72	require.NoError(t, decoder.Decode(&ready))
73	require.Zero(t, ready.ID)
74	_, err = conn.Write([]byte(`{"version":1,"id":1,"op":"capture","attachment":"attachment"}` + "\n"))
75	require.NoError(t, err)
76	var response envelope
77	require.NoError(t, decoder.Decode(&response))
78	require.Equal(t, uint64(1), response.ID)
79	require.Nil(t, response.Error)
80
81	require.NoError(t, endpoint.Close())
82	_, err = os.Lstat(path)
83	require.ErrorIs(t, err, os.ErrNotExist)
84}
85
86func TestListenUnixRejectsUnsafeParentSymlink(t *testing.T) {
87	root := shortDir(t)
88	target := filepath.Join(root, "target")
89	require.NoError(t, os.Mkdir(target, 0o700))
90	link := filepath.Join(root, "link")
91	require.NoError(t, os.Symlink(target, link))
92	_, err := ListenUnix(filepath.Join(link, "ui.sock"), New(nil, nil), nil)
93	require.Error(t, err)
94}
95
96func TestListenUnixRejectsExistingPathWithoutRemovingIt(t *testing.T) {
97	path := filepath.Join(shortDir(t), "ui.sock")
98	require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
99	_, err := ListenUnix(path, New(nil, nil), nil)
100	require.Error(t, err)
101	data, readErr := os.ReadFile(path)
102	require.NoError(t, readErr)
103	require.Equal(t, []byte("keep"), data)
104}
105
106func TestUnixEndpointClosePreservesReplacementPath(t *testing.T) {
107	directory := shortDir(t)
108	path := filepath.Join(directory, "ui.sock")
109	endpoint, err := ListenUnix(path, New(nil, nil), nil)
110	require.NoError(t, err)
111	defer func() { _ = endpoint.Close() }()
112
113	replacement := filepath.Join(directory, "replacement")
114	require.NoError(t, os.Rename(path, replacement))
115	require.NoError(t, os.WriteFile(path, []byte("keep"), 0o600))
116	require.NoError(t, endpoint.Close())
117
118	data, err := os.ReadFile(path)
119	require.NoError(t, err)
120	require.Equal(t, []byte("keep"), data)
121	require.NoError(t, os.Remove(replacement))
122}
123
124func TestServeUsesAttachmentWideActionLimitAcrossConnections(t *testing.T) {
125	service := portsmocks.NewMockUIService(t)
126	started := make(chan struct{})
127	release := make(chan struct{})
128	service.EXPECT().Action(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, _ ports.UIActionRequest) (ports.UIActionResult, error) {
129		close(started)
130		select {
131		case <-release:
132			return ports.UIActionResult{ActionID: 1, Accepted: true, Status: ports.UIActionProcessed}, nil
133		case <-ctx.Done():
134			return ports.UIActionResult{}, ctx.Err()
135		}
136	}).Once()
137	server := New(service, nil)
138	firstHost, firstPeer := net.Pipe()
139	secondHost, secondPeer := net.Pipe()
140	defer func() { _ = firstPeer.Close() }()
141	defer func() { _ = secondPeer.Close() }()
142	firstDone := make(chan error, 1)
143	secondDone := make(chan error, 1)
144	go func() { firstDone <- server.Serve(context.Background(), firstHost, testReady(true)) }()
145	go func() { secondDone <- server.Serve(context.Background(), secondHost, testReady(true)) }()
146	firstDecoder := json.NewDecoder(firstPeer)
147	secondDecoder := json.NewDecoder(secondPeer)
148	require.Zero(t, readEnvelope(t, firstDecoder).ID)
149	require.Zero(t, readEnvelope(t, secondDecoder).ID)
150	request := `{"version":1,"id":1,"op":"text","attachment":"a","generation":1,"text":"x"}` + "\n"
151	_, err := firstPeer.Write([]byte(request))
152	require.NoError(t, err)
153	select {
154	case <-started:
155	case <-time.After(time.Second):
156		t.Fatal("first action did not start")
157	}
158	_, err = secondPeer.Write([]byte(request))
159	require.NoError(t, err)
160	busy := readEnvelope(t, secondDecoder)
161	require.Equal(t, ports.UIErrBusy, busy.Error.Code)
162	close(release)
163	processed := readEnvelope(t, firstDecoder)
164	require.Nil(t, processed.Error)
165	require.NoError(t, firstPeer.Close())
166	require.NoError(t, secondPeer.Close())
167	select {
168	case <-firstDone:
169	case <-time.After(time.Second):
170		t.Fatal("first socket server did not stop")
171	}
172	select {
173	case <-secondDone:
174	case <-time.After(time.Second):
175		t.Fatal("second socket server did not stop")
176	}
177}
178
179func TestBridgeUsesExistingSocketWithoutCreatingAttachment(t *testing.T) {
180	directory := shortDir(t)
181	path := filepath.Join(directory, "ui.sock")
182	endpoint, err := ListenUnix(path, New(nil, nil), func() Ready { return testReady(false) })
183	require.NoError(t, err)
184	defer func() { _ = endpoint.Close() }()
185
186	var output bytes.Buffer
187	require.NoError(t, Bridge(context.Background(), path, strings.NewReader(""), &output))
188	decoder := json.NewDecoder(&output)
189	var ready envelope
190	require.NoError(t, decoder.Decode(&ready))
191	require.Zero(t, ready.ID)
192}
193
194// shortDir returns a private directory whose absolute path stays inside the
195// unix socket length limit. It is created under the system temporary
196// directory: the checkout's own path can already be long enough to overflow
197// the limit in a nested worktree.
198func shortDir(t *testing.T) string {
199	t.Helper()
200	base, err := filepath.EvalSymlinks(os.TempDir())
201	require.NoError(t, err)
202	directory, err := os.MkdirTemp(base, "vev-ui")
203	require.NoError(t, err)
204	directory, err = filepath.Abs(directory)
205	require.NoError(t, err)
206	require.NoError(t, safedir.EnsurePrivate(directory))
207	t.Cleanup(func() { require.NoError(t, os.RemoveAll(directory)) })
208	return directory
209}
210
211func fileMode(t *testing.T, path string) os.FileMode {
212	t.Helper()
213	info, err := os.Stat(path)
214	require.NoError(t, err)
215	return info.Mode().Perm()
216}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close