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}