v0.3.0 99dc266

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	remoteadapter "github.com/bnema/vev/internal/adapters/remote"
21	"github.com/bnema/vev/internal/domain"
22	"github.com/bnema/vev/internal/persist"
23	"github.com/bnema/vev/internal/ports"
24	portsmocks "github.com/bnema/vev/internal/ports/mocks"
25	"github.com/bnema/vev/internal/usecase/client"
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: func(ctx context.Context) error {
169				return runList(ctx, command{kind: kindList})
170			},
171		},
172		{
173			name: "kill joins release failure to operation failure",
174			run: func(ctx context.Context) error {
175				return runKill(ctx, "", false, false)
176			},
177			wantOperationErr: "vev: no daemon running",
178		},
179	}
180
181	originalProbe := daemonLifecycleProbe
182	t.Cleanup(func() { daemonLifecycleProbe = originalProbe })
183	for _, tt := range tests {
184		t.Run(tt.name, func(t *testing.T) {
185			t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
186			t.Setenv("XDG_STATE_HOME", t.TempDir())
187			daemonLifecycleProbe = fakeLifecycleProbe{
188				owner: fakeLifecycleOwnership{release: func() error { return releaseErr }},
189			}
190
191			err := tt.run(context.Background())
192			require.ErrorIs(t, err, releaseErr)
193			if tt.wantOperationErr != "" {
194				require.ErrorContains(t, err, tt.wantOperationErr)
195			}
196		})
197	}
198}
199
200func TestCatalogueFailureDoesNotListen(t *testing.T) {
201	originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon
202	t.Cleanup(func() {
203		openCatalogue = originalOpenCatalogue
204		listenDaemon = originalListenDaemon
205	})
206
207	for _, catalogueErr := range []error{errors.New("catalogue corrupt"), errors.New("catalogue unavailable")} {
208		t.Run(catalogueErr.Error(), func(t *testing.T) {
209			runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
210			runtimeDir := filepath.Join(runtimeRoot, "vev")
211			stateDir := filepath.Join(stateRoot, "vev")
212			t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
213			t.Setenv("XDG_STATE_HOME", stateRoot)
214
215			listenCalls := 0
216			openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
217				require.Equal(t, stateDir, gotStateDir)
218				return persist.OpenResult{}, catalogueErr
219			}
220			listenDaemon = func(string, ports.SerializedRuntimeObserver) (ports.Listener, error) {
221				listenCalls++
222				return nil, errors.New("IPC listen must not run after catalogue failure")
223			}
224
225			err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
226				return runDaemonOwnedWithLogger(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
227			})
228			require.ErrorIs(t, err, catalogueErr)
229			require.Zero(t, listenCalls)
230			require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
231
232			owner, acquireErr := lifecycle.TryAcquire(runtimeDir)
233			require.NoError(t, acquireErr, "catalogue failure must release lifecycle ownership")
234			require.NoError(t, owner.Release())
235		})
236	}
237}
238
239func TestCatalogueRegistryConstructionPrecedesSocketPublication(t *testing.T) {
240	var events []string
241	_, _, err := constructDaemonBeforeSocketPublication(
242		func() *daemon.Daemon {
243			events = append(events, "catalogue-registry")
244			return nil
245		},
246		func(*daemon.Daemon) error {
247			require.Equal(t, []string{"catalogue-registry"}, events)
248			events = append(events, "startup-garbage-collection")
249			return nil
250		},
251		func() (ports.Listener, error) {
252			require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection"}, events)
253			events = append(events, "socket-publication")
254			return nil, nil
255		},
256	)
257	require.NoError(t, err)
258	require.Equal(t, []string{"catalogue-registry", "startup-garbage-collection", "socket-publication"}, events)
259}
260
261func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
262	t.Run("ownership prefix is exact", func(t *testing.T) {
263		var events []string
264		owner := fakeLifecycleOwnership{release: func() error {
265			events = append(events, "unlock")
266			return nil
267		}}
268		deps := lifecycleStartupDeps{
269			ensurePrivate: func(path string) error {
270				if path == "runtime" {
271					events = append(events, "ensure-runtime")
272				} else {
273					events = append(events, "ensure-state")
274				}
275				return nil
276			},
277			acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
278				events = append(events, "lock")
279				return owner, nil
280			},
281		}
282
283		err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
284			events = append(events, "durable-open", "listen", "serve")
285			return nil
286		}, deps)
287		require.NoError(t, err)
288		require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock"}, events[:3])
289		require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock", "durable-open", "listen", "serve", "unlock"}, events)
290	})
291
292	t.Run("callback observes held lock and failure releases it", func(t *testing.T) {
293		runtimeDir := filepath.Join(t.TempDir(), "runtime")
294		stateDir := filepath.Join(t.TempDir(), "state")
295		startErr := errors.New("catalogue corrupt")
296
297		err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(context.Context) error {
298			_, lockErr := lifecycle.TryAcquire(runtimeDir)
299			require.ErrorIs(t, lockErr, lifecycle.ErrBusy)
300			return startErr
301		})
302		require.ErrorIs(t, err, startErr)
303
304		owner, err := lifecycle.TryAcquire(runtimeDir)
305		require.NoError(t, err, "callback failure must release lifecycle ownership")
306		require.NoError(t, owner.Release())
307	})
308
309	t.Run("busy and unavailable ownership fail before startup", func(t *testing.T) {
310		runtimeDir := filepath.Join(t.TempDir(), "runtime")
311		stateDir := filepath.Join(t.TempDir(), "state")
312		owner, err := lifecycle.TryAcquire(runtimeDir)
313		require.NoError(t, err)
314		defer func() { require.NoError(t, owner.Release()) }()
315
316		ctx, cancel := context.WithCancel(context.Background())
317		cancel()
318		started := false
319		err = runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(context.Context) error {
320			started = true
321			return nil
322		})
323		require.ErrorIs(t, err, context.Canceled)
324		require.False(t, started)
325
326		badState := filepath.Join(t.TempDir(), "state-file")
327		require.NoError(t, os.WriteFile(badState, []byte("unavailable"), 0o600))
328		err = runWithLifecycleOwner(context.Background(), filepath.Join(t.TempDir(), "other-runtime"), badState, func(context.Context) error {
329			started = true
330			return nil
331		})
332		require.Error(t, err)
333		require.False(t, started)
334	})
335
336	t.Run("corrupt catalogue fails before socket publication", func(t *testing.T) {
337		runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
338		runtimeDir := filepath.Join(runtimeRoot, "vev")
339		stateDir := filepath.Join(stateRoot, "vev")
340		require.NoError(t, safedir.EnsurePrivate(stateDir))
341		store, err := kv.Open(persist.StorePath(stateDir))
342		require.NoError(t, err)
343		require.NoError(t, store.Set([]byte("work"), []byte("malformed catalogue value")))
344		require.NoError(t, store.Close())
345
346		t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
347		t.Setenv("XDG_STATE_HOME", stateRoot)
348		before, err := os.ReadFile(persist.StorePath(stateDir))
349		require.NoError(t, err)
350		err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
351		require.Error(t, err)
352		require.Contains(t, err.Error(), stateDir)
353		require.Contains(t, err.Error(), "rm -rf "+stateDir)
354		after, readErr := os.ReadFile(persist.StorePath(stateDir))
355		require.NoError(t, readErr)
356		require.Equal(t, before, after, "failed startup must leave the catalogue untouched")
357		_, statErr := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
358		require.ErrorIs(t, statErr, os.ErrNotExist)
359	})
360
361	t.Run("proven absence creates the catalogue before socket publication", func(t *testing.T) {
362		runtimeRoot, err := os.MkdirTemp("/tmp", "vev-")
363		require.NoError(t, err)
364		t.Cleanup(func() { require.NoError(t, os.RemoveAll(runtimeRoot)) })
365		stateRoot := t.TempDir()
366		runtimeDir := filepath.Join(runtimeRoot, "vev")
367		stateDir := filepath.Join(stateRoot, "vev")
368		t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
369		t.Setenv("XDG_STATE_HOME", stateRoot)
370		ctx, cancel := context.WithCancel(t.Context())
371		result := make(chan error, 1)
372		go func() {
373			result <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned)
374		}()
375		t.Cleanup(func() {
376			cancel()
377			select {
378			case err := <-result:
379				require.NoError(t, err)
380			case <-time.After(5 * time.Second):
381				t.Error("daemon lifecycle did not stop after cancellation")
382			}
383		})
384
385		require.Eventually(t, func() bool {
386			_, err := os.Stat(filepath.Join(runtimeDir, "daemon.sock"))
387			return err == nil
388		}, 5*time.Second, time.Millisecond, "daemon socket was not published")
389		require.FileExists(t, persist.StorePath(stateDir))
390	})
391
392	t.Run("callback and release errors are joined", func(t *testing.T) {
393		startErr := errors.New("catalogue unavailable")
394		releaseErr := errors.New("unlock failed")
395		deps := lifecycleStartupDeps{
396			ensurePrivate: func(string) error { return nil },
397			acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
398				return fakeLifecycleOwnership{release: func() error { return releaseErr }}, nil
399			},
400		}
401
402		err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
403			return startErr
404		}, deps)
405		require.ErrorIs(t, err, startErr)
406		require.ErrorIs(t, err, releaseErr)
407	})
408}
409
410// scriptRecv makes a transport yield frames in order, then wait for done
411// before returning EOF. The shared done channel coordinates proxy readers
412// without relying on scheduling or sleeps.
413func TestRunUDPProxyUsesBoundedClientMaxPending(t *testing.T) {
414	require.Equal(t, 32, udpProxyClientTransportOptions.MaxPending)
415}
416
417func TestRunUDPProxyClientDeadAfterExceedsIdleTTL(t *testing.T) {
418	require.Positive(t, udpProxyIdleTTL)
419	require.Greater(t, udpProxyClientTransportOptions.DeadAfter, udpProxyIdleTTL)
420}
421
422func TestParseArgs(t *testing.T) {
423	tests := []struct {
424		name         string
425		args         []string
426		wantKind     cmdKind
427		wantIntent   uint8
428		wantName     string
429		wantRemote   string
430		wantListHost string
431		wantListAll  bool
432		wantHostAct  string
433		wantHostTgt  string
434		wantAll      bool
435		wantDaemon   bool
436		wantErr      bool
437		nonUsageErr  bool
438	}{
439		{name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
440		{name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral},
441		{name: "new named", args: []string{"new", "work"}, wantKind: kindAttach, wantIntent: ports.IntentNew, wantName: "work"},
442		{name: "new without name", args: []string{"new"}, wantErr: true},
443		{name: "new empty name", args: []string{"new", ""}, wantErr: true},
444		{name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
445		{name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
446		{name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "my work"},
447		{name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work"},
448		{name: "attach remote host uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
449		{name: "attach remote host with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: ports.IntentEphemeral, wantRemote: "user@example.com"},
450		{name: "attach remote host with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
451		{name: "attach remote ipv4 with session", args: []string{"attach", "user@192.0.2.10:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@192.0.2.10"},
452		{name: "attach remote ipv6 with session", args: []string{"attach", "user@[2001:db8::1]:work"}, wantKind: kindAttach, wantIntent: ports.IntentAttach, wantName: "work", wantRemote: "user@[2001:db8::1]"},
453		{name: "attach remote invalid session", args: []string{"attach", "user@example.com:my work"}, wantErr: true, nonUsageErr: true},
454		{name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},
455		{name: "attach without name", args: []string{"attach"}, wantErr: true},
456		{name: "ls", args: []string{"ls"}, wantKind: kindList},
457		{name: "list", args: []string{"list"}, wantKind: kindList},
458		{name: "ls host", args: []string{"ls", "arch"}, wantKind: kindList, wantListHost: "arch"},
459		{name: "list host", args: []string{"list", "build@mule"}, wantKind: kindList, wantListHost: "build@mule"},
460		{name: "ls --all", args: []string{"ls", "--all"}, wantKind: kindList, wantListAll: true},
461		{name: "list --all", args: []string{"list", "--all"}, wantKind: kindList, wantListAll: true},
462		{name: "ls host extra", args: []string{"ls", "arch", "extra"}, wantErr: true},
463		{name: "ls --all host", args: []string{"ls", "--all", "arch"}, wantErr: true},
464		{name: "ls invalid host", args: []string{"ls", "bad host"}, wantErr: true, nonUsageErr: true},
465		{name: "host add", args: []string{"host", "add", "arch"}, wantKind: kindHost, wantHostAct: "add", wantHostTgt: "arch"},
466		{name: "host rm", args: []string{"host", "rm", "build@mule"}, wantKind: kindHost, wantHostAct: "rm", wantHostTgt: "build@mule"},
467		{name: "host list", args: []string{"host", "list"}, wantKind: kindHost, wantHostAct: "list"},
468		{name: "host alone", args: []string{"host"}, wantErr: true},
469		{name: "host add missing target", args: []string{"host", "add"}, wantErr: true},
470		{name: "host add extra", args: []string{"host", "add", "arch", "extra"}, wantErr: true},
471		{name: "host add invalid", args: []string{"host", "add", " bad"}, wantErr: true, nonUsageErr: true},
472		{name: "host unknown action", args: []string{"host", "rename", "arch"}, wantErr: true},
473		{name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
474		{name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
475		{name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
476		{name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
477		{name: "kill daemon", args: []string{"kill", "--daemon"}, wantKind: kindKill, wantDaemon: true},
478		{name: "kill without name", args: []string{"kill"}, wantErr: true},
479		{name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true},
480		{name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
481		{name: "kill daemon rejects extra arg", args: []string{"kill", "--daemon", "extra"}, wantErr: true},
482		{name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
483		{name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
484		{name: "stdio", args: []string{"_stdio"}, wantKind: kindStdio},
485		{name: "stdio rejects session", args: []string{"_stdio", "work"}, wantErr: true},
486		{name: "stdio rejects extra args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
487		{name: "udp bootstrap", args: []string{"_udp-bootstrap"}, wantKind: kindUDPBootstrap},
488		{name: "udp bootstrap rejects session", args: []string{"_udp-bootstrap", "work"}, wantErr: true},
489		{name: "udp proxy", args: []string{"_udp-proxy"}, wantKind: kindUDPProxy},
490		{name: "udp proxy rejects session", args: []string{"_udp-proxy", "work"}, wantErr: true},
491		{name: "udp proxy rejects extra args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
492		{name: "help", args: []string{"--help"}, wantKind: kindHelp},
493		{name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
494		{name: "version", args: []string{"--version"}, wantKind: kindVersion},
495		{name: "unknown", args: []string{"frobnicate"}, wantErr: true},
496	}
497
498	for _, tt := range tests {
499		t.Run(tt.name, func(t *testing.T) {
500			got, err := parseArgs(tt.args)
501			if tt.wantErr {
502				if err == nil {
503					t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
504				}
505				if tt.nonUsageErr {
506					_, usage := errors.AsType[*usageError](err)
507					if usage {
508						t.Fatalf("parseArgs(%q) error = *usageError, want non-usage error", tt.args)
509					}
510				} else {
511					_, usage := errors.AsType[*usageError](err)
512					if !usage {
513						t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
514					}
515				}
516				return
517			}
518			if err != nil {
519				t.Fatalf("parseArgs(%q) unexpected error: %v", tt.args, err)
520			}
521			if got.kind != tt.wantKind {
522				t.Errorf("kind = %v, want %v", got.kind, tt.wantKind)
523			}
524			if got.kind == kindAttach && got.intent != tt.wantIntent {
525				t.Errorf("intent = %d, want %d", got.intent, tt.wantIntent)
526			}
527			if got.name != tt.wantName {
528				t.Errorf("name = %q, want %q", got.name, tt.wantName)
529			}
530			if got.remoteTarget != tt.wantRemote {
531				t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
532			}
533			if got.listHost != tt.wantListHost {
534				t.Errorf("listHost = %q, want %q", got.listHost, tt.wantListHost)
535			}
536			if got.listAll != tt.wantListAll {
537				t.Errorf("listAll = %v, want %v", got.listAll, tt.wantListAll)
538			}
539			if got.hostAction != tt.wantHostAct {
540				t.Errorf("hostAction = %q, want %q", got.hostAction, tt.wantHostAct)
541			}
542			if got.hostTarget != tt.wantHostTgt {
543				t.Errorf("hostTarget = %q, want %q", got.hostTarget, tt.wantHostTgt)
544			}
545			if got.killAll != tt.wantAll {
546				t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
547			}
548			if got.killDaemon != tt.wantDaemon {
549				t.Errorf("killDaemon = %v, want %v", got.killDaemon, tt.wantDaemon)
550			}
551		})
552	}
553}
554
555func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
556	_, err := parseArgs([]string{"new", "my work"})
557	if !errors.Is(err, domain.ErrInvalidSessionName) {
558		t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
559	}
560}
561
562func TestAttachTargetsCreateCommonSessionRequest(t *testing.T) {
563	tests := []struct {
564		name string
565		arg  string
566		want client.AttachRequest
567	}{
568		{name: "local host", arg: "work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
569		{name: "remote host", arg: "user@example.com:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
570		{name: "remote ipv4", arg: "user@192.0.2.10:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
571		{name: "remote ipv6", arg: "user@[2001:db8::1]:work", want: client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work"}},
572	}
573	for _, tt := range tests {
574		t.Run(tt.name, func(t *testing.T) {
575			cmd, err := parseArgs([]string{"attach", tt.arg})
576			require.NoError(t, err)
577			connection, err := client.NewSessionConnection(portsmocks.NewMockTransport(t), client.SessionTarget{
578				Intent:      cmd.intent,
579				SessionName: cmd.name,
580			})
581			require.NoError(t, err)
582			require.Equal(t, tt.want, connection.AttachRequest())
583		})
584	}
585}
586
587func TestListShowsBroken(t *testing.T) {
588	var out bytes.Buffer
589	printSessions(&out, []ports.SessionInfo{
590		{Name: "fresh", State: ports.SessionDown},
591		{Name: "loading", State: ports.SessionDown},
592		{Name: "broken", State: ports.SessionBroken},
593	})
594	require.Contains(t, out.String(), "fresh")
595	require.Contains(t, out.String(), "down")
596	require.Contains(t, out.String(), "loading")
597	require.Contains(t, out.String(), "broken")
598	require.NotContains(t, out.String(), "restoring")
599	require.NotContains(t, out.String(), "degraded")
600}
601
602func TestPrintSessionsShowsDownState(t *testing.T) {
603	var out bytes.Buffer
604	printSessions(&out, []ports.SessionInfo{
605		{Name: "main", State: ports.SessionUp, Tabs: 2, Attached: true},
606		{Name: "old", State: ports.SessionDown},
607	})
608	got := out.String()
609	for _, want := range []string{"NAME", "STATE", "main", "up", "2", "yes", "old", "down", "-"} {
610		if !strings.Contains(got, want) {
611			t.Fatalf("printSessions output %q missing %q", got, want)
612		}
613	}
614}
615
616func TestPrintSessionsMarksEphemeral(t *testing.T) {
617	var buf bytes.Buffer
618	printSessions(&buf, []ports.SessionInfo{
619		{Name: "0", State: ports.SessionUp, Ephemeral: true, Tabs: 1, Attached: false},
620		{Name: "work", State: ports.SessionUp, Tabs: 2, Attached: true},
621		{Name: "old", State: ports.SessionDown},
622	})
623	out := buf.String()
624	for _, want := range []string{"0", "temporary", "work", "up", "old", "down"} {
625		require.Contains(t, out, want)
626	}
627}
628
629func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
630	t.Helper()
631	store, err := persist.OpenStore(persist.StorePath(stateDir))
632	require.NoError(t, err)
633	return persist.New(store)
634}
635
636func TestRunListReadsDownSessionsWithoutDaemon(t *testing.T) {
637	stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
638	t.Setenv("XDG_STATE_HOME", stateRoot)
639	t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
640
641	p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
642	now := time.Now().UnixNano()
643	if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
644		t.Fatalf("Save error = %v", err)
645	}
646	if err := p.Close(); err != nil {
647		t.Fatalf("Close error = %v", err)
648	}
649
650	got := captureStdout(t, func() {
651		if err := runList(context.Background(), command{kind: kindList}); err != nil {
652			t.Fatalf("runList error = %v", err)
653		}
654	})
655	for _, want := range []string{"stored", "down", "-"} {
656		if !strings.Contains(got, want) {
657			t.Fatalf("runList output %q missing %q", got, want)
658		}
659	}
660}
661
662func TestRunListUnreadableCatalogueProvidesResetGuidance(t *testing.T) {
663	stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
664	stateDir := filepath.Join(stateRoot, "vev")
665	runtimeDir := filepath.Join(runtimeRoot, "vev")
666	t.Setenv("XDG_STATE_HOME", stateRoot)
667	t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
668	require.NoError(t, safedir.EnsurePrivate(stateDir))
669
670	catalogue := persist.StorePath(stateDir)
671	before := []byte("corrupt catalogue")
672	require.NoError(t, os.WriteFile(catalogue, before, 0o600))
673
674	var runErr error
675	stdout := captureStdout(t, func() { runErr = Run([]string{"ls"}) })
676	require.Empty(t, stdout)
677	require.Error(t, runErr)
678	require.ErrorContains(t, runErr, stateDir)
679	require.ErrorContains(t, runErr, "rm -rf "+stateDir)
680	require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
681	after, err := os.ReadFile(catalogue)
682	require.NoError(t, err)
683	require.Equal(t, before, after)
684}
685
686func TestRunKillMissingStoppedSessionDoesNotCreateStore(t *testing.T) {
687	stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
688	t.Setenv("XDG_STATE_HOME", stateRoot)
689	t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
690
691	err := runKill(context.Background(), "missing", false, false)
692	if err == nil || !strings.Contains(err.Error(), "no such session: missing") {
693		t.Fatalf("runKill error = %v, want no such session", err)
694	}
695	if _, statErr := os.Stat(filepath.Join(stateRoot, "vev", "sessions.kv")); !errors.Is(statErr, os.ErrNotExist) {
696		t.Fatalf("sessions.kv stat error = %v, want not exist", statErr)
697	}
698}
699
700func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
701	stateRoot, runtimeRoot := t.TempDir(), t.TempDir()
702	t.Setenv("XDG_STATE_HOME", stateRoot)
703	t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
704
705	p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
706	now := time.Now().UnixNano()
707	if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
708		t.Fatalf("Save error = %v", err)
709	}
710	if err := p.Close(); err != nil {
711		t.Fatalf("Close error = %v", err)
712	}
713	got := captureStdout(t, func() {
714		if err := runKill(context.Background(), "stored", false, false); err != nil {
715			t.Fatalf("runKill error = %v", err)
716		}
717	})
718	if !strings.Contains(got, "killed stored") {
719		t.Fatalf("runKill output = %q, want success", got)
720	}
721	records, err := persist.LoadReadOnly(filepath.Join(stateRoot, "vev"))
722	if err != nil {
723		t.Fatalf("LoadReadOnly error = %v", err)
724	}
725	if len(records) != 0 {
726		t.Fatalf("records after kill = %#v, want none", records)
727	}
728}
729
730func captureStdout(t *testing.T, fn func()) string {
731	t.Helper()
732	old := os.Stdout
733	r, w, err := os.Pipe()
734	if err != nil {
735		t.Fatalf("os.Pipe error = %v", err)
736	}
737	os.Stdout = w
738	defer func() { os.Stdout = old }()
739	defer func() { _ = r.Close() }()
740	outCh := make(chan []byte, 1)
741	errCh := make(chan error, 1)
742	go func() {
743		out, err := io.ReadAll(r)
744		outCh <- out
745		errCh <- err
746	}()
747
748	fn()
749	_ = w.Close()
750	out := <-outCh
751	if err := <-errCh; err != nil {
752		t.Fatalf("ReadAll stdout error = %v", err)
753	}
754	return string(out)
755}
756
757func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
758	called := false
759	err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
760		createDetached: func(context.Context, string) error {
761			called = true
762			return nil
763		},
764	})
765	if err == nil {
766		t.Fatal("runAttach with VEV set returned nil error")
767	}
768	if called {
769		t.Fatal("runAttach dialed while nested attach should be rejected")
770	}
771	if !strings.Contains(err.Error(), "sessions should be nested with care") {
772		t.Fatalf("runAttach error = %q, want nested-session warning", err)
773	}
774}
775
776func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
777	var gotName string
778	err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
779		createDetached: func(_ context.Context, name string) error {
780			gotName = name
781			return nil
782		},
783	})
784	if err != nil {
785		t.Fatalf("runAttachWithDeps returned error: %v", err)
786	}
787	if gotName != "scratch" {
788		t.Fatalf("detached name = %q, want scratch", gotName)
789	}
790}
791
792func TestDetachedLocalHelloIncludesTrueColor(t *testing.T) {
793	t.Setenv("TERM", "xterm-direct")
794	t.Setenv("COLORTERM", "")
795
796	hello := detachedLocalHello("scratch", "/tmp/work")
797
798	require.Equal(t, ports.IntentNew, hello.Intent)
799	require.Equal(t, "scratch", hello.Name)
800	require.Equal(t, "xterm-direct", hello.TermEnv)
801	require.Equal(t, "/tmp/work", hello.Cwd)
802	require.True(t, hello.TrueColor)
803	require.Equal(t, os.Environ(), hello.Env)
804}
805
806type namedDialer struct{ name string }
807
808func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
809	return nil, errors.New("not used")
810}
811
812// fakeClipboardReader is a distinguishable ports.ClipboardReader used only to
813// verify identity (that runAttachWithDeps threads the *same* reader through),
814// never actually invoked in these wiring tests.
815type fakeClipboardReader struct{ ports.ClipboardReader }
816
817func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
818	tests := []struct {
819		name              string
820		selectedTransport string
821		wantMode          ports.RemoteTransportMode
822	}{
823		{name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
824		{name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
825	}
826
827	for _, tt := range tests {
828		t.Run(tt.name, func(t *testing.T) {
829			var gotDialer string
830			var gotRemote bool
831			var gotClipboard ports.ClipboardReader
832			clip := &fakeClipboardReader{}
833			factory := portsmocks.NewMockRemoteDialerFactory(t)
834			factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
835
836			err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
837				remoteDialerFactory:     factory,
838				selectedRemoteTransport: tt.selectedTransport,
839				clipboard:               clip,
840				runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
841					nd, ok := deps.Dialer.(namedDialer)
842					if !ok {
843						t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
844					}
845					gotDialer = nd.name
846					gotRemote = deps.Remote
847					if !request.Remote || request.EnvironmentPolicy != ports.EnvironmentPolicyClientOwned {
848						t.Fatal("direct CLI remote attach must preserve client-owned environment policy")
849					}
850					gotClipboard = deps.Clipboard
851					if request.Intent != ports.IntentAttach || request.SessionName != "work" {
852						t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
853					}
854					return nil
855				},
856			})
857			if err != nil {
858				t.Fatalf("runAttachWithDeps returned error: %v", err)
859			}
860			if gotDialer != "remote" {
861				t.Fatalf("remote dialer = %q, want remote", gotDialer)
862			}
863			if !gotRemote {
864				t.Fatal("remote attach must pass remote=true to runClient")
865			}
866			if gotClipboard != ports.ClipboardReader(clip) {
867				t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
868			}
869		})
870	}
871}
872
873func TestRunAttachWithDepsDirectLegacyCallbackPreservesClientOwnership(t *testing.T) {
874	factory := portsmocks.NewMockRemoteDialerFactory(t)
875	factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
876	factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
877
878	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
879		remoteDialerFactory: factory,
880		runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
881			require.True(t, request.Remote)
882			require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy)
883			nextDialer, nextRequest, err := deps.AttachHandoff(ports.AttachTarget{
884				Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach,
885				EnvironmentPolicy: ports.EnvironmentPolicyClientOwned,
886			})
887			require.NoError(t, err)
888			require.NotNil(t, nextDialer)
889			require.Equal(t, ports.EnvironmentPolicyClientOwned, nextRequest.EnvironmentPolicy)
890			return nil
891		},
892	})
893	require.NoError(t, err)
894}
895
896func TestRunAttachWithDepsRemotePickerHandoffReopensDirectConnection(t *testing.T) {
897	factory := portsmocks.NewMockRemoteDialerFactory(t)
898	factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-1"}, nil).Once()
899	factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-2"}, nil).Once()
900	var calls int
901	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
902		remoteDialerFactory: factory,
903		runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
904			calls++
905			require.NotNil(t, deps.Dialer)
906			require.True(t, request.Remote)
907			require.Equal(t, ports.EnvironmentPolicyClientOwned, request.EnvironmentPolicy, "direct CLI remote ownership must survive a handoff")
908			if calls == 1 {
909				return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
910			}
911			return nil
912		},
913	})
914	require.NoError(t, err)
915	require.Equal(t, 2, calls, "a picker handoff must close the old connection and open a fresh direct connection")
916}
917
918func TestRunAttachWithDepsAllowsRevisitingRemoteHandoff(t *testing.T) {
919	factory := portsmocks.NewMockRemoteDialerFactory(t)
920	factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
921	factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote-revisit"}, nil).Once()
922
923	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
924		remoteDialerFactory: factory,
925		runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
926			dialer, request, err := deps.AttachHandoff(ports.AttachTarget{
927				Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach,
928			})
929			require.NoError(t, err)
930			require.NotNil(t, dialer)
931			require.Equal(t, "work", request.SessionName)
932			return nil
933		},
934	})
935	require.NoError(t, err)
936}
937
938func TestRunAttachWithDepsBoundsRepeatedAttachTargetHandoffs(t *testing.T) {
939	factory := portsmocks.NewMockRemoteDialerFactory(t)
940	factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Times(maxAttachTargetHandoffs + 1)
941
942	calls := 0
943	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
944		remoteDialerFactory: factory,
945		runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
946			calls++
947			return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentAttach}}
948		},
949	})
950	require.ErrorContains(t, err, "attach handoff exceeded maximum")
951	require.Equal(t, maxAttachTargetHandoffs+1, calls)
952}
953
954func TestRunAttachWithDepsLocalPickerHandoffAttachesSelectedRemote(t *testing.T) {
955	factory := portsmocks.NewMockRemoteDialerFactory(t)
956	factory.EXPECT().DialerForRemote("selected.example", "picked", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
957
958	localCalls, remoteCalls := 0, 0
959	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "", "", nil, runAttachDeps{
960		localDialer:         func() ports.Dialer { return namedDialer{name: "local"} },
961		remoteDialerFactory: factory,
962		runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
963			if deps.Remote {
964				remoteCalls++
965				require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "picked", Remote: true, Origin: ports.RouteOriginDiscovery, OriginKey: "selected.example", RemoteOrigin: "selected.example", EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}, request)
966				return nil
967			}
968			localCalls++
969			require.Equal(t, client.AttachRequest{Intent: ports.IntentAttach, SessionName: "work", Origin: ports.RouteOriginLocal, OriginKey: "local"}, request)
970			return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "selected.example", Session: "picked", Intent: ports.IntentAttach}}
971		},
972	})
973	require.NoError(t, err)
974	require.Equal(t, 1, localCalls, "the failed local picker attach must not retry locally")
975	require.Equal(t, 1, remoteCalls, "the selected target must be dialed and attached exactly once")
976}
977
978func TestRunAttachWithDepsRejectsInvalidHandoffBeforeDialing(t *testing.T) {
979	tests := []struct {
980		name   string
981		target ports.AttachTarget
982	}{
983		{name: "invalid host", target: ports.AttachTarget{Endpoint: "remote host", Session: "work", Intent: ports.IntentAttach}},
984		{name: "invalid session", target: ports.AttachTarget{Endpoint: "remote.example", Session: "bad name", Intent: ports.IntentAttach}},
985		{name: "tokenless resume", target: ports.AttachTarget{Endpoint: "remote.example", Session: "work", Intent: ports.IntentResume}},
986		{
987			name: "tokenless resume with remote target",
988			target: ports.AttachTarget{
989				Endpoint: "remote.example", Session: "work", Intent: ports.IntentResume,
990				RemoteTarget: &domain.RemoteSessionTarget{
991					Endpoint: "remote.example", DisplayOrigin: "remote.example",
992					LifecycleID: domain.SessionLifecycleID{1}, SessionName: "work", LiveTabID: "tab-1",
993				},
994				EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
995			},
996		},
997	}
998	for _, tt := range tests {
999		t.Run(tt.name, func(t *testing.T) {
1000			factory := portsmocks.NewMockRemoteDialerFactory(t)
1001			factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
1002			calls := 0
1003			err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1004				remoteDialerFactory: factory,
1005				runClient: func(_ context.Context, _ client.Dependencies, _ client.AttachRequest) error {
1006					calls++
1007					return &client.AttachTargetError{Target: tt.target}
1008				},
1009			})
1010			require.ErrorContains(t, err, "invalid remote attach handoff")
1011			require.Equal(t, 1, calls)
1012		})
1013	}
1014}
1015
1016func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
1017	factory := portsmocks.NewMockRemoteDialerFactory(t)
1018	runClientCalled := false
1019
1020	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1021		remoteDialerFactory:     factory,
1022		selectedRemoteTransport: "serial",
1023		runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
1024			runClientCalled = true
1025			return nil
1026		},
1027	})
1028	if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` {
1029		t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err)
1030	}
1031	if runClientCalled {
1032		t.Fatal("runClient called after invalid remote transport")
1033	}
1034}
1035
1036func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
1037	factoryErr := errors.New("factory failed")
1038	factory := portsmocks.NewMockRemoteDialerFactory(t)
1039	factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
1040	runClientCalled := false
1041
1042	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
1043		remoteDialerFactory: factory,
1044		runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
1045			runClientCalled = true
1046			return nil
1047		},
1048	})
1049	if !errors.Is(err, factoryErr) {
1050		t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr)
1051	}
1052	if runClientCalled {
1053		t.Fatal("runClient called after remote factory error")
1054	}
1055}
1056
1057func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
1058	var gotDialer string
1059	gotRemote := true
1060	gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
1061	factory := portsmocks.NewMockRemoteDialerFactory(t)
1062	err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
1063		localDialer:         func() ports.Dialer { return namedDialer{name: "local"} },
1064		remoteDialerFactory: factory,
1065		clipboard:           &fakeClipboardReader{}, // must NOT reach runClient for a local attach
1066		runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
1067			nd, ok := deps.Dialer.(namedDialer)
1068			if !ok {
1069				t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
1070			}
1071			gotDialer = nd.name
1072			gotRemote = deps.Remote
1073			if request.Remote {
1074				t.Fatal("local carriage metadata must not be in the attach request")
1075			}
1076			gotClipboard = deps.Clipboard
1077			if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
1078				t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
1079			}
1080			return nil
1081		},
1082	})
1083	if err != nil {
1084		t.Fatalf("runAttachWithDeps returned error: %v", err)
1085	}
1086	if gotDialer != "local" {
1087		t.Fatalf("local dialer = %q, want local", gotDialer)
1088	}
1089	if gotRemote {
1090		t.Fatal("local attach must pass remote=false to runClient")
1091	}
1092	if gotClipboard != nil {
1093		t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
1094	}
1095}
1096
1097func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
1098	oldTimeout := udpBootstrapTimeout
1099	udpBootstrapTimeout = time.Second
1100	t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1101	oldCommand := udpProxyCommand
1102	var gotCmd *exec.Cmd
1103	udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1104		cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'")
1105		gotCmd = cmd
1106		return cmd
1107	}
1108	t.Cleanup(func() { udpProxyCommand = oldCommand })
1109
1110	got := captureStdout(t, func() {
1111		if err := runUDPBootstrap(context.Background()); err != nil {
1112			t.Fatalf("runUDPBootstrap() error = %v", err)
1113		}
1114	})
1115	if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" {
1116		t.Fatalf("readiness = %q", got)
1117	}
1118	if gotCmd.Stdin == nil || gotCmd.Stderr == nil {
1119		t.Fatalf("proxy stdio = stdin %T stderr %T, want detached from SSH channels", gotCmd.Stdin, gotCmd.Stderr)
1120	}
1121	if gotCmd.Stdout == os.Stdout || gotCmd.Stderr == os.Stderr || gotCmd.Stdin == os.Stdin {
1122		t.Fatalf("proxy inherited SSH stdio handles")
1123	}
1124}
1125
1126func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
1127	oldTimeout := udpBootstrapTimeout
1128	udpBootstrapTimeout = time.Second
1129	t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1130	oldCommand := udpProxyCommand
1131	udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1132		return exec.Command("/bin/sh", "-c", "exit 7")
1133	}
1134	t.Cleanup(func() { udpProxyCommand = oldCommand })
1135
1136	err := runUDPBootstrap(context.Background())
1137	if !errors.Is(err, io.EOF) {
1138		t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
1139	}
1140}
1141
1142func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
1143	oldTimeout := udpBootstrapTimeout
1144	udpBootstrapTimeout = 10 * time.Millisecond
1145	t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
1146	oldCommand := udpProxyCommand
1147	udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
1148		return exec.Command("/bin/sh", "-c", "sleep 5")
1149	}
1150	t.Cleanup(func() { udpProxyCommand = oldCommand })
1151
1152	err := runUDPBootstrap(context.Background())
1153	if !errors.Is(err, context.DeadlineExceeded) {
1154		t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
1155	}
1156}
1157
1158func TestParseUDPPortRange(t *testing.T) {
1159	tests := []struct {
1160		name      string
1161		value     string
1162		want      udpPortRange
1163		wantError bool
1164	}{
1165		{name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
1166		{name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
1167		{name: "zero range rejected", value: "0-100", wantError: true},
1168		{name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
1169		{name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
1170		{name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
1171		{name: "reversed range", value: "61023-61000", wantError: true},
1172		{name: "non-numeric", value: "abc", wantError: true},
1173		{name: "missing end", value: "61000-", wantError: true},
1174		{name: "port too high", value: "70000", wantError: true},
1175	}
1176	for _, tt := range tests {
1177		t.Run(tt.name, func(t *testing.T) {
1178			got, err := parseUDPPortRange(tt.value)
1179			if tt.wantError {
1180				if err == nil {
1181					t.Fatalf("parseUDPPortRange(%q) error = nil, want error", tt.value)
1182				}
1183				return
1184			}
1185			if err != nil {
1186				t.Fatalf("parseUDPPortRange(%q) error = %v", tt.value, err)
1187			}
1188			if got != tt.want {
1189				t.Fatalf("parseUDPPortRange(%q) = %+v, want %+v", tt.value, got, tt.want)
1190			}
1191		})
1192	}
1193}
1194
1195func TestListenUDPInRange(t *testing.T) {
1196	// Occupy a wildcard UDP port, then assert listenUDPInRange skips it.
1197	var lc net.ListenConfig
1198	occupied, err := lc.ListenPacket(context.Background(), "udp", ":0")
1199	if err != nil {
1200		t.Fatal(err)
1201	}
1202	defer func() { _ = occupied.Close() }()
1203	udpPort := func(t *testing.T, conn net.PacketConn) int {
1204		t.Helper()
1205		addr, ok := conn.LocalAddr().(*net.UDPAddr)
1206		if !ok {
1207			t.Fatalf("LocalAddr() = %T, want *net.UDPAddr", conn.LocalAddr())
1208		}
1209		return addr.Port
1210	}
1211	busyPort := udpPort(t, occupied)
1212
1213	t.Run("skips busy port", func(t *testing.T) {
1214		conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
1215		if err != nil {
1216			t.Fatalf("listenUDPInRange error = %v", err)
1217		}
1218		defer func() { _ = conn.Close() }()
1219		got := udpPort(t, conn)
1220		if got == busyPort {
1221			t.Fatalf("bound busy port %d, want a different port in range", busyPort)
1222		}
1223		if got < busyPort || got > busyPort+8 {
1224			t.Fatalf("bound port %d outside range %d-%d", got, busyPort, busyPort+8)
1225		}
1226	})
1227
1228	t.Run("exhausted range errors", func(t *testing.T) {
1229		_, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort})
1230		if err == nil {
1231			t.Fatal("listenUDPInRange error = nil, want exhausted-range error")
1232		}
1233		if !strings.Contains(err.Error(), "no free UDP port in range") {
1234			t.Fatalf("error = %v, want 'no free UDP port in range'", err)
1235		}
1236	})
1237
1238	t.Run("invalid direct range errors", func(t *testing.T) {
1239		_, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort + 8, end: busyPort})
1240		if err == nil {
1241			t.Fatal("listenUDPInRange error = nil, want invalid-range error")
1242		}
1243		if !strings.Contains(err.Error(), "invalid UDP port range") {
1244			t.Fatalf("error = %v, want 'invalid UDP port range'", err)
1245		}
1246	})
1247
1248	t.Run("ephemeral binds", func(t *testing.T) {
1249		conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
1250		if err != nil {
1251			t.Fatalf("listenUDPInRange ephemeral error = %v", err)
1252		}
1253		defer func() { _ = conn.Close() }()
1254		if udpPort(t, conn) == 0 {
1255			t.Fatal("ephemeral bind returned port 0")
1256		}
1257	})
1258}
1259
1260func TestPprofAddrIsLoopback(t *testing.T) {
1261	tests := []struct {
1262		addr string
1263		want bool
1264	}{
1265		{addr: "127.0.0.1:6060", want: true},
1266		{addr: "localhost:6060", want: true},
1267		{addr: "[::1]:6060", want: true},
1268		{addr: ":6060", want: false},
1269		{addr: "0.0.0.0:6060", want: false},
1270		{addr: "192.168.1.5:6060", want: false},
1271		{addr: "garbage", want: false},
1272	}
1273
1274	for _, tt := range tests {
1275		t.Run(tt.addr, func(t *testing.T) {
1276			if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
1277				t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
1278			}
1279		})
1280	}
1281}
1282
1283type appCatalogCacheStub struct {
1284	loads int
1285}
1286
1287func (s *appCatalogCacheStub) Load() ([]ports.RemoteCatalogCacheEntry, error) {
1288	s.loads++
1289	return nil, nil
1290}
1291
1292func (s *appCatalogCacheStub) Store([]ports.RemoteCatalogCacheEntry) error { return nil }
1293
1294func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
1295	oldHostStore := newRemoteHostStore
1296	oldCatalogCache := newRemoteCatalogCache
1297	oldCatalogClient := newRemoteCatalogClient
1298	oldDialerFactory := newRemoteDialerFactoryWithRuntimeObserver
1299	t.Cleanup(func() {
1300		newRemoteHostStore = oldHostStore
1301		newRemoteCatalogCache = oldCatalogCache
1302		newRemoteCatalogClient = oldCatalogClient
1303		newRemoteDialerFactoryWithRuntimeObserver = oldDialerFactory
1304	})
1305
1306	stateDir := t.TempDir()
1307	cache := &appCatalogCacheStub{}
1308	var hostPath, cachePath string
1309	newRemoteHostStore = func(path string) ports.RemoteHostStore {
1310		hostPath = path
1311		return nil
1312	}
1313	newRemoteCatalogCache = func(path string) ports.RemoteCatalogCache {
1314		cachePath = path
1315		return cache
1316	}
1317	newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil }
1318	newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) ports.RemoteDialerFactory { return nil }
1319
1320	option, err := remoteDiscoveryDaemonOption(stateDir, nil, "stdio")
1321	require.NoError(t, err)
1322	_ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option)
1323	require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath)
1324	require.Equal(t, remoteadapter.CatalogCachePath(stateDir), cachePath)
1325	require.Equal(t, 1, cache.loads, "daemon startup must load the cache once")
1326}
1327
1328func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) {
1329	_, err := remoteDiscoveryDaemonOption(t.TempDir(), nil, "serial")
1330	require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`)
1331}

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