v0.1.0 7deb448

run_test.go RawHistoryFolder

1package app
2
3import (
4	"bufio"
5	"bytes"
6	"context"
7	"encoding/json"
8	"errors"
9	"io"
10	"log/slog"
11	"net"
12	"os"
13	"os/exec"
14	"path/filepath"
15	"strings"
16	"testing"
17	"time"
18
19	"github.com/bnema/vev/internal/adapters/lifecycle"
20	"github.com/bnema/vev/internal/domain"
21	"github.com/bnema/vev/internal/persist"
22	"github.com/bnema/vev/internal/ports"
23	portsmocks "github.com/bnema/vev/internal/ports/mocks"
24	"github.com/bnema/vev/internal/usecase/client"
25	"github.com/bnema/vev/internal/usecase/confirm"
26	"github.com/bnema/vev/internal/usecase/daemon"
27	"github.com/bnema/vev/pkg/kv"
28	"github.com/bnema/vev/pkg/safedir"
29	"github.com/stretchr/testify/mock"
30	"github.com/stretchr/testify/require"
31)
32
33func decodeJSONLogs(t *testing.T, data []byte) []map[string]any {
34	t.Helper()
35	var entries []map[string]any
36	scanner := bufio.NewScanner(bytes.NewReader(data))
37	for scanner.Scan() {
38		var entry map[string]any
39		require.NoError(t, json.Unmarshal(scanner.Bytes(), &entry))
40		entries = append(entries, entry)
41	}
42	require.NoError(t, scanner.Err())
43	return entries
44}
45
46func eventNames(entries []map[string]any) []string {
47	names := make([]string, 0, len(entries))
48	for _, entry := range entries {
49		if name, ok := entry["msg"].(string); ok {
50			names = append(names, name)
51		}
52	}
53	return names
54}
55
56func requireEvent(t *testing.T, entries []map[string]any, name string) map[string]any {
57	t.Helper()
58	for _, entry := range entries {
59		if entry["msg"] == name {
60			return entry
61		}
62	}
63	t.Fatalf("event %q not found in %v", name, eventNames(entries))
64	return nil
65}
66
67func TestRecoveryObservability(t *testing.T) {
68	var logBuffer bytes.Buffer
69	log := slog.New(slog.NewJSONHandler(&logBuffer, nil))
70	owner := fakeLifecycleOwnership{release: func() error { return nil }}
71	deps := lifecycleStartupDeps{
72		ensurePrivate: func(string) error { return nil },
73		acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
74			return owner, nil
75		},
76		log: log,
77	}
78	ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
79	records := []domain.CatalogueRecord{
80		{Committed: ref},
81		{Committed: ref},
82		{},
83		{Committed: ref, DegradedReason: "checkpoint validation failed"},
84	}
85	require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error {
86		logCatalogueRecovery(log, records, "current")
87		logStartupRecoveryCounts(log, records, 0)
88		return nil
89	}, deps))
90
91	entries := decodeJSONLogs(t, logBuffer.Bytes())
92	for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "daemon_startup_complete", "lifecycle_owner_released"} {
93		require.Contains(t, eventNames(entries), name)
94	}
95	startup := requireEvent(t, entries, "daemon_startup_complete")
96	require.EqualValues(t, 2, startup["healthy"])
97	require.EqualValues(t, 1, startup["fresh"])
98	require.EqualValues(t, 0, startup["restoring"])
99	require.EqualValues(t, 1, startup["broken"])
100	require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete")
101}
102
103// A session-scoped conflict no longer aborts startup, so the daemon comes up
104// looking healthy. Startup must therefore name the fenced session in the log,
105// persist a notice for it, and never report the recovery as clean.
106type fakeLifecycleOwnership struct {
107	release func() error
108}
109
110func (o fakeLifecycleOwnership) Release() error { return o.release() }
111
112type fakeLifecycleProbe struct {
113	owner lifecycleOwnership
114	err   error
115}
116
117func (p fakeLifecycleProbe) TryAcquire(string) (lifecycleOwnership, error) {
118	return p.owner, p.err
119}
120
121func TestJoinLifecycleReleaseError(t *testing.T) {
122	operationErr := errors.New("operation failed")
123	releaseErr := errors.New("release failed")
124
125	tests := []struct {
126		name         string
127		operationErr error
128		releaseErr   error
129		wantErrors   []error
130	}{
131		{
132			name:       "successful operation preserves release failure",
133			releaseErr: releaseErr,
134			wantErrors: []error{releaseErr},
135		},
136		{
137			name:         "operation and release failures are joined",
138			operationErr: operationErr,
139			releaseErr:   releaseErr,
140			wantErrors:   []error{operationErr, releaseErr},
141		},
142	}
143
144	for _, tt := range tests {
145		t.Run(tt.name, func(t *testing.T) {
146			got := tt.operationErr
147			joinLifecycleReleaseError(&got, fakeLifecycleOwnership{release: func() error {
148				return tt.releaseErr
149			}})
150
151			for _, wantErr := range tt.wantErrors {
152				require.ErrorIs(t, got, wantErr)
153			}
154		})
155	}
156}
157
158func TestOfflineCommandsPropagateLifecycleReleaseErrors(t *testing.T) {
159	releaseErr := errors.New("release failed")
160
161	tests := []struct {
162		name             string
163		run              func(context.Context) error
164		wantOperationErr string
165	}{
166		{
167			name: "list joins release failure to success",
168			run:  runList,
169		},
170		{
171			name: "kill joins release failure to operation failure",
172			run: func(ctx context.Context) error {
173				return runKill(ctx, "", false, false)
174			},
175			wantOperationErr: "vev: no daemon running",
176		},
177	}
178
179	originalProbe := daemonLifecycleProbe
180	t.Cleanup(func() { daemonLifecycleProbe = originalProbe })
181	for _, tt := range tests {
182		t.Run(tt.name, func(t *testing.T) {
183			t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
184			t.Setenv("XDG_STATE_HOME", t.TempDir())
185			daemonLifecycleProbe = fakeLifecycleProbe{
186				owner: fakeLifecycleOwnership{release: func() error { return releaseErr }},
187			}
188
189			err := tt.run(context.Background())
190			require.ErrorIs(t, err, releaseErr)
191			if tt.wantOperationErr != "" {
192				require.ErrorContains(t, err, tt.wantOperationErr)
193			}
194		})
195	}
196}
197
198func TestCatalogueFailureDoesNotListen(t *testing.T) {
199	originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon
200	t.Cleanup(func() {
201		openCatalogue = originalOpenCatalogue
202		listenDaemon = originalListenDaemon
203	})
204
205	for _, catalogueErr := range []error{errors.New("catalogue corrupt"), errors.New("catalogue unavailable")} {
206		t.Run(catalogueErr.Error(), func(t *testing.T) {
207			runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
208			runtimeDir := filepath.Join(runtimeRoot, "vev")
209			stateDir := filepath.Join(stateRoot, "vev")
210			t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
211			t.Setenv("XDG_STATE_HOME", stateRoot)
212
213			listenCalls := 0
214			openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
215				require.Equal(t, stateDir, gotStateDir)
216				return persist.OpenResult{}, catalogueErr
217			}
218			listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {
219				listenCalls++
220				return nil, errors.New("IPC listen must not run after catalogue failure")
221			}
222
223			err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
224				return runDaemonOwnedWithLogger(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
225			})
226			require.ErrorIs(t, err, catalogueErr)
227			require.Zero(t, listenCalls)
228			require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
229
230			owner, acquireErr := lifecycle.TryAcquire(runtimeDir)
231			require.NoError(t, acquireErr, "catalogue failure must release lifecycle ownership")
232			require.NoError(t, owner.Release())
233		})
234	}
235}
236
237func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
238	var events []string
239	_, _, err := constructDaemonBeforeSocketPublication(
240		func() *daemon.Daemon {
241			events = append(events, "catalogue-registry")
242			return nil
243		},
244		func(*daemon.Daemon) error {
245			require.Equal(t, []string{"catalogue-registry"}, events)
246			events = append(events, "startup-garbage-collection")
247			return nil
248		},
249		func() (ports.Listener, error) {
250			require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection"}, events)
251			events = append(events, "socket-publication")
252			return nil, nil
253		},
254	)
255	require.NoError(t, err)
256	require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection", "socket-publication"}, events)
257}
258
259func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
260	t.Run("ownership prefix is exact", func(t *testing.T) {
261		var events []string
262		owner := fakeLifecycleOwnership{release: func() error {
263			events = append(events, "unlock")
264			return nil
265		}}
266		deps := lifecycleStartupDeps{
267			ensurePrivate: func(path string) error {
268				if path == "runtime" {
269					events = append(events, "ensure-runtime")
270				} else {
271					events = append(events, "ensure-state")
272				}
273				return nil
274			},
275			acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
276				events = append(events, "lock")
277				return owner, nil
278			},
279		}
280
281		err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
282			events = append(events, "durable-open", "listen", "serve")
283			return nil
284		}, deps)
285		require.NoError(t, err)
286		require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock"}, events[:3])
287		require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock", "durable-open", "listen", "serve", "unlock"}, events)
288	})
289
290	t.Run("callback observes held lock and failure releases it", func(t *testing.T) {
291		runtimeDir := filepath.Join(t.TempDir(), "runtime")
292		stateDir := filepath.Join(t.TempDir(), "state")
293		startErr := errors.New("catalogue corrupt")
294
295		err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(context.Context) error {
296			_, lockErr := lifecycle.TryAcquire(runtimeDir)
297			require.ErrorIs(t, lockErr, lifecycle.ErrBusy)
298			return startErr
299		})
300		require.ErrorIs(t, err, startErr)
301
302		owner, err := lifecycle.TryAcquire(runtimeDir)
303		require.NoError(t, err, "callback failure must release lifecycle ownership")
304		require.NoError(t, owner.Release())
305	})
306
307	t.Run("busy and unavailable ownership fail before startup", func(t *testing.T) {
308		runtimeDir := filepath.Join(t.TempDir(), "runtime")
309		stateDir := filepath.Join(t.TempDir(), "state")
310		owner, err := lifecycle.TryAcquire(runtimeDir)
311		require.NoError(t, err)
312		defer func() { require.NoError(t, owner.Release()) }()
313
314		ctx, cancel := context.WithCancel(context.Background())
315		cancel()
316		started := false
317		err = runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(context.Context) error {
318			started = true
319			return nil
320		})
321		require.ErrorIs(t, err, context.Canceled)
322		require.False(t, started)
323
324		badState := filepath.Join(t.TempDir(), "state-file")
325		require.NoError(t, os.WriteFile(badState, []byte("unavailable"), 0o600))
326		err = runWithLifecycleOwner(context.Background(), filepath.Join(t.TempDir(), "other-runtime"), badState, func(context.Context) error {
327			started = true
328			return nil
329		})
330		require.Error(t, err)
331		require.False(t, started)
332	})
333
334	t.Run("corrupt catalogue fails before socket publication", func(t *testing.T) {
335		runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
336		runtimeDir := filepath.Join(runtimeRoot, "vev")
337		stateDir := filepath.Join(stateRoot, "vev")
338		require.NoError(t, safedir.EnsurePrivate(stateDir))
339		store, err := kv.Open(persist.StorePath(stateDir))
340		require.NoError(t, err)
341		require.NoError(t, store.Set([]byte("work"), []byte("malformed catalogue value")))
342		require.NoError(t, store.Close())
343
344		t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
345		t.Setenv("XDG_STATE_HOME", stateRoot)
346		before, err := os.ReadFile(persist.StorePath(stateDir))
347		require.NoError(t, err)
348		err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
349		require.Error(t, err)
350		require.Contains(t, err.Error(), stateDir)
351		require.Contains(t, err.Error(), "rm -rf "+stateDir)
352		after, readErr := os.ReadFile(persist.StorePath(stateDir))
353		require.NoError(t, readErr)
354		require.Equal(t, before, after, "failed startup must leave the catalogue untouched")
355		_, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
356		require.ErrorIs(t, statErr, os.ErrNotExist)
357	})
358
359	t.Run("proven absence creates the catalogue before socket publication", func(t *testing.T) {
360		runtimeRoot, err := os.MkdirTemp("/tmp", "vev-")
361		require.NoError(t, err)
362		t.Cleanup(func() { require.NoError(t, os.RemoveAll(runtimeRoot)) })
363		stateRoot := t.TempDir()
364		runtimeDir := filepath.Join(runtimeRoot, "vev")
365		stateDir := filepath.Join(stateRoot, "vev")
366		t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
367		t.Setenv("XDG_STATE_HOME", stateRoot)
368		ctx, cancel := context.WithCancel(t.Context())
369		result := make(chan error, 1)
370		go func() {
371			result <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned)
372		}()
373		t.Cleanup(func() {
374			cancel()
375			select {
376			case err := <-result:
377				require.NoError(t, err)
378			case <-time.After(5 * time.Second):
379				t.Error("daemon lifecycle did not stop after cancellation")
380			}
381		})
382
383		require.Eventually(t, func() bool {
384			_, err := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
385			return err == nil
386		}, 5*time.Second, time.Millisecond, "daemon socket was not published")
387		require.FileExists(t, persist.StorePath(stateDir))
388	})
389
390	t.Run("callback and release errors are joined", func(t *testing.T) {
391		startErr := errors.New("catalogue unavailable")
392		releaseErr := errors.New("unlock failed")
393		deps := lifecycleStartupDeps{
394			ensurePrivate: func(string) error { return nil },
395			acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
396				return fakeLifecycleOwnership{release: func() error { return releaseErr }}, nil
397			},
398		}
399
400		err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
401			return startErr
402		}, deps)
403		require.ErrorIs(t, err, startErr)
404		require.ErrorIs(t, err, releaseErr)
405	})
406}
407
408// scriptRecv makes a transport yield frames in order, then wait for done
409// before returning EOF. The shared done channel coordinates proxy readers
410// without relying on scheduling or sleeps.
411func scriptRecv(tr *portsmocks.MockTransport, done <-chan struct{}, frames ...ports.Frame) *portsmocks.MockTransport_Recv_Call {
412	script := make(chan ports.Frame, len(frames))
413	for _, frame := range frames {
414		script <- frame
415	}
416	return tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
417		select {
418		case frame := <-script:
419			return frame, nil
420		case <-done:
421			return ports.Frame{}, io.EOF
422		}
423	})
424}
425
426func TestFirstHelloTransportReplacesRemoteEnvironment(t *testing.T) {
427	for _, tt := range []struct {
428		name     string
429		session  string
430		wantName string
431	}{
432		{name: "stdio replaces environment", wantName: ""},
433		{name: "udp proxy replaces environment and retains session rewrite", session: "work", wantName: "work"},
434	} {
435		t.Run(tt.name, func(t *testing.T) {
436			original := ports.Hello{
437				Version:           ports.ProtocolVersion,
438				Intent:            ports.IntentAttach,
439				ClientID:          [16]byte{1},
440				ResumeToken:       2,
441				Size:              domain.Size{Cols: 80, Rows: 24},
442				TermEnv:           "client-term",
443				Cwd:               "/client",
444				TrueColor:         true,
445				MaxOutputInFlight: 8,
446				Env:               []string{"CLIENT=value"},
447			}
448			nonHello := ports.Frame{Type: ports.MsgInput, Payload: []byte("unchanged")}
449			transport := portsmocks.NewMockTransport(t)
450			done := make(chan struct{})
451			scriptRecv(transport, done,
452				ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(original)},
453				nonHello,
454			).Times(2)
455			wrapped := newFirstHelloTransport(transport, tt.session, []string{"REMOTE=one", "TOKEN=a=b=c"})
456
457			frame, err := wrapped.Recv()
458			require.NoError(t, err)
459			got, err := ports.UnmarshalHello(frame.Payload)
460			require.NoError(t, err)
461			original.Name = tt.wantName
462			original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
463			require.Equal(t, original, got)
464
465			frame, err = wrapped.Recv()
466			require.NoError(t, err)
467			require.Equal(t, nonHello, frame)
468		})
469	}
470}
471
472func TestRunStdioProxyReplacesHelloEnvironmentAtDaemonBoundary(t *testing.T) {
473	original := remoteProxyHello()
474	client, daemon, sent := newRemoteProxyTransports(t, original)
475
476	err := runStdioProxy(context.Background(), client, daemon, []string{"REMOTE=one", "TOKEN=a=b=c"}, nil)
477	require.NoError(t, err)
478
479	got := firstHello(t, sent)
480	original.Env = []string{"REMOTE=one", "TOKEN=a=b=c"}
481	require.Equal(t, original, got)
482}
483
484func TestRunUDPProxyRuntimeReplacesHelloEnvironmentAndSessionAtDaemonBoundary(t *testing.T) {
485	original := remoteProxyHello()
486	client, daemon, sent := newRemoteProxyTransports(t, original)
487
488	err := runUDPProxyRuntime(context.Background(), "remote-work", client, daemon, []string{"REMOTE=udp", "TOKEN=a=b=c"}, nil)
489	require.NoError(t, err)
490
491	got := firstHello(t, sent)
492	original.Name = "remote-work"
493	original.Env = []string{"REMOTE=udp", "TOKEN=a=b=c"}
494	require.Equal(t, original, got)
495}
496
497func remoteProxyHello() ports.Hello {
498	return ports.Hello{
499		Version:           ports.ProtocolVersion,
500		Intent:            ports.IntentAttach,
501		ClientID:          [16]byte{1},
502		ResumeToken:       2,
503		Size:              domain.Size{Cols: 80, Rows: 24},
504		TermEnv:           "client-term",
505		Cwd:               "/client",
506		TrueColor:         true,
507		MaxOutputInFlight: 1,
508		Env:               []string{"CLIENT=value"},
509	}
510}
511
512func newRemoteProxyTransports(t *testing.T, hello ports.Hello) (*portsmocks.MockTransport, *portsmocks.MockTransport, <-chan ports.Frame) {
513	t.Helper()
514	client := portsmocks.NewMockTransport(t)
515	daemon := portsmocks.NewMockTransport(t)
516	clientDone := make(chan struct{})
517	daemonDone := make(chan struct{})
518	sent := make(chan ports.Frame, 1)
519
520	scriptRecv(client, clientDone, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}).Maybe()
521	client.EXPECT().Close().RunAndReturn(func() error {
522		close(clientDone)
523		return nil
524	}).Once()
525	daemon.EXPECT().Send(mock.Anything).RunAndReturn(func(frame ports.Frame) error {
526		sent <- frame
527		close(daemonDone)
528		return nil
529	}).Once()
530	scriptRecv(daemon, daemonDone).Once()
531	daemon.EXPECT().Close().Return(nil).Once()
532
533	return client, daemon, sent
534}
535
536func firstHello(t *testing.T, sent <-chan ports.Frame) ports.Hello {
537	t.Helper()
538	frame := <-sent
539	hello, err := ports.UnmarshalHello(frame.Payload)
540	require.NoError(t, err)
541	return hello
542}
543
544func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
545	require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
546}
547
548func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) {
549	require.Positive(t, udpProxyIdleTTL)
550	require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL)
551}
552
553func TestParseArgs(t *testing.T) {
554	tests := []struct {
555		name       string
556		args       []string
557		wantKind   cmdKind
558		wantIntent uint8
559		wantName   string
560		wantRemote string
561		wantAll    bool
562		wantDaemon bool
563		wantErr    bool
564	}{
565		{name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
566		{name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
567		{name: "new named", args: []string{"new", "work"}, wantKind: kindAttach, wantIntent: ports.IntentNew, wantName: "work"},
568		{name: "new without name", args: []string{"new"}, wantErr: true},
569		{name: "new empty name", args: []string{"new", ""}, wantErr: true},
570		{name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
571		{name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
572		{name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
573		{name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
574		{name: "attach remote without session uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
575		{name: "attach remote with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
576		{name: "attach remote with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
577		{name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},
578		{name: "attach without name", args: []string{"attach"}, wantErr: true},
579		{name: "ls", args: []string{"ls"}, wantKind: kindList},
580		{name: "list", args: []string{"list"}, wantKind: kindList},
581		{name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
582		{name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
583		{name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
584		{name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
585		{name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
586		{name: "kill without name", args: []string{"kill"}, wantErr: true},
587		{name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true},
588		{name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
589		{name: "kill daemon rejects extra arg", args: []string{"kill", "--daemon", "extra"}, wantErr: true},
590		{name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
591		{name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
592		{name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
593		{name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
594		{name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
595		{name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
596		{name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"},
597		{name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"},
598		{name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
599		{name: "help", args: []string{"--help"}, wantKind: kindHelp},
600		{name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
601		{name: "version", args: []string{"--version"}, wantKind: kindVersion},
602		{name: "unknown", args: []string{"frobnicate"}, wantErr: true},
603	}
604
605	for _, tt := range tests {
606		t.Run(tt.name, func(t *testing.T) {
607			got, err := parseArgs(tt.args)
608			if tt.wantErr {
609				if err == nil {
610					t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
611				}
612				var ue *usageError
613				if !errors.As(err, &ue) {
614					t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
615				}
616				return
617			}
618			if err != nil {
619				t.Fatalf("parseArgs(%q) unexpected error: %v", tt.args, err)
620			}
621			if got.kind != tt.wantKind {
622				t.Errorf("kind = %v, want %v", got.kind, tt.wantKind)
623			}
624			if got.kind == kindAttach && got.intent != tt.wantIntent {
625				t.Errorf("intent = %d, want %d", got.intent, tt.wantIntent)
626			}
627			if got.name != tt.wantName {
628				t.Errorf("name = %q, want %q", got.name, tt.wantName)
629			}
630			if got.remoteTarget != tt.wantRemote {
631				t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
632			}
633			if got.killAll != tt.wantAll {
634				t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
635			}
636			if got.killDaemon != tt.wantDaemon {
637				t.Errorf("killDaemon = %v, want %v", got.killDaemon, tt.wantDaemon)
638			}
639		})
640	}
641}
642
643func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
644	_, err := parseArgs([]string{"new", "my work"})
645	if !errors.Is(err, domain.ErrInvalidSessionName) {
646		t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
647	}
648}
649
650func TestListShowsBroken(t *testing.T) {
651	var out bytes.Buffer
652	printSessions(&out, []ports.SessionInfo{
653		{Name: "fresh", State: ports.SessionStopped},
654		{Name: "loading", State: ports.SessionStopped},
655		{Name: "broken", State: ports.SessionBroken},
656	})
657	require.Contains(t, out.String(), "fresh")
658	require.Contains(t, out.String(), "stopped")
659	require.Contains(t, out.String(), "loading")
660	require.Contains(t, out.String(), "broken")
661	require.NotContains(t, out.String(), "restoring")
662	require.NotContains(t, out.String(), "degraded")
663}
664
665func TestPrintSessionsShowsStoppedState(t *testing.T) {
666	var out bytes.Buffer
667	printSessions(&out, []ports.SessionInfo{
668		{Name: "main", State: ports.SessionRunning, Tabs: 2, Attached: true},
669		{Name: "old", State: ports.SessionStopped},
670	})
671	got := out.String()
672	for _, want := range []string{"NAME", "STATE", "main", "running", "2", "yes", "old", "stopped", "-"} {
673		if !strings.Contains(got, want) {
674			t.Fatalf("printSessions output %q missing %q", got, want)
675		}
676	}
677}
678
679func TestPrintSessionsMarksEphemeral(t *testing.T) {
680	var buf bytes.Buffer
681	printSessions(&buf, []ports.SessionInfo{
682		{Name: "0", State: ports.SessionRunning, Ephemeral: true, Tabs: 1, Attached: false},
683		{Name: "work", State: ports.SessionRunning, Tabs: 2, Attached: true},
684		{Name: "old", State: ports.SessionStopped},
685	})
686	out := buf.String()
687	for _, want := range []string{"0", "temporary", "work", "running", "old", "stopped"} {
688		require.Contains(t, out, want)
689	}
690}
691
692func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
693	t.Helper()
694	store, err := persist.OpenStore(persist.StorePath(stateDir))
695	require.NoError(t, err)
696	return persist.New(store)
697}
698
699func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
700	stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
701	t.Setenv("XDG_STATE_HOME", stateRoot)
702	t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
703
704	p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
705	now := time.Now().UnixNano()
706	if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
707		t.Fatalf("Save error = %v", err)
708	}
709	if err := p.Close(); err != nil {
710		t.Fatalf("Close error = %v", err)
711	}
712
713	got := captureStdout(t, func() {
714		if err := runList(context.Background()); err != nil {
715			t.Fatalf("runList error = %v", err)
716		}
717	})
718	for _, want := range []string{"stored", "stopped", "-"} {
719		if !strings.Contains(got, want) {
720			t.Fatalf("runList output %q missing %q", got, want)
721		}
722	}
723}
724
725func TestRunListUnreadableCatalogueProvidesResetGuidance(t *testing.T) {
726	stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
727	stateDir := filepath.Join(stateRoot, "vev")
728	runtimeDir := filepath.Join(runtimeRoot, "vev")
729	t.Setenv("XDG_STATE_HOME", stateRoot)
730	t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
731	require.NoError(t, safedir.EnsurePrivate(stateDir))
732
733	catalogue := persist.StorePath(stateDir)
734	before := []byte("corrupt catalogue")
735	require.NoError(t, os.WriteFile(catalogue, before, 0o600))
736
737	var runErr error
738	stdout := captureStdout(t, func() { runErr = Run([]string{"ls"}) })
739	require.Empty(t, stdout)
740	require.Error(t, runErr)
741	require.ErrorContains(t, runErr, stateDir)
742	require.ErrorContains(t, runErr, "rm -rf "+stateDir)
743	require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
744	after, err := os.ReadFile(catalogue)
745	require.NoError(t, err)
746	require.Equal(t, before, after)
747}
748
749func TestRunKillMissingStoppedSessionDoesNotCreateStore(t *testing.T) {
750	stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
751	t.Setenv("XDG_STATE_HOME", stateRoot)
752	t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
753
754	err := runKill(context.Background(), "missing", false, false)
755	if err == nil || !strings.Contains(err.Error(), "no such session: missing") {
756		t.Fatalf("runKill error = %v, want no such session", err)
757	}
758	if _, statErr := os.Stat(filepath.Join(stateRoot, "vev", "sessions.kv")); !errors.Is(statErr, os.ErrNotExist) {
759		t.Fatalf("sessions.kv stat error = %v, want not exist", statErr)
760	}
761}
762
763func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
764	stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
765	t.Setenv("XDG_STATE_HOME", stateRoot)
766	t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
767
768	p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
769	now := time.Now().UnixNano()
770	if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
771		t.Fatalf("Save error = %v", err)
772	}
773	if err := p.Close(); err != nil {
774		t.Fatalf("Close error = %v", err)
775	}
776	got := captureStdout(t, func() {
777		if err := runKill(context.Background(), "stored", false, false); err != nil {
778			t.Fatalf("runKill error = %v", err)
779		}
780	})
781	if !strings.Contains(got, "killed stored") {
782		t.Fatalf("runKill output = %q, want success", got)
783	}
784	records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
785	if err != nil {
786		t.Fatalf("LoadReadOnly error = %v", err)
787	}
788	if len(records) != 0 {
789		t.Fatalf("records after kill = %#v, want none", records)
790	}
791}
792
793func captureStdout(t *testing.T, fn func()) string {
794	t.Helper()
795	old := os.Stdout
796	r, w, err := os.Pipe()
797	if err != nil {
798		t.Fatalf("os.Pipe error = %v", err)
799	}
800	os.Stdout = w
801	defer func() { os.Stdout = old }()
802	defer func() { _ = r.Close() }()
803	outCh := make(chan []byte, 1)
804	errCh := make(chan error, 1)
805	go func() {
806		out, err := io.ReadAll(r)
807		outCh <- out
808		errCh <- err
809	}()
810
811	fn()
812	_ = w.Close()
813	out := <-outCh
814	if err := <-errCh; err != nil {
815		t.Fatalf("ReadAll stdout error = %v", err)
816	}
817	return string(out)
818}
819
820func TestRunLocalAttachPromptsAndRestartsOnProtocolMismatch(t *testing.T) {
821	var prompts bytes.Buffer
822	answers := strings.NewReader("y\n")
823	attachCalls := 0
824	killCalls := 0
825
826	err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
827		confirmer: confirm.NewConfirmer(answers, &prompts),
828		attach: func(context.Context, uint8, string) error {
829			attachCalls++
830			if attachCalls == 1 {
831				return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
832			}
833			return nil
834		},
835		killDaemon: func(context.Context) error {
836			killCalls++
837			return nil
838		},
839	})
840	if err != nil {
841		t.Fatalf("runLocalAttachWithRecovery returned error: %v", err)
842	}
843	if attachCalls != 2 {
844		t.Fatalf("attach calls = %d, want 2", attachCalls)
845	}
846	if killCalls != 1 {
847		t.Fatalf("kill calls = %d, want 1", killCalls)
848	}
849	if got := prompts.String(); !strings.Contains(got, "Your vev version differs") || !strings.Contains(got, "kill it") {
850		t.Fatalf("prompt = %q, want version/kill prompt", got)
851	}
852}
853
854func TestRunLocalAttachPropagatesPromptError(t *testing.T) {
855	readErr := errors.New("read failed")
856	err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
857		confirmer: confirm.NewConfirmer(errorReader{err: readErr}, &bytes.Buffer{}),
858		attach: func(context.Context, uint8, string) error {
859			return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
860		},
861		killDaemon: func(context.Context) error {
862			t.Fatal("killDaemon should not be called after prompt error")
863			return nil
864		},
865	})
866	if !errors.Is(err, readErr) {
867		t.Fatalf("error = %v, want wrapped %v", err, readErr)
868	}
869}
870
871func TestRunLocalAttachPropagatesKillError(t *testing.T) {
872	killErr := errors.New("kill failed")
873	err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
874		confirmer: confirm.NewConfirmer(strings.NewReader("y\n"), &bytes.Buffer{}),
875		attach: func(context.Context, uint8, string) error {
876			return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
877		},
878		killDaemon: func(context.Context) error { return killErr },
879	})
880	if !errors.Is(err, killErr) {
881		t.Fatalf("error = %v, want %v", err, killErr)
882	}
883}
884
885func TestRunLocalAttachSettlesBeforeRetry(t *testing.T) {
886	var order []string
887	err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
888		confirmer: confirm.NewConfirmer(strings.NewReader("y\n"), &bytes.Buffer{}),
889		attach: func(context.Context, uint8, string) error {
890			order = append(order, "attach")
891			if len(order) == 1 {
892				return &client.ProtocolError{Code: ports.ErrVersionMismatch, Text: "protocol version mismatch"}
893			}
894			return nil
895		},
896		killDaemon: func(context.Context) error {
897			order = append(order, "kill")
898			return nil
899		},
900		settleAfterKill: func(context.Context) error {
901			order = append(order, "settle")
902			return nil
903		},
904	})
905	if err != nil {
906		t.Fatalf("runLocalAttachWithRecovery returned error: %v", err)
907	}
908	if got, want := strings.Join(order, ","), "attach,kill,settle,attach"; got != want {
909		t.Fatalf("order = %s, want %s", got, want)
910	}
911}
912
913type errorReader struct{ err error }
914
915func (r errorReader) Read([]byte) (int, error) { return 0, r.err }
916
917func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
918	answers := strings.NewReader("n\n")
919	wantErr := &client.ProtocolError{Code: ports.ErrInternal, Text: "malformed hello"}
920	attachCalls := 0
921	killCalls := 0
922
923	err := runLocalAttachWithRecovery(context.Background(), ports.IntentEphemeral, "", attachRecoveryDeps{
924		confirmer: confirm.NewConfirmer(answers, &bytes.Buffer{}),
925		attach: func(context.Context, uint8, string) error {
926			attachCalls++
927			return wantErr
928		},
929		killDaemon: func(context.Context) error {
930			killCalls++
931			return nil
932		},
933	})
934	if !errors.Is(err, wantErr) {
935		t.Fatalf("error = %v, want original %v", err, wantErr)
936	}
937	if attachCalls != 1 {
938		t.Fatalf("attach calls = %d, want 1", attachCalls)
939	}
940	if killCalls != 0 {
941		t.Fatalf("kill calls = %d, want 0", killCalls)
942	}
943}
944
945func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
946	called := false
947	err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
948		createDetached: func(context.Context, string) error {
949			called = true
950			return nil
951		},
952	})
953	if err == nil {
954		t.Fatal("runAttach with VEV set returned nil error")
955	}
956	if called {
957		t.Fatal("runAttach dialed while nested attach should be rejected")
958	}
959	if !strings.Contains(err.Error(), "sessions should be nested with care") {
960		t.Fatalf("runAttach error = %q, want nested-session warning", err)
961	}
962}
963
964func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
965	var gotName string
966	err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
967		createDetached: func(_ context.Context, name string) error {
968			gotName = name
969			return nil
970		},
971	})
972	if err != nil {
973		t.Fatalf("runAttachWithDeps returned error: %v", err)
974	}
975	if gotName != "scratch" {
976		t.Fatalf("detached name = %q, want scratch", gotName)
977	}
978}
979
980func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
981	t.Setenv("TERM", "xterm-direct")
982	t.Setenv("COLORTERM", "")
983
984	hello := detachedLocalHello("scratch", "/tmp/work")
985
986	require.Equal(t, ports.IntentNew, hello.Intent)
987	require.Equal(t, "scratch", hello.Name)
988	require.Equal(t, "xterm-direct", hello.TermEnv)
989	require.Equal(t, "/tmp/work", hello.Cwd)
990	require.True(t, hello.TrueColor)
991	require.Equal(t, os.Environ(), hello.Env)
992}
993
994type namedDialer struct{ name string }
995
996func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
997	return nil, errors.New("not used")
998}
999
1000// fakeClipboardReader is a distinguishable ports.ClipboardReader used only to
1001// verify identity (that runAttachWithDeps threads the *same* reader through),
1002// never actually invoked in these wiring tests.
1003type fakeClipboardReader struct{ ports.ClipboardReader }
1004
1005func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
1006	tests := []struct {
1007		name              string
1008		selectedTransport string
1009		wantMode          ports.RemoteTransportMode
1010	}{
1011		{name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
1012		{name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
1013	}
1014
1015	for _, tt := range tests {
1016		t.Run(tt.name, func(t *testing.T) {
1017			var gotDialer string
1018			var gotRemote bool
1019			var gotClipboard ports.ClipboardReader
1020			clip := &fakeClipboardReader{}
1021			factory := portsmocks.NewMockRemoteDialerFactory(t)
1022			factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
1023
1024			err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1025				remoteDialerFactory:     factory,
1026				selectedRemoteTransport: tt.selectedTransport,
1027				clipboard:               clip,
1028				runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
1029					nd, ok := deps.Dialer.(namedDialer)
1030					if !ok {
1031						t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1032					}
1033					gotDialer = nd.name
1034					gotRemote = request.Remote
1035					gotClipboard = deps.Clipboard
1036					if request.Intent != ports.IntentAttach || request.SessionName != "work" {
1037						t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
1038					}
1039					return nil
1040				},
1041			})
1042			if err != nil {
1043				t.Fatalf("runAttachWithDeps returned error: %v", err)
1044			}
1045			if gotDialer != "remote" {
1046				t.Fatalf("remote dialer = %q, want remote", gotDialer)
1047			}
1048			if !gotRemote {
1049				t.Fatal("remote attach must pass remote=true to runClient")
1050			}
1051			if gotClipboard != ports.ClipboardReader(clip) {
1052				t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
1053			}
1054		})
1055	}
1056}
1057
1058func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
1059	factory := portsmocks.NewMockRemoteDialerFactory(t)
1060	runClientCalled := false
1061
1062	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1063		remoteDialerFactory:     factory,
1064		selectedRemoteTransport: "serial",
1065		runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
1066			runClientCalled = true
1067			return nil
1068		},
1069	})
1070	if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` {
1071		t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err)
1072	}
1073	if runClientCalled {
1074		t.Fatal("runClient called after invalid remote transport")
1075	}
1076}
1077
1078func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
1079	factoryErr := errors.New("factory failed")
1080	factory := portsmocks.NewMockRemoteDialerFactory(t)
1081	factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
1082	runClientCalled := false
1083
1084	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1085		remoteDialerFactory: factory,
1086		runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
1087			runClientCalled = true
1088			return nil
1089		},
1090	})
1091	if !errors.Is(err, factoryErr) {
1092		t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr)
1093	}
1094	if runClientCalled {
1095		t.Fatal("runClient called after remote factory error")
1096	}
1097}
1098
1099func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
1100	var gotDialer string
1101	gotRemote := true
1102	gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
1103	factory := portsmocks.NewMockRemoteDialerFactory(t)
1104	err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
1105		localDialer:         func() ports.Dialer { return namedDialer{name: "local"} },
1106		remoteDialerFactory: factory,
1107		clipboard:           &fakeClipboardReader{}, // must NOT reach runClient for a local attach
1108		runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
1109			nd, ok := deps.Dialer.(namedDialer)
1110			if !ok {
1111				t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1112			}
1113			gotDialer = nd.name
1114			gotRemote = request.Remote
1115			gotClipboard = deps.Clipboard
1116			if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
1117				t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
1118			}
1119			return nil
1120		},
1121	})
1122	if err != nil {
1123		t.Fatalf("runAttachWithDeps returned error: %v", err)
1124	}
1125	if gotDialer != "local" {
1126		t.Fatalf("local dialer = %q, want local", gotDialer)
1127	}
1128	if gotRemote {
1129		t.Fatal("local attach must pass remote=false to runClient")
1130	}
1131	if gotClipboard != nil {
1132		t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
1133	}
1134}
1135
1136func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
1137	oldTimeout := udpBootstrapTimeout
1138	udpBootstrapTimeout = time.Second
1139	t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1140	oldCommand := udpProxyCommand
1141	var gotCmd *exec.Cmd
1142	udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1143		cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'")
1144		gotCmd = cmd
1145		return cmd
1146	}
1147	t.Cleanup(func() { udpProxyCommand = oldCommand })
1148
1149	got := captureStdout(t, func() {
1150		if err := runUDPBootstrap(context.Background(), "work"); err != nil {
1151			t.Fatalf("runUDPBootstrap() error = %v", err)
1152		}
1153	})
1154	if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" {
1155		t.Fatalf("readiness = %q", got)
1156	}
1157	if gotCmd.Stdin == nil || gotCmd.Stderr == nil {
1158		t.Fatalf("proxy stdio = stdin %T stderr %T, want detached from SSH channels", gotCmd.Stdin, gotCmd.Stderr)
1159	}
1160	if gotCmd.Stdout == os.Stdout || gotCmd.Stderr == os.Stderr || gotCmd.Stdin == os.Stdin {
1161		t.Fatalf("proxy inherited SSH stdio handles")
1162	}
1163}
1164
1165func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
1166	oldTimeout := udpBootstrapTimeout
1167	udpBootstrapTimeout = time.Second
1168	t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1169	oldCommand := udpProxyCommand
1170	udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1171		return exec.Command("/bin/sh", "-c", "exit 7")
1172	}
1173	t.Cleanup(func() { udpProxyCommand = oldCommand })
1174
1175	err := runUDPBootstrap(context.Background(), "work")
1176	if !errors.Is(err, io.EOF) {
1177		t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
1178	}
1179}
1180
1181func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
1182	oldTimeout := udpBootstrapTimeout
1183	udpBootstrapTimeout = 10 * time.Millisecond
1184	t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1185	oldCommand := udpProxyCommand
1186	udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1187		return exec.Command("/bin/sh", "-c", "sleep 5")
1188	}
1189	t.Cleanup(func() { udpProxyCommand = oldCommand })
1190
1191	err := runUDPBootstrap(context.Background(), "work")
1192	if !errors.Is(err, context.DeadlineExceeded) {
1193		t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
1194	}
1195}
1196
1197func TestParseUDPPortRange(t *testing.T) {
1198	tests := []struct {
1199		name      string
1200		value     string
1201		want      udpPortRange
1202		wantError bool
1203	}{
1204		{name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
1205		{name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
1206		{name: "zero range rejected", value: "0-100", wantError: true},
1207		{name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
1208		{name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
1209		{name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
1210		{name: "reversed range", value: "61023-61000", wantError: true},
1211		{name: "non-numeric", value: "abc", wantError: true},
1212		{name: "missing end", value: "61000-", wantError: true},
1213		{name: "port too high", value: "70000", wantError: true},
1214	}
1215	for _, tt := range tests {
1216		t.Run(tt.name, func(t *testing.T) {
1217			got, err := parseUDPPortRange(tt.value)
1218			if tt.wantError {
1219				if err == nil {
1220					t.Fatalf("parseUDPPortRange(%q) error = nil, want error", tt.value)
1221				}
1222				return
1223			}
1224			if err != nil {
1225				t.Fatalf("parseUDPPortRange(%q) error = %v", tt.value, err)
1226			}
1227			if got != tt.want {
1228				t.Fatalf("parseUDPPortRange(%q) = %+v, want %+v", tt.value, got, tt.want)
1229			}
1230		})
1231	}
1232}
1233
1234func TestListenUDPInRange(t *testing.T) {
1235	// Occupy a wildcard UDP port, then assert listenUDPInRange skips it.
1236	var lc net.ListenConfig
1237	occupied, err := lc.ListenPacket(context.Background(), "udp", ":0")
1238	if err != nil {
1239		t.Fatal(err)
1240	}
1241	defer func() { _ = occupied.Close() }()
1242	udpPort := func(t *testing.T, conn net.PacketConn) int {
1243		t.Helper()
1244		addr, ok := conn.LocalAddr().(*net.UDPAddr)
1245		if !ok {
1246			t.Fatalf("LocalAddr() = %T, want *net.UDPAddr", conn.LocalAddr())
1247		}
1248		return addr.Port
1249	}
1250	busyPort := udpPort(t, occupied)
1251
1252	t.Run("skips busy port", func(t *testing.T) {
1253		conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
1254		if err != nil {
1255			t.Fatalf("listenUDPInRange error = %v", err)
1256		}
1257		defer func() { _ = conn.Close() }()
1258		got := udpPort(t, conn)
1259		if got == busyPort {
1260			t.Fatalf("bound busy port %d, want a different port in range", busyPort)
1261		}
1262		if got < busyPort || got > busyPort+8 {
1263			t.Fatalf("bound port %d outside range %d-%d", got, busyPort, busyPort+8)
1264		}
1265	})
1266
1267	t.Run("exhausted range errors", func(t *testing.T) {
1268		_, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort})
1269		if err == nil {
1270			t.Fatal("listenUDPInRange error = nil, want exhausted-range error")
1271		}
1272		if !strings.Contains(err.Error(), "no free UDP port in range") {
1273			t.Fatalf("error = %v, want 'no free UDP port in range'", err)
1274		}
1275	})
1276
1277	t.Run("invalid direct range errors", func(t *testing.T) {
1278		_, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort + 8, end: busyPort})
1279		if err == nil {
1280			t.Fatal("listenUDPInRange error = nil, want invalid-range error")
1281		}
1282		if !strings.Contains(err.Error(), "invalid UDP port range") {
1283			t.Fatalf("error = %v, want 'invalid UDP port range'", err)
1284		}
1285	})
1286
1287	t.Run("ephemeral binds", func(t *testing.T) {
1288		conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
1289		if err != nil {
1290			t.Fatalf("listenUDPInRange ephemeral error = %v", err)
1291		}
1292		defer func() { _ = conn.Close() }()
1293		if udpPort(t, conn) == 0 {
1294			t.Fatal("ephemeral bind returned port 0")
1295		}
1296	})
1297}
1298
1299func TestPprofAddrIsLoopback(t *testing.T) {
1300	tests := []struct {
1301		addr string
1302		want bool
1303	}{
1304		{addr: "127.0.0.1:6060", want: true},
1305		{addr: "localhost:6060", want: true},
1306		{addr: "[::1]:6060", want: true},
1307		{addr: ":6060", want: false},
1308		{addr: "0.0.0.0:6060", want: false},
1309		{addr: "192.168.1.5:6060", want: false},
1310		{addr: "garbage", want: false},
1311	}
1312
1313	for _, tt := range tests {
1314		t.Run(tt.addr, func(t *testing.T) {
1315			if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
1316				t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
1317			}
1318		})
1319	}
1320}

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