main ece7682

list_readonly_test.go RawHistoryFolder

1package app
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"net"
8	"os"
9	"path/filepath"
10	"syscall"
11	"testing"
12	"time"
13
14	"github.com/stretchr/testify/require"
15
16	"github.com/bnema/vev/internal/adapters/ipc"
17	"github.com/bnema/vev/internal/adapters/sessionwire"
18	"github.com/bnema/vev/internal/domain"
19	"github.com/bnema/vev/internal/ports"
20	"github.com/bnema/vev/internal/protocol"
21	"github.com/bnema/vev/internal/protocol/catalogue"
22	"github.com/bnema/vev/internal/protocol/wire"
23)
24
25func TestObservedHostStatus(t *testing.T) {
26	now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
27	for _, tc := range []struct {
28		name         string
29		availability domain.RemoteAvailability
30		age          time.Duration
31		want         string
32		show         bool
33	}{
34		{"fresh", domain.RemoteAvailabilityReachable, time.Second, "reachable", true},
35		{"stale", domain.RemoteAvailabilityReachable, 2 * time.Minute, "stale (2m0s since last observation)", false},
36		{"unknown success", domain.RemoteAvailabilityReachable, 0, "stale (last observation unknown)", false},
37		{"never observed", domain.RemoteAvailabilityUnknown, time.Second, "not yet observed", false},
38		{"down", domain.RemoteAvailabilityUnreachable, time.Second, "down (unreachable)", false},
39		{"auth", domain.RemoteAvailabilityAuthFailed, time.Second, "down (authentication_failed)", false},
40		{"invalid", domain.RemoteAvailabilityInvalidResponse, time.Second, "down (invalid_response)", false},
41		{"incompatible", domain.RemoteAvailabilityIncompatible, time.Second, "incompatible (identity changed? use vev host rm/add)", false},
42		{"no daemon", domain.RemoteAvailabilityNoDaemon, time.Second, "no vev daemon — Enter in the picker to create a session", false},
43	} {
44		t.Run(tc.name, func(t *testing.T) {
45			observed := ports.BrokerDaemonObservation{Availability: tc.availability}
46			if tc.age != 0 {
47				observed.LastSuccess = now.Add(-tc.age)
48			}
49			status, show := observedHostStatus(observed, now)
50			require.Equal(t, tc.want, status)
51			require.Equal(t, tc.show, show)
52		})
53	}
54}
55
56func TestSnapshotListNeverShowsCachedUpWhenHostIsNotFresh(t *testing.T) {
57	now := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC)
58	for _, availability := range []domain.RemoteAvailability{domain.RemoteAvailabilityReachable, domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityNoDaemon} {
59		t.Run(availability.String(), func(t *testing.T) {
60			remote := ports.BrokerDaemonObservation{
61				Endpoint: "user@example.test", DisplayOrigin: "user@example.test",
62				Registration: domain.RemoteRegistration{Endpoint: "user@example.test", Incarnation: [16]byte{1}, Generation: 1},
63				Policy:       seamBrokerTestPolicy(), Availability: availability,
64				LastSuccess: now.Add(-time.Minute), InventoryKnown: true,
65				Sessions: []catalogue.RemoteCatalogSession{{LifecycleID: domain.SessionLifecycleID{1}, Name: "old", State: catalogue.RemoteCatalogSessionUp}},
66			}
67			snapshot := ports.BrokerSnapshot{Epoch: seamBrokerTestEpoch, Revision: 1, Daemons: []ports.BrokerDaemonObservation{remote}}
68			var output bytes.Buffer
69			require.NoError(t, renderBrokerSnapshotList(command{listAll: true}, snapshot, &output, now))
70			require.NotContains(t, output.String(), "old@")
71			require.NotContains(t, output.String(), " up")
72		})
73	}
74}
75
76func TestRunListWithoutBrokerDoesNotStartEitherProcess(t *testing.T) {
77	previous := connectListBroker
78	connectListBroker = func(context.Context) (ports.BrokerService, error) { return nil, errBrokerAbsent }
79	t.Cleanup(func() { connectListBroker = previous })
80	root := t.TempDir()
81	t.Setenv("XDG_RUNTIME_DIR", filepath.Join(root, "runtime"))
82	t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state"))
83	t.Setenv("XDG_CONFIG_HOME", filepath.Join(root, "config"))
84	for _, tc := range []struct {
85		name string
86		cmd  command
87		want string
88	}{
89		{"local", command{kind: kindList}, "no sessions"},
90		{"remote", command{kind: kindList, listHost: "user@example.test"}, "not connected (no broker running)"},
91		{"all", command{kind: kindList, listAll: true}, "no hosts"},
92	} {
93		t.Run(tc.name, func(t *testing.T) {
94			output := captureStdout(t, func() { require.NoError(t, runList(context.Background(), tc.cmd)) })
95			require.Contains(t, output, tc.want)
96		})
97	}
98	_, err := os.Stat(filepath.Join(root, "runtime", "vev"))
99	require.True(t, errors.Is(err, os.ErrNotExist))
100}
101
102func TestHostListUsesExistingConnector(t *testing.T) {
103	var output bytes.Buffer
104	called := false
105	deps := remoteHostDeps{
106		connect:         func(context.Context) (ports.BrokerService, error) { t.Fatal("list spawned a broker"); return nil, nil },
107		connectExisting: func(context.Context) (ports.BrokerService, error) { called = true; return nil, errBrokerAbsent },
108		stdout:          &output,
109	}
110	t.Setenv("XDG_STATE_HOME", t.TempDir())
111	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
112	require.NoError(t, runHostCommand(context.Background(), command{hostAction: hostActionList}, deps))
113	require.True(t, called)
114	require.Equal(t, "no hosts\n", output.String())
115}
116
117// TestStopDaemonWithoutBrokerReachesExistingDaemon pins that a stop request
118// with no broker running goes straight to an existing local daemon: the idle
119// broker exits long before the daemon, so its absence never implies that no
120// daemon runs, and nothing is started to find out.
121func TestStopDaemonWithoutBrokerReachesExistingDaemon(t *testing.T) {
122	tests := []struct {
123		name    string
124		daemon  bool
125		outcome protocol.KillOutcome
126		wantErr error
127	}{
128		{name: "running daemon stops", daemon: true, outcome: protocol.KillSucceeded},
129		{name: "absent daemon is reported, not started", daemon: false, wantErr: errDaemonNotRunning},
130	}
131	for _, tt := range tests {
132		t.Run(tt.name, func(t *testing.T) {
133			received := make(chan protocol.ClientMessage, 1)
134			previous := dialListDaemon
135			t.Cleanup(func() { dialListDaemon = previous })
136			dialListDaemon = func(context.Context, string, ...ipc.Option) (wire.Transport, error) {
137				if !tt.daemon {
138					return nil, &net.OpError{Op: "dial", Net: "unix", Err: syscall.ENOENT}
139				}
140				clientSide, serverSide := net.Pipe()
141				server := sessionwire.NewServerConnection(ipc.NewTransport(serverSide))
142				go func() {
143					defer func() { _ = server.Close() }()
144					message, err := server.ReceiveClient()
145					if err != nil {
146						return
147					}
148					received <- message
149					_ = server.SendServer(protocol.KillResult{RequestID: 1, Outcome: tt.outcome})
150				}()
151				return ipc.NewTransport(clientSide), nil
152			}
153
154			err := stopDaemonWithoutBroker(context.Background())
155			if tt.wantErr != nil {
156				require.ErrorIs(t, err, tt.wantErr)
157				return
158			}
159			require.NoError(t, err)
160			select {
161			case message := <-received:
162				require.Equal(t, protocol.Kill{RequestID: 1, Scope: protocol.KillDaemon}, message)
163			case <-time.After(5 * time.Second):
164				t.Fatal("the daemon never received the stop request")
165			}
166		})
167	}
168}

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