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	"os"
12	"path/filepath"
13	"strings"
14	"testing"
15	"time"
16
17	"github.com/bnema/vev/internal/adapters/daemonmux"
18	"github.com/bnema/vev/internal/adapters/lifecycle"
19	"github.com/bnema/vev/internal/domain"
20	"github.com/bnema/vev/internal/persist"
21	"github.com/bnema/vev/internal/ports"
22	"github.com/bnema/vev/internal/protocol"
23	"github.com/bnema/vev/internal/protocol/wire"
24	"github.com/bnema/vev/pkg/kv"
25	"github.com/bnema/vev/pkg/safedir"
26	"github.com/stretchr/testify/require"
27)
28
29func decodeJSONLogs(t *testing.T, data []byte) []map[string]any {
30	t.Helper()
31	var entries []map[string]any
32	scanner := bufio.NewScanner(bytes.NewReader(data))
33	for scanner.Scan() {
34		var entry map[string]any
35		require.NoError(t, json.Unmarshal(scanner.Bytes(), &entry))
36		entries = append(entries, entry)
37	}
38	require.NoError(t, scanner.Err())
39	return entries
40}
41
42func eventNames(entries []map[string]any) []string {
43	names := make([]string, 0, len(entries))
44	for _, entry := range entries {
45		if name, ok := entry["msg"].(string); ok {
46			names = append(names, name)
47		}
48	}
49	return names
50}
51
52func requireEvent(t *testing.T, entries []map[string]any, name string) map[string]any {
53	t.Helper()
54	for _, entry := range entries {
55		if entry["msg"] == name {
56			return entry
57		}
58	}
59	t.Fatalf("event %q not found in %v", name, eventNames(entries))
60	return nil
61}
62
63func TestRecoveryObservability(t *testing.T) {
64	var logBuffer bytes.Buffer
65	log := slog.New(slog.NewJSONHandler(&logBuffer, nil))
66	owner := fakeLifecycleOwnership{release: func() error { return nil }}
67	deps := lifecycleStartupDeps{
68		ensurePrivate: func(string) error { return nil },
69		acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
70			return owner, nil
71		},
72		log: log,
73	}
74	ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
75	records := []domain.CatalogueRecord{
76		{Committed: ref},
77		{Committed: ref},
78		{},
79		{Committed: ref, DegradedReason: "checkpoint validation failed"},
80	}
81	require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error {
82		logCatalogueRecovery(log, records, "current")
83		logStartupRecoveryCounts(log, records, 0)
84		return nil
85	}, deps))
86
87	entries := decodeJSONLogs(t, logBuffer.Bytes())
88	for _, name := range []string{"lifecycle_owner_wait", "lifecycle_owner_acquired", "catalogue_validated", "daemon_startup_complete", "lifecycle_owner_released"} {
89		require.Contains(t, eventNames(entries), name)
90	}
91	startup := requireEvent(t, entries, "daemon_startup_complete")
92	require.EqualValues(t, 2, startup["healthy"])
93	require.EqualValues(t, 1, startup["fresh"])
94	require.EqualValues(t, 0, startup["restoring"])
95	require.EqualValues(t, 1, startup["broken"])
96	require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete")
97}
98
99// A session-scoped conflict no longer aborts startup, so the daemon comes up
100// looking healthy. Startup must therefore name the fenced session in the log,
101// persist a notice for it, and never report the recovery as clean.
102type fakeLifecycleOwnership struct {
103	release func() error
104}
105
106func (o fakeLifecycleOwnership) Release() error { return o.release() }
107
108func TestCatalogueFailureDoesNotListen(t *testing.T) {
109	originalOpenCatalogue, originalListenDaemon := openCatalogue, listenDaemon
110	t.Cleanup(func() {
111		openCatalogue = originalOpenCatalogue
112		listenDaemon = originalListenDaemon
113	})
114
115	for _, catalogueErr := range []error{errors.New("catalogue corrupt"), errors.New("catalogue unavailable")} {
116		t.Run(catalogueErr.Error(), func(t *testing.T) {
117			runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
118			runtimeDir := filepath.Join(runtimeRoot, "vev")
119			stateDir := filepath.Join(stateRoot, "vev")
120			t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
121			t.Setenv("XDG_STATE_HOME", stateRoot)
122
123			listenCalls := 0
124			openCatalogue = func(gotStateDir string) (persist.OpenResult, error) {
125				require.Equal(t, stateDir, gotStateDir)
126				return persist.OpenResult{}, catalogueErr
127			}
128			listenDaemon = func(string, ports.SerializedRuntimeObserver) (wire.Listener, error) {
129				listenCalls++
130				return nil, errors.New("IPC listen must not run after catalogue failure")
131			}
132
133			err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(ctx context.Context) error {
134				return runDaemonOwnedWithLogger(ctx, slog.New(slog.NewTextHandler(io.Discard, nil)))
135			})
136			require.ErrorIs(t, err, catalogueErr)
137			require.Zero(t, listenCalls)
138			require.NoFileExists(t, filepath.Join(runtimeDir, "daemon.sock"))
139
140			owner, acquireErr := lifecycle.TryAcquire(runtimeDir)
141			require.NoError(t, acquireErr, "catalogue failure must release lifecycle ownership")
142			require.NoError(t, owner.Release())
143		})
144	}
145}
146
147// TestLifecycleOwnershipAcceptsOwnerOnlyVariantLock is the production-daemon
148// regression for the relaxed lock-permission rule: a pre-existing lock written
149// with an owner-only mode other than 0600 must still let the daemon start, while
150// group/other access stays refused (covered by the lifecycle unit tests).
151func TestLifecycleOwnershipAcceptsOwnerOnlyVariantLock(t *testing.T) {
152	runtimeDir := filepath.Join(t.TempDir(), "runtime")
153	stateDir := filepath.Join(t.TempDir(), "state")
154	require.NoError(t, safedir.EnsurePrivate(runtimeDir))
155	require.NoError(t, os.WriteFile(lifecycle.Path(runtimeDir), nil, 0o600))
156	require.NoError(t, os.Chmod(lifecycle.Path(runtimeDir), 0o700))
157
158	started := false
159	err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(context.Context) error {
160		started = true
161		return nil
162	})
163	require.NoError(t, err, "an owner-only variant lock must not block production daemon startup")
164	require.True(t, started)
165
166	owner, err := lifecycle.TryAcquire(runtimeDir)
167	require.NoError(t, err, "lifecycle ownership must be released after startup")
168	require.NoError(t, owner.Release())
169}
170
171func TestLifecycleOwnershipPrecedesDaemonStartup(t *testing.T) {
172	t.Run("ownership prefix is exact", func(t *testing.T) {
173		var events []string
174		owner := fakeLifecycleOwnership{release: func() error {
175			events = append(events, "unlock")
176			return nil
177		}}
178		deps := lifecycleStartupDeps{
179			ensurePrivate: func(path string) error {
180				if path == "runtime" {
181					events = append(events, "ensure-runtime")
182				} else {
183					events = append(events, "ensure-state")
184				}
185				return nil
186			},
187			acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
188				events = append(events, "lock")
189				return owner, nil
190			},
191		}
192
193		err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
194			events = append(events, "durable-open", "listen", "serve")
195			return nil
196		}, deps)
197		require.NoError(t, err)
198		require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock"}, events[:3])
199		require.Equal(t, []string{"ensure-runtime", "ensure-state", "lock", "durable-open", "listen", "serve", "unlock"}, events)
200	})
201
202	t.Run("callback observes held lock and failure releases it", func(t *testing.T) {
203		runtimeDir := filepath.Join(t.TempDir(), "runtime")
204		stateDir := filepath.Join(t.TempDir(), "state")
205		startErr := errors.New("catalogue corrupt")
206
207		err := runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, func(context.Context) error {
208			_, lockErr := lifecycle.TryAcquire(runtimeDir)
209			require.ErrorIs(t, lockErr, lifecycle.ErrBusy)
210			return startErr
211		})
212		require.ErrorIs(t, err, startErr)
213
214		owner, err := lifecycle.TryAcquire(runtimeDir)
215		require.NoError(t, err, "callback failure must release lifecycle ownership")
216		require.NoError(t, owner.Release())
217	})
218
219	t.Run("busy and unavailable ownership fail before startup", func(t *testing.T) {
220		runtimeDir := filepath.Join(t.TempDir(), "runtime")
221		stateDir := filepath.Join(t.TempDir(), "state")
222		owner, err := lifecycle.TryAcquire(runtimeDir)
223		require.NoError(t, err)
224		defer func() { require.NoError(t, owner.Release()) }()
225
226		ctx, cancel := context.WithCancel(context.Background())
227		cancel()
228		started := false
229		err = runWithLifecycleOwner(ctx, runtimeDir, stateDir, func(context.Context) error {
230			started = true
231			return nil
232		})
233		require.ErrorIs(t, err, context.Canceled)
234		require.False(t, started)
235
236		badState := filepath.Join(t.TempDir(), "state-file")
237		require.NoError(t, os.WriteFile(badState, []byte("unavailable"), 0o600))
238		err = runWithLifecycleOwner(context.Background(), filepath.Join(t.TempDir(), "other-runtime"), badState, func(context.Context) error {
239			started = true
240			return nil
241		})
242		require.Error(t, err)
243		require.False(t, started)
244	})
245
246	t.Run("corrupt catalogue fails before socket publication", func(t *testing.T) {
247		runtimeRoot, stateRoot := t.TempDir(), t.TempDir()
248		runtimeDir := filepath.Join(runtimeRoot, "vev")
249		stateDir := filepath.Join(stateRoot, "vev")
250		require.NoError(t, safedir.EnsurePrivate(stateDir))
251		store, err := kv.Open(persist.StorePath(stateDir))
252		require.NoError(t, err)
253		require.NoError(t, store.Set([]byte("work"), []byte("malformed catalogue value")))
254		require.NoError(t, store.Close())
255
256		t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
257		t.Setenv("XDG_STATE_HOME", stateRoot)
258		before, err := os.ReadFile(persist.StorePath(stateDir))
259		require.NoError(t, err)
260		err = runWithLifecycleOwner(context.Background(), runtimeDir, stateDir, runDaemonOwned)
261		require.Error(t, err)
262		require.Contains(t, err.Error(), stateDir)
263		require.Contains(t, err.Error(), "rm -rf "+stateDir)
264		after, readErr := os.ReadFile(persist.StorePath(stateDir))
265		require.NoError(t, readErr)
266		require.Equal(t, before, after, "failed startup must leave the catalogue untouched")
267		_, statErr := os.Stat(daemonmux.SocketPath(runtimeDir))
268		require.ErrorIs(t, statErr, os.ErrNotExist)
269	})
270
271	t.Run("proven absence creates the catalogue before socket publication", func(t *testing.T) {
272		runtimeRoot, err := os.MkdirTemp("/tmp", "vev-")
273		require.NoError(t, err)
274		t.Cleanup(func() { require.NoError(t, os.RemoveAll(runtimeRoot)) })
275		stateRoot := t.TempDir()
276		runtimeDir := filepath.Join(runtimeRoot, "vev")
277		stateDir := filepath.Join(stateRoot, "vev")
278		t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
279		t.Setenv("XDG_STATE_HOME", stateRoot)
280		ctx, cancel := context.WithCancel(t.Context())
281		result := make(chan error, 1)
282		go func() {
283			result <- runWithLifecycleOwner(ctx, runtimeDir, stateDir, runDaemonOwned)
284		}()
285		t.Cleanup(func() {
286			cancel()
287			select {
288			case err := <-result:
289				require.NoError(t, err)
290			case <-time.After(5 * time.Second):
291				t.Error("daemon lifecycle did not stop after cancellation")
292			}
293		})
294
295		require.Eventually(t, func() bool {
296			_, err := os.Stat(daemonmux.SocketPath(runtimeDir))
297			return err == nil
298		}, 5*time.Second, time.Millisecond, "daemon mux socket was not published")
299		require.FileExists(t, persist.StorePath(stateDir))
300	})
301
302	t.Run("callback and release errors are joined", func(t *testing.T) {
303		startErr := errors.New("catalogue unavailable")
304		releaseErr := errors.New("unlock failed")
305		deps := lifecycleStartupDeps{
306			ensurePrivate: func(string) error { return nil },
307			acquire: func(context.Context, string, time.Duration) (lifecycleOwnership, error) {
308				return fakeLifecycleOwnership{release: func() error { return releaseErr }}, nil
309			},
310		}
311
312		err := runWithLifecycleOwnerDeps(context.Background(), "runtime", "state", func(context.Context) error {
313			return startErr
314		}, deps)
315		require.ErrorIs(t, err, startErr)
316		require.ErrorIs(t, err, releaseErr)
317	})
318}
319
320func TestDevelopmentTempDirOption(t *testing.T) {
321	absoluteBase := filepath.Join(t.TempDir(), ".dev")
322	creatorErr := errors.New("cannot secure directory")
323	tests := []struct {
324		name        string
325		environment string
326		base        string
327		creatorErr  error
328		wantPath    string
329		wantOption  bool
330		wantErr     error
331	}{
332		{
333			name: "inactive environment",
334			base: absoluteBase,
335		},
336		{
337			name:        "active environment",
338			environment: "work",
339			base:        absoluteBase,
340			wantPath:    filepath.Join(absoluteBase, "work", "tmp"),
341			wantOption:  true,
342		},
343		{
344			name:        "creator error",
345			environment: "work",
346			base:        absoluteBase,
347			creatorErr:  creatorErr,
348			wantPath:    filepath.Join(absoluteBase, "work", "tmp"),
349			wantErr:     creatorErr,
350		},
351	}
352
353	for _, tt := range tests {
354		t.Run(tt.name, func(t *testing.T) {
355			t.Setenv("VEV_ENV", tt.environment)
356			t.Setenv("VEV_ENV_ROOT", tt.base)
357			var creatorCalls []string
358
359			option, err := developmentTempDirOption(func(path string) error {
360				creatorCalls = append(creatorCalls, path)
361				return tt.creatorErr
362			})
363
364			if tt.wantErr != nil {
365				require.ErrorIs(t, err, tt.wantErr)
366				require.Nil(t, option)
367			} else {
368				require.NoError(t, err)
369				if tt.wantOption {
370					require.NotNil(t, option)
371				} else {
372					require.Nil(t, option)
373				}
374			}
375			if tt.wantPath == "" {
376				require.Empty(t, creatorCalls)
377			} else {
378				require.Equal(t, []string{tt.wantPath}, creatorCalls)
379				require.True(t, filepath.IsAbs(tt.wantPath))
380			}
381		})
382	}
383}
384
385func TestRunActivatesDevelopmentEnvironmentBeforeParsing(t *testing.T) {
386	t.Setenv("VEV_ENV", "../live")
387	t.Setenv("XDG_CONFIG_HOME", "/existing/config")
388	t.Setenv("XDG_STATE_HOME", "/existing/state")
389	t.Setenv("XDG_RUNTIME_DIR", "/existing/runtime")
390
391	err := Run([]string{"unknown"})
392	require.ErrorContains(t, err, "invalid VEV_ENV")
393	require.NotContains(t, err.Error(), "unknown command")
394	require.Equal(t, "/existing/config", os.Getenv("XDG_CONFIG_HOME"))
395	require.Equal(t, "/existing/state", os.Getenv("XDG_STATE_HOME"))
396	require.Equal(t, "/existing/runtime", os.Getenv("XDG_RUNTIME_DIR"))
397}
398
399func TestParseArgs(t *testing.T) {
400	tests := []struct {
401		name         string
402		args         []string
403		wantKind     cmdKind
404		wantIntent   uint8
405		wantName     string
406		wantRemote   string
407		wantListHost string
408		wantListAll  bool
409		wantHostAct  string
410		wantHostTgt  string
411		wantAll      bool
412		wantSessions bool
413		wantErr      bool
414		nonUsageErr  bool
415	}{
416		{name: "no args -> ephemeral attach", args: nil, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral},
417		{name: "empty slice -> ephemeral attach", args: []string{}, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral},
418		{name: "new named", args: []string{"new", "work"}, wantKind: kindAttach, wantIntent: protocol.IntentNew, wantName: "work"},
419		{name: "new remote", args: []string{"new", "work", "user@example.com"}, wantKind: kindAttach, wantIntent: protocol.IntentNew, wantName: "work", wantRemote: "user@example.com"},
420		{name: "new invalid remote", args: []string{"new", "work", "bad host"}, wantErr: true, nonUsageErr: true},
421		{name: "new without name", args: []string{"new"}, wantErr: true},
422		{name: "new empty name", args: []string{"new", ""}, wantErr: true},
423		{name: "new command override unsupported", args: []string{"new", "work", "--", "sh"}, wantErr: true},
424		{name: "attach named", args: []string{"attach", "work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "work"},
425		{name: "attach preserves legacy unsafe name", args: []string{"attach", "my work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "my work"},
426		{name: "attach alias a", args: []string{"a", "work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "work"},
427		{name: "attach remote host uses ephemeral", args: []string{"attach", "user@example.com"}, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral, wantRemote: "user@example.com"},
428		{name: "attach remote host with empty session uses ephemeral", args: []string{"attach", "user@example.com:"}, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral, wantRemote: "user@example.com"},
429		{name: "attach remote host with session", args: []string{"attach", "user@example.com:work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "work", wantRemote: "user@example.com"},
430		{name: "attach remote ipv4 with session", args: []string{"attach", "user@192.0.2.10:work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "work", wantRemote: "user@192.0.2.10"},
431		{name: "attach remote ipv6 with session", args: []string{"attach", "user@[2001:db8::1]:work"}, wantKind: kindAttach, wantIntent: protocol.IntentAttach, wantName: "work", wantRemote: "user@[2001:db8::1]"},
432		{name: "attach remote invalid session", args: []string{"attach", "user@example.com:my work"}, wantErr: true, nonUsageErr: true},
433		{name: "attach extra arg", args: []string{"attach", "work", "extra"}, wantErr: true},
434		{name: "attach without name", args: []string{"attach"}, wantErr: true},
435		{name: "ls", args: []string{"ls"}, wantKind: kindList},
436		{name: "list", args: []string{"list"}, wantKind: kindList},
437		{name: "ls host", args: []string{"ls", "arch"}, wantKind: kindList, wantListHost: "arch"},
438		{name: "list host", args: []string{"list", "build@mule"}, wantKind: kindList, wantListHost: "build@mule"},
439		{name: "ls --all", args: []string{"ls", "--all"}, wantKind: kindList, wantListAll: true},
440		{name: "list --all", args: []string{"list", "--all"}, wantKind: kindList, wantListAll: true},
441		{name: "ls host extra", args: []string{"ls", "arch", "extra"}, wantErr: true},
442		{name: "ls --all host", args: []string{"ls", "--all", "arch"}, wantErr: true},
443		{name: "ls invalid host", args: []string{"ls", "bad host"}, wantErr: true, nonUsageErr: true},
444		{name: "host add", args: []string{"host", "add", "arch"}, wantKind: kindHost, wantHostAct: "add", wantHostTgt: "arch"},
445		{name: "host rm", args: []string{"host", "rm", "build@mule"}, wantKind: kindHost, wantHostAct: "rm", wantHostTgt: "build@mule"},
446		{name: "host list", args: []string{"host", "list"}, wantKind: kindHost, wantHostAct: "list"},
447		{name: "host alone", args: []string{"host"}, wantErr: true},
448		{name: "host add missing target", args: []string{"host", "add"}, wantErr: true},
449		{name: "host add extra", args: []string{"host", "add", "arch", "extra"}, wantErr: true},
450		{name: "host add invalid", args: []string{"host", "add", " bad"}, wantErr: true, nonUsageErr: true},
451		{name: "host unknown action", args: []string{"host", "rename", "arch"}, wantErr: true},
452		{name: "kill named", args: []string{"kill", "work"}, wantKind: kindKill, wantName: "work"},
453		{name: "kill preserves legacy unsafe name via terminator", args: []string{"kill", "--", "my work"}, wantKind: kindKill, wantName: "my work"},
454		{name: "kill dashed name via terminator", args: []string{"kill", "--", "--all"}, wantKind: kindKill, wantName: "--all"},
455		{name: "kill all", args: []string{"kill", "--all"}, wantKind: kindKill, wantAll: true},
456		{name: "kill sessions", args: []string{"kill", "--sessions"}, wantKind: kindKill, wantSessions: true},
457		{name: "kill sessions rejects extra arg", args: []string{"kill", "--sessions", "extra"}, wantErr: true},
458		{name: "hidden daemon stop", args: []string{"_daemon-stop"}, wantKind: kindDaemonStop},
459		{name: "hidden daemon stop rejects args", args: []string{"_daemon-stop", "x"}, wantErr: true},
460		{name: "kill unknown flag", args: []string{"kill", "-x"}, wantErr: true},
461		{name: "kill removed daemon flag", args: []string{"kill", "--daemon"}, wantErr: true},
462		{name: "kill removed broker flag", args: []string{"kill", "--broker"}, wantErr: true},
463		{name: "kill without name", args: []string{"kill"}, wantErr: true},
464		{name: "kill terminator without name", args: []string{"kill", "--"}, wantErr: true},
465		{name: "kill terminator empty name", args: []string{"kill", "--", ""}, wantErr: true},
466		{name: "leading ui socket without value", args: []string{"--ui-socket"}, wantErr: true},
467		{name: "leading ui flag before ls", args: []string{"--ui-observe", "ls"}, wantErr: true},
468		{name: "leading ui flag before ui-driver", args: []string{"--ui-observe", "--ui-driver"}, wantErr: true},
469		{name: "leading ui flag only", args: []string{"--ui-observe"}, wantKind: kindAttach, wantIntent: protocol.IntentEphemeral},
470		{name: "new remote with ui flag", args: []string{"new", "work", "user@example.com", "--ui-control"}, wantKind: kindAttach, wantIntent: protocol.IntentNew, wantName: "work", wantRemote: "user@example.com"},
471		{name: "web renew rejects args", args: []string{"--web-renew-token", "x"}, wantErr: true},
472		{name: "kill all rejects extra arg", args: []string{"kill", "--all", "extra"}, wantErr: true},
473		{name: "kill extra arg", args: []string{"kill", "work", "extra"}, wantErr: true},
474		{name: "daemon", args: []string{"--daemon"}, wantKind: kindDaemon},
475		{name: "removed stdio", args: []string{"_stdio"}, wantErr: true},
476		{name: "removed quic bootstrap", args: []string{"_quic-bootstrap"}, wantErr: true},
477		{name: "removed quic proxy", args: []string{"_quic-proxy"}, wantErr: true},
478		{name: "removed udp bootstrap", args: []string{"_udp-bootstrap"}, wantErr: true},
479		{name: "removed udp proxy", args: []string{"_udp-proxy"}, wantErr: true},
480		{name: "removed udp proxy rejects session", args: []string{"_udp-proxy", "work"}, wantErr: true},
481		{name: "removed udp proxy rejects extra args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
482		{name: "help", args: []string{"--help"}, wantKind: kindHelp},
483		{name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
484		{name: "version", args: []string{"--version"}, wantKind: kindVersion},
485		{name: "unknown", args: []string{"frobnicate"}, wantErr: true},
486	}
487
488	for _, tt := range tests {
489		t.Run(tt.name, func(t *testing.T) {
490			got, err := parseArgs(tt.args)
491			if tt.wantErr {
492				if err == nil {
493					t.Fatalf("parseArgs(%q) = %+v, want error", tt.args, got)
494				}
495				if tt.nonUsageErr {
496					_, usage := errors.AsType[*usageError](err)
497					if usage {
498						t.Fatalf("parseArgs(%q) error = *usageError, want non-usage error", tt.args)
499					}
500				} else {
501					_, usage := errors.AsType[*usageError](err)
502					if !usage {
503						t.Fatalf("parseArgs(%q) error = %T, want *usageError", tt.args, err)
504					}
505				}
506				return
507			}
508			if err != nil {
509				t.Fatalf("parseArgs(%q) unexpected error: %v", tt.args, err)
510			}
511			if got.kind != tt.wantKind {
512				t.Errorf("kind = %v, want %v", got.kind, tt.wantKind)
513			}
514			if got.kind == kindAttach && got.intent != tt.wantIntent {
515				t.Errorf("intent = %d, want %d", got.intent, tt.wantIntent)
516			}
517			if got.name != tt.wantName {
518				t.Errorf("name = %q, want %q", got.name, tt.wantName)
519			}
520			if got.remoteTarget != tt.wantRemote {
521				t.Errorf("remoteTarget = %q, want %q", got.remoteTarget, tt.wantRemote)
522			}
523			if got.listHost != tt.wantListHost {
524				t.Errorf("listHost = %q, want %q", got.listHost, tt.wantListHost)
525			}
526			if got.listAll != tt.wantListAll {
527				t.Errorf("listAll = %v, want %v", got.listAll, tt.wantListAll)
528			}
529			if got.hostAction != tt.wantHostAct {
530				t.Errorf("hostAction = %q, want %q", got.hostAction, tt.wantHostAct)
531			}
532			if got.hostTarget != tt.wantHostTgt {
533				t.Errorf("hostTarget = %q, want %q", got.hostTarget, tt.wantHostTgt)
534			}
535			if got.killAll != tt.wantAll {
536				t.Errorf("killAll = %v, want %v", got.killAll, tt.wantAll)
537			}
538			if got.killSessions != tt.wantSessions {
539				t.Errorf("killSessions = %v, want %v", got.killSessions, tt.wantSessions)
540			}
541		})
542	}
543}
544
545func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
546	_, err := parseArgs([]string{"new", "my work"})
547	if !errors.Is(err, domain.ErrInvalidSessionName) {
548		t.Fatalf("parseArgs new unsafe error = %v, want %v", err, domain.ErrInvalidSessionName)
549	}
550}
551
552func TestListShowsBroken(t *testing.T) {
553	var out bytes.Buffer
554	printSessions(&out, []protocol.SessionInfo{
555		{Name: "fresh", State: protocol.SessionDown},
556		{Name: "loading", State: protocol.SessionDown},
557		{Name: "broken", State: protocol.SessionBroken},
558	})
559	require.Contains(t, out.String(), "fresh")
560	require.Contains(t, out.String(), "down")
561	require.Contains(t, out.String(), "loading")
562	require.Contains(t, out.String(), "broken")
563	require.NotContains(t, out.String(), "restoring")
564	require.NotContains(t, out.String(), "degraded")
565}
566
567func TestPrintSessionsShowsDownState(t *testing.T) {
568	var out bytes.Buffer
569	printSessions(&out, []protocol.SessionInfo{
570		{Name: "main", State: protocol.SessionUp, Tabs: 2, Attached: true},
571		{Name: "old", State: protocol.SessionDown},
572	})
573	got := out.String()
574	for _, want := range []string{"NAME", "STATE", "main", "up", "2", "yes", "old", "down", "-"} {
575		if !strings.Contains(got, want) {
576			t.Fatalf("printSessions output %q missing %q", got, want)
577		}
578	}
579}
580
581func TestPrintSessionsMarksEphemeral(t *testing.T) {
582	var buf bytes.Buffer
583	printSessions(&buf, []protocol.SessionInfo{
584		{Name: "0", State: protocol.SessionUp, Ephemeral: true, Tabs: 1, Attached: false},
585		{Name: "work", State: protocol.SessionUp, Tabs: 2, Attached: true},
586		{Name: "old", State: protocol.SessionDown},
587	})
588	out := buf.String()
589	for _, want := range []string{"0", "temporary", "work", "up", "old", "down"} {
590		require.Contains(t, out, want)
591	}
592}
593
594func newTestPersister(t *testing.T, stateDir string) *persist.Persister {
595	t.Helper()
596	store, err := persist.OpenStore(persist.StorePath(stateDir))
597	require.NoError(t, err)
598	return persist.New(store)
599}
600
601func captureStdout(t *testing.T, fn func()) string {
602	t.Helper()
603	old := os.Stdout
604	r, w, err := os.Pipe()
605	if err != nil {
606		t.Fatalf("os.Pipe error = %v", err)
607	}
608	os.Stdout = w
609	defer func() { os.Stdout = old }()
610	defer func() { _ = r.Close() }()
611	outCh := make(chan []byte, 1)
612	errCh := make(chan error, 1)
613	go func() {
614		out, err := io.ReadAll(r)
615		outCh <- out
616		errCh <- err
617	}()
618
619	fn()
620	_ = w.Close()
621	out := <-outCh
622	if err := <-errCh; err != nil {
623		t.Fatalf("ReadAll stdout error = %v", err)
624	}
625	return string(out)
626}
627
628func TestPprofAddrIsLoopback(t *testing.T) {
629	tests := []struct {
630		addr string
631		want bool
632	}{
633		{addr: "127.0.0.1:6060", want: true},
634		{addr: "localhost:6060", want: true},
635		{addr: "[::1]:6060", want: true},
636		{addr: ":6060", want: false},
637		{addr: "0.0.0.0:6060", want: false},
638		{addr: "192.168.1.5:6060", want: false},
639		{addr: "garbage", want: false},
640	}
641
642	for _, tt := range tests {
643		t.Run(tt.addr, func(t *testing.T) {
644			if got := pprofAddrIsLoopback(tt.addr); got != tt.want {
645				t.Fatalf("pprofAddrIsLoopback(%q) = %v, want %v", tt.addr, got, tt.want)
646			}
647		})
648	}
649}

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