1package client
2
3import (
4 "strings"
5 "testing"
6 "time"
7
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"
12 "github.com/bnema/vev/internal/protocol"
13 "github.com/bnema/vev/internal/protocol/catalogue"
14 "github.com/bnema/vev/internal/usecase/ui"
15)
16
17// Picker parity with main (Plan 003 B1-B4, C1). These port the behaviors the
18// deleted daemon picker tests pinned on main (picker_interaction_test.go,
19// remote_picker_test.go, picker_lines.go) onto the client-owned catalogue.
20
21func pickerTestTab(id, name, detail string, attention bool) catalogue.RemoteCatalogTab {
22 return catalogue.RemoteCatalogTab{ID: id, Name: name, Detail: detail, Attention: attention}
23}
24
25func pickerTestTabbed(name string, seed byte, state catalogue.RemoteCatalogSessionState, lastUsed uint64, active string, tabs ...catalogue.RemoteCatalogTab) catalogue.RemoteCatalogSession {
26 session := pickerTestSession(name, seed, state)
27 session.LastUsedSeq = lastUsed
28 session.ActiveTabID = active
29 for i := range tabs {
30 tabs[i].Index = uint16(i)
31 }
32 session.Tabs = tabs
33 return session
34}
35
36func pickerLinesOfKind(lines []protocol.PickerLine, kind protocol.PickerLineKind) []protocol.PickerLine {
37 out := make([]protocol.PickerLine, 0)
38 for _, line := range lines {
39 if line.Kind == kind {
40 out = append(out, line)
41 }
42 }
43 return out
44}
45
46// pickerTabKey returns the key of the tab row labelled label that follows the
47// session header labelled session.
48func pickerTabKey(t *testing.T, lines []protocol.PickerLine, session, label string) string {
49 t.Helper()
50 inSession := false
51 for _, line := range lines {
52 switch line.Kind {
53 case protocol.PickerLineSession, protocol.PickerLineHost, protocol.PickerLineSection:
54 inSession = line.Kind == protocol.PickerLineSession && line.Label == session
55 case protocol.PickerLineTab:
56 if inSession && line.Label == label {
57 return line.Key
58 }
59 }
60 }
61 t.Fatalf("no tab %q under session %q", label, session)
62 return ""
63}
64
65// TestPickerCatalogueTabRows ports main's picker_lines.go tab rows: a session
66// with tabs is a non-focusable header naming its tabs; each tab row is a
67// destination with its pane-title detail and its own bell, and the header
68// rings when any tab does.
69func TestPickerCatalogueTabRows(t *testing.T) {
70 now := time.Unix(1000, 0)
71 navigateKill := protocol.PickerCanNavigate | protocol.PickerCanKill
72 tests := []struct {
73 name string
74 daemon ports.BrokerDaemonObservation
75 session string
76 wantHeaderBel bool
77 wantTabs []protocol.PickerLine // Label, Detail, Attention, Actions, Stopped
78 }{
79 {
80 name: "live local session",
81 daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 1, catalogue_Up, 3, "t2",
82 pickerTestTab("t1", "shell", "(zsh)", false),
83 pickerTestTab("t2", "build", "(make)", true),
84 pickerTestTab("t3", "", "", false),
85 )),
86 session: "work",
87 wantHeaderBel: true,
88 wantTabs: []protocol.PickerLine{
89 {Label: "shell", Detail: "(zsh)", Actions: navigateKill},
90 {Label: "build", Detail: "(make)", Attention: true, Actions: navigateKill},
91 {Label: "3", Actions: navigateKill},
92 },
93 },
94 {
95 name: "stopped local session keeps its tabs",
96 daemon: pickerTestLocalObservation(now, pickerTestTabbed("old", 2, catalogue_Down, 1, "",
97 pickerTestTab("t1", "notes", "", false),
98 )),
99 session: "old",
100 wantTabs: []protocol.PickerLine{
101 {Label: "notes", Actions: navigateKill, Stopped: true},
102 },
103 },
104 {
105 name: "remote tabs navigate but never kill",
106 daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1",
107 pickerTestTab("r1", "logs", "(tail)", false),
108 pickerTestTab("", "anon", "", false),
109 )),
110 session: "remote-a",
111 wantTabs: []protocol.PickerLine{
112 {Label: "logs", Detail: "(tail)", Actions: protocol.PickerCanNavigate},
113 // A live tab without a stable ID cannot be targeted exactly.
114 {Label: "anon"},
115 },
116 },
117 }
118 for _, tt := range tests {
119 t.Run(tt.name, func(t *testing.T) {
120 catalogue, _ := pickerTestCatalogue(t)
121 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
122 for _, lines := range [][]protocol.PickerLine{catalogue.Lines(), catalogue.RecentLines()} {
123 header, ok := pickerLineByLabel(lines, tt.session)
124 require.True(t, ok)
125 require.Equal(t, protocol.PickerLineSession, header.Kind)
126 require.False(t, header.Focusable, "a tabbed session header is not a destination")
127 require.Zero(t, header.Actions)
128 require.Equal(t, tt.wantHeaderBel, header.Attention, "the header rings when any tab does")
129
130 tabs := pickerLinesOfKind(lines, protocol.PickerLineTab)
131 require.Len(t, tabs, len(tt.wantTabs))
132 for i, want := range tt.wantTabs {
133 got := tabs[i]
134 require.Equal(t, want.Label, got.Label)
135 require.Equal(t, want.Detail, got.Detail)
136 require.Equal(t, want.Attention, got.Attention)
137 require.Equal(t, want.Actions, got.Actions, "tab %q actions", got.Label)
138 require.Equal(t, want.Stopped, got.Stopped)
139 require.True(t, got.Focusable, "every tab row is reachable")
140 require.NotEqual(t, header.Key, got.Key)
141 }
142 }
143 })
144 }
145}
146
147// TestPickerCatalogueResolvesTabTargets pins exact tab targets: live and
148// stopped-local tabs prefer their stable tab, a stopped remote tab restores
149// through its exact selector (stable ID, else ordinal), and a tab that left
150// the session is gone.
151func TestPickerCatalogueResolvesTabTargets(t *testing.T) {
152 now := time.Unix(1000, 0)
153 lifecycle := pickerTestLifecycle(3)
154 tests := []struct {
155 name string
156 daemon ports.BrokerDaemonObservation
157 session string
158 tab string
159 after *ports.BrokerDaemonObservation
160 want attachmentTab
161 wantErr pickerCatalogueErrorCode
162 }{
163 {
164 name: "live local tab",
165 daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
166 session: "work", tab: "build",
167 want: attachmentTab{preferred: "t2"},
168 },
169 {
170 name: "stopped local tab",
171 daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Down, 1, "", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
172 session: "work", tab: "build",
173 want: attachmentTab{preferred: "t2"},
174 },
175 {
176 name: "live remote tab",
177 daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Up, 0, "r1", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))),
178 session: "remote-a", tab: "edit",
179 want: attachmentTab{preferred: "r2"},
180 },
181 {
182 name: "stopped remote tab by stable id",
183 daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("r1", "logs", "", false), pickerTestTab("r2", "edit", "", false))),
184 session: "remote-a", tab: "edit",
185 want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabID: "r2", TabIndex: protocol.NoTabIndex, Stopped: true}},
186 },
187 {
188 name: "stopped remote tab by ordinal",
189 daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 3, catalogue_Down, 0, "", pickerTestTab("", "logs", "", false), pickerTestTab("", "edit", "", false))),
190 session: "remote-a", tab: "edit",
191 want: attachmentTab{stopped: &protocol.SessionAttachTarget{LifecycleID: lifecycle, SessionName: "remote-a", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true}},
192 },
193 {
194 name: "tab closed after projection",
195 daemon: pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false), pickerTestTab("t2", "build", "", false))),
196 session: "work", tab: "build",
197 after: func() *ports.BrokerDaemonObservation {
198 o := pickerTestLocalObservation(now, pickerTestTabbed("work", 3, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false)))
199 return &o
200 }(),
201 wantErr: pickerCatalogueGone,
202 },
203 }
204 for _, tt := range tests {
205 t.Run(tt.name, func(t *testing.T) {
206 catalogue, _ := pickerTestCatalogue(t)
207 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
208 key := pickerTabKey(t, catalogue.Lines(), tt.session, tt.tab)
209 if tt.after != nil {
210 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}}))
211 }
212 request, tab, err := catalogue.ResolveTarget(key, pickerTestBase())
213 if tt.wantErr != 0 {
214 require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
215 return
216 }
217 require.NoError(t, err)
218 require.Equal(t, ports.BrokerAdmissionExact, request.Admission)
219 require.Equal(t, lifecycle, request.Target.LifecycleID)
220 require.Equal(t, tt.want, tab)
221 })
222 }
223}
224
225// TestPickerCatalogueRecentAndGroupedProjections ports main's
226// TestPickerSnapshotPublishesDistinctRecentAndGroupedProjections plus the MRU
227// order of picker.go: Recent is flat (live by recency, then remote, then
228// stopped by recency); Grouped is sectioned per daemon; both carry the same
229// destinations and actions.
230func TestPickerCatalogueRecentAndGroupedProjections(t *testing.T) {
231 now := time.Unix(1000, 0)
232 local := pickerTestLocalObservation(now,
233 pickerTestTabbed("alpha", 1, catalogue_Up, 5, "", pickerTestTab("a1", "sh", "", false)),
234 pickerTestTabbed("beta", 2, catalogue_Up, 9, "", pickerTestTab("b1", "sh", "", false)),
235 pickerTestTabbed("gamma", 3, catalogue_Down, 7, ""),
236 pickerTestTabbed("delta", 4, catalogue_Down, 8, ""),
237 )
238 remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 99, "", pickerTestTab("r1", "sh", "", false)))
239 catalogue, _ := pickerTestCatalogue(t)
240 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}}))
241 recent, grouped := catalogue.projections()
242
243 tests := []struct {
244 name string
245 lines []protocol.PickerLine
246 wantSessions []string
247 wantSections []string
248 }{
249 {name: "recent", lines: recent.Lines, wantSessions: []string{"beta", "alpha", "remote-a", "delta", "gamma"}, wantSections: []string{}},
250 {name: "grouped", lines: grouped.Lines, wantSessions: []string{"beta", "alpha", "delta", "gamma", "remote-a"}, wantSections: []string{"local", "user@arch"}},
251 }
252 for _, tt := range tests {
253 t.Run(tt.name, func(t *testing.T) {
254 require.Equal(t, tt.wantSessions, pickerSessionLabels(tt.lines))
255 require.Equal(t, tt.wantSections, pickerSectionLabels(tt.lines))
256 })
257 }
258 actions := func(lines []protocol.PickerLine) map[string]protocol.PickerLineActions {
259 out := make(map[string]protocol.PickerLineActions)
260 for _, line := range lines {
261 if line.Key != "" {
262 out[line.Key] = line.Actions
263 }
264 }
265 return out
266 }
267 require.Equal(t, actions(recent.Lines), actions(grouped.Lines), "projections disagree on destination actions")
268 require.NotEqual(t, recent.Lines, grouped.Lines, "both projections carry the same sort input")
269 remoteRecent, ok := pickerLineByLabel(recent.Lines, "remote-a")
270 require.True(t, ok)
271 require.Contains(t, remoteRecent.Detail, "@user@arch", "a flat remote row names its origin")
272 require.NoError(t, protocol.ValidatePickerSnapshot(protocol.PickerSnapshot{InteractionID: 1, SourceID: pickerCatalogueSourceID, SourceRevision: 1, Status: protocol.PickerSourceOK, Recent: recent, Grouped: grouped}))
273}
274
275// TestPickerCatalogueCursorStartsOnCurrent ports main's pickerSelectionMatches
276// and default cursor: the attached tab, else the attached session's active
277// tab, else the most recent session's active tab.
278func TestPickerCatalogueCursorStartsOnCurrent(t *testing.T) {
279 now := time.Unix(1000, 0)
280 local := pickerTestLocalObservation(now,
281 pickerTestTabbed("alpha", 1, catalogue_Up, 5, "a2", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)),
282 pickerTestTabbed("beta", 2, catalogue_Up, 9, "b2", pickerTestTab("b1", "one", "", false), pickerTestTab("b2", "two", "", false)),
283 )
284 remote := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "r1", pickerTestTab("r1", "one", "", false), pickerTestTab("r2", "two", "", false)))
285 tests := []struct {
286 name string
287 current pickerCurrent
288 wantSession string
289 wantTab string
290 }{
291 {name: "no attachment: most recent active tab", wantSession: "beta", wantTab: "two"},
292 {name: "attached tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a1"}, wantSession: "alpha", wantTab: "one"},
293 {name: "attached session, unknown tab", current: pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1)}, wantSession: "alpha", wantTab: "two"},
294 {name: "attached remote tab", current: pickerCurrent{known: true, endpoint: "user@arch", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "remote-a", wantTab: "two"},
295 {name: "other endpoint never matches", current: pickerCurrent{known: true, endpoint: "user@other", lifecycle: pickerTestLifecycle(5), tab: "r2"}, wantSession: "beta", wantTab: "two"},
296 }
297 for _, tt := range tests {
298 t.Run(tt.name, func(t *testing.T) {
299 catalogue, _ := pickerTestCatalogue(t)
300 catalogue.SetCurrent(tt.current)
301 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{local, remote}}))
302 recent, grouped := catalogue.projections()
303 for _, projection := range []protocol.PickerProjection{recent, grouped} {
304 want := pickerTabKey(t, projection.Lines, tt.wantSession, tt.wantTab)
305 require.Equal(t, want, projection.Cursor.Key)
306 require.Equal(t, want, projection.Lines[projection.Cursor.Index].Key)
307 }
308 })
309 }
310}
311
312// TestPickerControllerOverlayOpensOnCurrentTab pins that SetCurrent restarts
313// the presentation on the attached tab and drops a leftover search.
314func TestPickerControllerOverlayOpensOnCurrentTab(t *testing.T) {
315 controller, clock := pickerTestController(t)
316 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(),
317 pickerTestTabbed("alpha", 1, catalogue_Up, 1, "a1", pickerTestTab("a1", "one", "", false), pickerTestTab("a2", "two", "", false)),
318 pickerTestTabbed("beta", 2, catalogue_Up, 9, "b1", pickerTestTab("b1", "one", "", false)),
319 )}})
320 require.True(t, controller.ConsumeTerminalRead([]byte("/on")))
321 require.True(t, controller.modelSearchActive(t))
322
323 controller.SetCurrent(pickerCurrent{known: true, local: true, lifecycle: pickerTestLifecycle(1), tab: "a2"})
324 require.False(t, controller.modelSearchActive(t), "a fresh overlay starts without the previous search")
325 require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "alpha", "two"), mustCursorKey(t, controller))
326}
327
328// TestPickerControllerSearchesTabNamesAndPaneTitles pins that tab rows are
329// searchable by their tab name and their pane-title detail.
330func TestPickerControllerSearchesTabNamesAndPaneTitles(t *testing.T) {
331 tests := []struct {
332 name string
333 query string
334 want string
335 }{
336 {name: "tab name", query: "deploy", want: "deploy"},
337 {name: "pane title", query: "htop", want: "monitor"},
338 }
339 for _, tt := range tests {
340 t.Run(tt.name, func(t *testing.T) {
341 controller, clock := pickerTestController(t)
342 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(clock.Now(),
343 pickerTestTabbed("work", 1, catalogue_Up, 1, "w1",
344 pickerTestTab("w1", "shell", "(zsh)", false),
345 pickerTestTab("w2", "deploy", "(ansible)", false),
346 pickerTestTab("w3", "monitor", "(htop)", false),
347 ),
348 )}})
349 require.True(t, controller.ConsumeTerminalRead([]byte("/"+tt.query)))
350 require.Equal(t, pickerTabKey(t, controller.Catalogue().RecentLines(), "work", tt.want), mustCursorKey(t, controller))
351 })
352 }
353}
354
355// TestPickerControllerSortToggleSwitchesProjection pins that `s` switches
356// between the distinct Recent and Grouped projections.
357func TestPickerControllerSortToggleSwitchesProjection(t *testing.T) {
358 controller, clock := pickerTestController(t)
359 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
360 pickerTestLocalObservation(clock.Now(), pickerTestTabbed("alpha", 1, catalogue_Up, 5, ""), pickerTestTabbed("gamma", 3, catalogue_Down, 1, "")),
361 pickerTestRemoteObservation("user@arch", 1, 1, clock.Now(), pickerTestTabbed("remote-a", 5, catalogue_Up, 0, "")),
362 }})
363 order := func() []string {
364 frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30}))
365 positions := map[string]int{}
366 for _, label := range []string{"alpha", "gamma", "remote-a"} {
367 positions[label] = strings.Index(frame, label)
368 require.GreaterOrEqual(t, positions[label], 0, "%q is rendered", label)
369 }
370 labels := []string{"alpha", "gamma", "remote-a"}
371 for i := range labels {
372 for j := i + 1; j < len(labels); j++ {
373 if positions[labels[j]] < positions[labels[i]] {
374 labels[i], labels[j] = labels[j], labels[i]
375 }
376 }
377 }
378 return labels
379 }
380 grouped := []string{"alpha", "gamma", "remote-a"}
381 recent := []string{"alpha", "remote-a", "gamma"}
382 require.Equal(t, grouped, order(), "default grouped: local section, then remote")
383 require.True(t, controller.ConsumeTerminalRead([]byte("s")))
384 require.Equal(t, recent, order(), "recent: live, remote, stopped")
385
386 // Reopening the picker keeps this client's chosen sort.
387 controller.SetCurrent(pickerCurrent{})
388 require.Equal(t, recent, order(), "sort survives a reopen")
389 require.True(t, controller.ConsumeTerminalRead([]byte("s")))
390 require.Equal(t, grouped, order())
391}
392
393// TestPickerControllerRendersBellsAndRepaintsOnPublication ports main's
394// TestPickerSnapshotCarriesAttentionBellsToTheClient and
395// TestAttentionPulseRepublishesTheOpenPickerSource: a bell raised by a later
396// publication reaches the rendered picker.
397func TestPickerControllerRendersBellsAndRepaintsOnPublication(t *testing.T) {
398 controller, clock := pickerTestController(t)
399 quiet := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", false)))
400 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{quiet}})
401 bell := string(ui.AttentionGlyph)
402 require.NotContains(t, string(controller.Render(domain.Size{Cols: 100, Rows: 30})), bell)
403
404 ringing := pickerTestLocalObservation(clock.Now(), pickerTestTabbed("work", 1, catalogue_Up, 1, "t1", pickerTestTab("t1", "shell", "", true)))
405 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ringing}})
406 controller.invalidatePresentation()
407 frame := string(controller.Render(domain.Size{Cols: 100, Rows: 30}))
408 require.Equal(t, 2, strings.Count(frame, bell), "the header and its tab row both ring")
409}
410
411// TestPickerCatalogueResolveKill pins which rows `x` may destroy: local live
412// and stopped sessions (by exact lifecycle and name), never a remote row, and
413// never a lifecycle that left or was replaced.
414func TestPickerCatalogueResolveKill(t *testing.T) {
415 now := time.Unix(1000, 0)
416 tests := []struct {
417 name string
418 daemon ports.BrokerDaemonObservation
419 label string
420 after *ports.BrokerDaemonObservation
421 wantStopped bool
422 wantErr pickerCatalogueErrorCode
423 }{
424 {name: "live local", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha"},
425 {name: "stopped local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Down)), label: "alpha", wantStopped: true},
426 {name: "broken local deletes history", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken)), label: "alpha", wantStopped: true},
427 {name: "remote is refused", daemon: pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha", wantErr: pickerCatalogueUnavailable},
428 {
429 name: "gone", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha",
430 after: func() *ports.BrokerDaemonObservation { o := pickerTestLocalObservation(now); return &o }(),
431 wantErr: pickerCatalogueGone,
432 },
433 {
434 name: "replaced by the same name", daemon: pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), label: "alpha",
435 after: func() *ports.BrokerDaemonObservation {
436 o := pickerTestLocalObservation(now, pickerTestSession("alpha", 2, catalogue_Up))
437 return &o
438 }(),
439 wantErr: pickerCatalogueReplaced,
440 },
441 }
442 for _, tt := range tests {
443 t.Run(tt.name, func(t *testing.T) {
444 catalogue, _ := pickerTestCatalogue(t)
445 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}}))
446 line, ok := pickerLineByLabel(catalogue.Lines(), tt.label)
447 require.True(t, ok)
448 if tt.after != nil {
449 require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{*tt.after}}))
450 }
451 target, err := catalogue.ResolveKill(line.Key)
452 if tt.wantErr != 0 {
453 require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err)
454 return
455 }
456 require.NoError(t, err)
457 require.Equal(t, BrokerOperationRoute{Epoch: 3, Destination: ports.BrokerEndpointFence{Local: true}}, target.route)
458 require.NoError(t, target.route.Validate())
459 require.Equal(t, tt.label, target.name)
460 require.Equal(t, tt.wantStopped, target.stopped)
461 })
462 }
463}
464
465// TestPickerControllerKillKeyWakesSupervisor pins that `x` wakes the
466// supervisor with the killed row's key, only on a row that authorises it, and
467// types into an active search instead.
468func TestPickerControllerKillKeyWakesSupervisor(t *testing.T) {
469 tests := []struct {
470 name string
471 daemon func(time.Time) ports.BrokerDaemonObservation
472 input string
473 wantKill bool
474 wantKey bool
475 }{
476 {name: "local row", daemon: func(now time.Time) ports.BrokerDaemonObservation {
477 return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
478 }, input: "x", wantKill: true, wantKey: true},
479 {name: "remote row authorises no kill", daemon: func(now time.Time) ports.BrokerDaemonObservation {
480 return pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up))
481 }, input: "x", wantKill: true},
482 {name: "search types x", daemon: func(now time.Time) ports.BrokerDaemonObservation {
483 return pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up))
484 }, input: "/x"},
485 }
486 for _, tt := range tests {
487 t.Run(tt.name, func(t *testing.T) {
488 controller, clock := pickerTestController(t)
489 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon(clock.Now())}})
490 require.True(t, controller.ConsumeTerminalRead([]byte(tt.input)))
491 if tt.wantKill {
492 select {
493 case <-controller.OpsReady():
494 default:
495 t.Fatal("a kill decision must wake the supervisor")
496 }
497 }
498 op, key := controller.TakeOp()
499 require.Equal(t, tt.wantKill, op.kill)
500 if tt.wantKey {
501 require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), key)
502 } else {
503 require.Empty(t, key)
504 }
505 })
506 }
507}
508
509// TestPickerControllerRecoveryReplacesVisibleFailure checks that a host's
510// recovery toast replaces its failure toast and leaves other hosts' toasts.
511func TestPickerControllerRecoveryReplacesVisibleFailure(t *testing.T) {
512 controller, clock := pickerTestController(t)
513 down := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
514 down.Availability = domain.RemoteAvailabilityUnreachable
515 down.FailureEpisode = 1
516 other := pickerTestRemoteObservation("user@mule", 2, 2, clock.Now())
517 other.Availability = domain.RemoteAvailabilityUnreachable
518 other.FailureEpisode = 1
519 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{down, other}})
520
521 up := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now())
522 up.FailureEpisode = 1
523 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{up, other}})
524
525 messages := controller.state().notices
526 require.Len(t, messages, 2, "each host keeps one toast")
527 require.Contains(t, messages, "Remote host reconnected: user@arch")
528 for _, message := range messages {
529 require.NotContains(t, message, "Remote check failed: user@arch", "the recovery replaced the failure")
530 }
531}
532
533// TestPickerControllerHostFailureToastOncePerEpisode ports main's
534// TestRemoteFailureNoticeEmittedOncePerFailureEpisode and
535// TestRemoteFailureNoticesKeepEndpointsDistinct.
536func TestPickerControllerHostFailureToastOncePerEpisode(t *testing.T) {
537 controller, clock := pickerTestController(t)
538 failing := func(endpoint string, seed byte, episode uint64, kind domain.RemoteFailureKind) ports.BrokerDaemonObservation {
539 o := pickerTestRemoteObservation(endpoint, seed, seed, clock.Now())
540 o.Availability = domain.RemoteAvailabilityUnreachable
541 o.ConsecutiveFailures = 1
542 o.FailureEpisode = episode
543 o.LastFailure = domain.RemoteFailure{Kind: kind}
544 return o
545 }
546 revision := ports.BrokerRevision(0)
547 apply := func(daemons ...ports.BrokerDaemonObservation) []ui.QueuedToast[ui.Toast] {
548 revision++
549 controller.mu.Lock()
550 controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible})
551 controller.mu.Unlock()
552 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: revision, Daemons: daemons})
553 controller.mu.Lock()
554 defer controller.mu.Unlock()
555 return controller.notices.Visible(clock.Now())
556 }
557
558 toasts := apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust))
559 require.Len(t, toasts, 1)
560 require.Contains(t, toasts[0].Value.Message, "SSH host verification failed")
561 require.Contains(t, toasts[0].Value.Message, "user@arch")
562 require.Empty(t, apply(failing("user@arch", 1, 1, domain.RemoteFailureTrust)), "the same episode toasts once")
563 require.Len(t, apply(failing("user@arch", 1, 2, domain.RemoteFailureTrust)), 1, "a new episode toasts again")
564
565 recovered := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(time.Second))
566 recovered.FailureEpisode = 2
567 toasts = apply(recovered)
568 require.Len(t, toasts, 1, "a recovery toasts once")
569 require.Equal(t, "Remote host reconnected: user@arch", toasts[0].Value.Message)
570 require.Empty(t, apply(recovered), "a steady healthy host is silent")
571
572 require.Len(t, apply(failing("user@arch", 1, 3, domain.RemoteFailureTimeout)), 1)
573 unknown := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(2*time.Second))
574 unknown.Availability = domain.RemoteAvailabilityUnknown
575 unknown.FailureEpisode = 3
576 require.Empty(t, apply(unknown), "an in-between state is silent")
577 recovered = pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(3*time.Second))
578 recovered.FailureEpisode = 3
579 require.Len(t, apply(recovered), 1, "failing → unknown → reachable still reports recovery")
580 require.Len(t, apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout)), 1, "an outage after recovery toasts again")
581
582 toasts = apply(failing("user@arch", 1, 4, domain.RemoteFailureTimeout), failing("user@mule", 2, 1, domain.RemoteFailureAuthentication))
583 require.Len(t, toasts, 1, "only the newly failing endpoint toasts")
584 require.Contains(t, toasts[0].Value.Message, "user@mule")
585}
586
587// TestPickerControllerRefusesFailingRemoteInstantly pins B4: a commit on a
588// remote host the broker observed failing refuses at once with main's notice
589// instead of dialing, while a stale-but-reachable host stays attemptable.
590func TestPickerControllerRefusesFailingRemoteInstantly(t *testing.T) {
591 tests := []struct {
592 name string
593 availability domain.RemoteAvailability
594 wantNotice string
595 }{
596 {name: "unreachable", availability: domain.RemoteAvailabilityUnreachable, wantNotice: "Remote session unavailable: remote-a@user@arch — host unreachable"},
597 {name: "authentication", availability: domain.RemoteAvailabilityAuthFailed, wantNotice: "Remote session unavailable: remote-a@user@arch — authentication failed"},
598 {name: "reachable", availability: domain.RemoteAvailabilityReachable},
599 }
600 for _, tt := range tests {
601 t.Run(tt.name, func(t *testing.T) {
602 controller, clock := pickerTestController(t)
603 o := pickerTestRemoteObservation("user@arch", 1, 1, clock.Now().Add(-time.Hour), pickerTestSession("remote-a", 3, catalogue_Up))
604 o.Availability = tt.availability
605 controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{o}})
606 controller.mu.Lock()
607 controller.notices = ui.NewToastQueue[ui.Toast](ui.ToastQueueOptions{MaxVisible: pickerNoticeVisible})
608 controller.mu.Unlock()
609
610 _, _, err := controller.ResolveCommit(pickerRowKeyByLabel(t, controller, "remote-a"), pickerTestBase())
611 if tt.wantNotice == "" {
612 require.NoError(t, err)
613 return
614 }
615 require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnavailable))
616 controller.mu.Lock()
617 active := controller.notices.Visible(clock.Now())
618 controller.mu.Unlock()
619 require.Len(t, active, 1)
620 require.Equal(t, tt.wantNotice, active[0].Value.Message)
621 })
622 }
623}
624
625// TestSessionAttachmentHelloCarriesTab pins that a committed tab reaches the
626// daemon: a stable tab is preferred, a stopped remote tab is restored through
627// its exact selector, and a session-level commit carries neither.
628func TestSessionAttachmentHelloCarriesTab(t *testing.T) {
629 stopped := protocol.SessionAttachTarget{LifecycleID: domain.SessionLifecycleID{1}, SessionName: "alpha", TabIndex: 1, TabRawName: "edit", TabExpectedCount: 2, Stopped: true}
630 tests := []struct {
631 name string
632 local bool
633 tab attachmentTab
634 wantPreferred domain.TabStableID
635 wantTarget *protocol.SessionAttachTarget
636 }{
637 {name: "session level", local: true},
638 {name: "local tab", local: true, tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"},
639 {name: "remote live tab", tab: attachmentTab{preferred: "t_two"}, wantPreferred: "t_two"},
640 {name: "remote stopped tab", tab: attachmentTab{stopped: &stopped}, wantTarget: &stopped},
641 }
642 for _, tt := range tests {
643 t.Run(tt.name, func(t *testing.T) {
644 request := sessionTestRequest(tt.local)
645 provenance := SessionEnvironmentRemote
646 if tt.local {
647 provenance = SessionEnvironmentLocalPicker
648 }
649 worker, err := newSessionAttachmentWorker(sessionAttachmentConfig{Request: request, Geometry: domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, SessionEnvironment: SessionEnvironment{Provenance: provenance}, Tab: tt.tab})
650 require.NoError(t, err)
651 hello := worker.hello(newSessionTestStream())
652 require.Equal(t, tt.wantPreferred, hello.PreferredTabID)
653 require.Equal(t, tt.wantTarget, hello.SessionTarget)
654 require.NoError(t, protocol.ValidateHello(hello))
655 })
656 }
657}