okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: sessions and folders fuzzy filter, shared confirm and footers10e0f66

main
13.4 KiB371 linesraw
1// The folder picker's query grammar and host states are bug-prone logic
2// the e2e suite cannot reach without a real remote host: which text lists a
3// host's folders, which completes a host, how remote paths fold under that
4// host's home, and that a dead host is never picked.
5package tui
6
7import (
8	"context"
9	"errors"
10	"slices"
11	"strings"
12	"testing"
13	"time"
14
15	tea "charm.land/bubbletea/v2"
16	"github.com/charmbracelet/x/ansi"
17
18	"albedo/cli/internal/daemon"
19)
20
21func TestParseQuery(t *testing.T) {
22	for _, c := range []struct {
23		q, dir, segment string
24		kind            queryKind
25	}{
26		{"", "", "", queryRecent},
27		{"albedo", "", "albedo", queryRecent},
28		{"~", "~", "", queryListing},
29		{"~/pro", "~/", "pro", queryListing},
30		{"/srv/", "/srv/", "", queryListing},
31		{"src/", "src/", "", queryListing},
32		{"mayer@cher", "", "mayer@cher", queryHosts},
33		{"@cher", "", "@cher", queryHosts},
34		{"chernobog:", "chernobog:~", "", queryListing},
35		{"chernobog:~", "chernobog:~", "", queryListing},
36		{"chernobog:pro", "chernobog:~", "pro", queryListing},
37		{"chernobog:~/proj/", "chernobog:~/proj/", "", queryListing},
38		{"chernobog:~/proj/al", "chernobog:~/proj/", "al", queryListing},
39		{"mayer@chernobog:/srv/", "mayer@chernobog:/srv/", "", queryListing},
40		{"[::1]:/tmp/x", "[::1]:/tmp/", "x", queryListing},
41		// a slash before the colon is a local path, as the daemon reads it
42		{"a/b:c/", "a/b:c/", "", queryListing},
43	} {
44		got := parseQuery(c.q)
45		if got.kind != c.kind || got.dir != c.dir || got.segment != c.segment {
46			t.Errorf("parseQuery(%q) = %+v, want kind %d dir %q segment %q", c.q, got, c.kind, c.dir, c.segment)
47		}
48	}
49}
50
51func TestFolderRequestKeepsLocationsAndJoinsRelativePaths(t *testing.T) {
52	for _, c := range []struct{ workspace, typed, want string }{
53		{"/w", "chernobog:~", "chernobog:~"},
54		{"/w", "chernobog:~/proj/", "chernobog:~/proj"},
55		{"/w", "chernobog:/", "chernobog:/"},
56		{"/w", "chernobog:proj/", "chernobog:~/proj"},
57		{"/w", "~/x/", "~/x"},
58		{"/w", "src/", "/w/src"},
59		{"mayer@chernobog:/home/mayer/proj", "src/", "mayer@chernobog:/home/mayer/proj/src"},
60		{"mayer@chernobog:/home/mayer/proj", "../", "mayer@chernobog:/home/mayer"},
61	} {
62		if got := folderRequest(c.workspace, c.typed); got != c.want {
63			t.Errorf("folderRequest(%q, %q) = %q, want %q", c.workspace, c.typed, got, c.want)
64		}
65	}
66}
67
68func TestHostRowsCompleteOnlyWhatTheTextAsksFor(t *testing.T) {
69	known := []daemon.KnownHost{
70		{Host: "mayer@chernobog", Label: "chernobog", Source: "recent"},
71		{Host: "devbox", Label: "devbox", Source: "config"},
72	}
73	names := func(rows []folderRow) (out []string) {
74		for _, r := range rows {
75			out = append(out, r.path+"="+r.hostKey)
76		}
77		return out
78	}
79	for _, c := range []struct {
80		text string
81		kind queryKind
82		want string
83	}{
84		{"", queryRecent, ""},
85		{"cher", queryRecent, "chernobog:=mayer@chernobog"},
86		// a bare word is a prefix, never a fuzzy match, so local filtering
87		// does not fill with hosts
88		{"cb", queryRecent, ""},
89		{"@", queryHosts, "chernobog:=mayer@chernobog devbox:=devbox"},
90		{"@dev", queryHosts, "devbox:=devbox"},
91		{"root@cher", queryHosts, "root@chernobog:=root@chernobog"},
92	} {
93		if got := strings.Join(names(hostRows(c.kind, c.text, known)), " "); got != c.want {
94			t.Errorf("hostRows(%q) = %q, want %q", c.text, got, c.want)
95		}
96	}
97}
98
99// fakeFolders answers the picker the way the daemon does for one local
100// folder and one remote host.
101type fakeFolders struct {
102	lists  map[string]daemon.FolderList
103	hosts  []daemon.KnownHost
104	probe  daemon.HostStatus
105	warmed []string
106}
107
108func (f *fakeFolders) List(_ context.Context, p string) (daemon.FolderList, error) {
109	if l, ok := f.lists[p]; ok {
110		return l, nil
111	}
112	return daemon.FolderList{}, errors.New("no such folder")
113}
114func (f *fakeFolders) Repo(_ context.Context, _ string) (*daemon.Repo, error) { return nil, nil }
115func (f *fakeFolders) Preview(_ context.Context, p string) (daemon.FolderPreview, error) {
116	return daemon.FolderPreview{Path: p}, nil
117}
118func (f *fakeFolders) Sessions(_ context.Context) ([]daemon.Session, error) {
119	label := "chernobog"
120	return []daemon.Session{
121		{ID: "a", Workspace: "/Users/dawn/proj/albedo"},
122		{ID: "b", Workspace: "mayer@chernobog:/home/mayer/proj/albedo", Location: &daemon.Location{Label: &label}},
123	}, nil
124}
125func (f *fakeFolders) Move(_ context.Context, id, workspace string) (daemon.Session, error) {
126	return daemon.Session{ID: id, Workspace: workspace}, nil
127}
128func (f *fakeFolders) Hosts(_ context.Context) ([]daemon.KnownHost, error) { return f.hosts, nil }
129func (f *fakeFolders) Host(_ context.Context, _ string) (daemon.HostStatus, error) {
130	return f.probe, nil
131}
132func (f *fakeFolders) Warm(_ context.Context, h string) (daemon.HostStatus, error) {
133	f.warmed = append(f.warmed, h)
134	return f.probe, nil
135}
136func (f *fakeFolders) Local() bool { return true }
137
138// settle runs the picker's commands until it waits on nothing but timers,
139// which it drops: previews and polls are not what these tests are about.
140func settle(t *testing.T, m FolderPicker, cmd tea.Cmd) FolderPicker {
141	t.Helper()
142	return drainPicker(m, cmd)
143}
144
145// drainPicker is settle for the gallery, which has no test to report to.
146func drainPicker(m FolderPicker, cmd tea.Cmd) FolderPicker {
147	queue := []tea.Cmd{cmd}
148	for len(queue) > 0 {
149		next := queue[0]
150		queue = queue[1:]
151		if next == nil {
152			continue
153		}
154		done := make(chan tea.Msg, 1)
155		go func() { done <- next() }()
156		var msg tea.Msg
157		select {
158		case msg = <-done:
159		case <-time.After(100 * time.Millisecond):
160			continue // a tick
161		}
162		if batch, ok := msg.(tea.BatchMsg); ok {
163			queue = append(queue, batch...)
164			continue
165		}
166		if msg == nil {
167			continue
168		}
169		var more tea.Cmd
170		m, more = m.Update(msg)
171		queue = append(queue, more)
172	}
173	return m
174}
175
176func typeQuery(t *testing.T, m FolderPicker, text string) FolderPicker {
177	t.Helper()
178	return typeText(m, text)
179}
180
181// typeText types text one key at a time, settling after each.
182func typeText(m FolderPicker, text string) FolderPicker {
183	for _, r := range text {
184		var cmd tea.Cmd
185		m, cmd = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
186		m = drainPicker(m, cmd)
187	}
188	return m
189}
190
191func remoteFolders(state, detail string) *fakeFolders {
192	return &fakeFolders{
193		lists: map[string]daemon.FolderList{
194			"chernobog:~":      {Path: "mayer@chernobog:/home/mayer", Home: "mayer@chernobog:/home/mayer", Entries: []daemon.FolderEntry{{Name: "proj"}, {Name: "notes"}}},
195			"chernobog:~/proj": {Path: "mayer@chernobog:/home/mayer/proj", Home: "mayer@chernobog:/home/mayer", Entries: []daemon.FolderEntry{{Name: "albedo", VCS: "jj"}}},
196		},
197		hosts: []daemon.KnownHost{{Host: "mayer@chernobog", Label: "chernobog", Source: "recent"}, {Host: "devbox", Label: "devbox", Source: "config"}},
198		probe: daemon.HostStatus{Host: "mayer@chernobog", State: state, Detail: detail, Home: "/home/mayer", OS: "Linux", Arch: "aarch64"},
199	}
200}
201
202func pickerFrame(m FolderPicker) string {
203	return ansi.Strip(m.View())
204}
205
206func TestPickerBrowsesAHostFoldingUnderItsHome(t *testing.T) {
207	src := remoteFolders("ready", "")
208	m := NewFolderPicker(src, daemon.Session{ID: "a", Workspace: "/Users/dawn/proj/albedo"}, nil)
209	m.SetSize(100, 16)
210	m = settle(t, m, m.Init())
211
212	m = typeQuery(t, m, "chernobog:")
213	frame := pickerFrame(m)
214	if !strings.Contains(frame, "in chernobog:~") || !strings.Contains(frame, "proj") || !strings.Contains(frame, "notes") {
215		t.Fatalf("chernobog: did not list the host's home:\n%s", frame)
216	}
217	// tab enters the highlighted folder in the host's own ~
218	m = settle(t, m, func() tea.Cmd { var c tea.Cmd; m, c = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}); return c }())
219	var cmd tea.Cmd
220	m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
221	m = settle(t, m, cmd)
222	m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyTab})
223	m = settle(t, m, cmd)
224	// under the host's home, scp-style: relative to it
225	if got := m.input.Value(); got != "chernobog:proj/" {
226		t.Fatalf("tab went to %q", got)
227	}
228	if frame := pickerFrame(m); !strings.Contains(frame, "in chernobog:proj") || !strings.Contains(frame, "albedo") {
229		t.Fatalf("the host's folder did not list:\n%s", frame)
230	}
231	// shift+tab goes back up through the canonical path, still folded
232	m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
233	m = settle(t, m, cmd)
234	if got := m.input.Value(); got != "chernobog:~/" {
235		t.Fatalf("shift+tab went to %q", got)
236	}
237	// enter moves the session to the canonical location
238	m = typeQuery(t, m, "pro")
239	m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
240	moved, ok := cmd().(FolderMovedMsg)
241	if !ok || moved.Workspace != "mayer@chernobog:/home/mayer/proj" {
242		t.Fatalf("enter moved to %#v", moved)
243	}
244	// back among the recent folders, the remote one reads like the preview's rule
245	m.setQuery("")
246	m.cursor = slices.IndexFunc(m.rows, func(r folderRow) bool { return r.hostKey != "" })
247	if row := m.row(m.rows[m.cursor], false, false, false, 60, time.Now()); !strings.Contains(ansi.Strip(row), "chernobog:proj") {
248		t.Fatalf("a recent remote folder's where should read host:path under its home: %q", ansi.Strip(row))
249	}
250	if len(src.warmed) != 1 {
251		t.Fatalf("the host was warmed %d times, want once: %v", len(src.warmed), src.warmed)
252	}
253}
254
255func TestPickerCompletesHostsAndRefusesADeadOne(t *testing.T) {
256	src := remoteFolders("unsupported", "needs python >= 3.11")
257	m := NewFolderBrowser(src, "/Users/dawn/proj/albedo", "")
258	m.SetSize(140, 16)
259	m = settle(t, m, m.Init())
260
261	m = typeQuery(t, m, "@")
262	frame := pickerFrame(m)
263	if !strings.Contains(frame, "chernobog") || !strings.Contains(frame, "devbox") || !strings.Contains(frame, "ssh config") {
264		t.Fatalf("@ did not list the known hosts:\n%s", frame)
265	}
266	// highlighting the host warmed it, and the row says why it is no good
267	if !strings.Contains(frame, "needs python >= 3.11") {
268		t.Fatalf("the dead host's row does not say why:\n%s", frame)
269	}
270
271	// the recent remote folder is the same host: enter refuses it
272	m = NewFolderBrowser(src, "/Users/dawn/proj/albedo", "")
273	m.SetSize(100, 16)
274	m = settle(t, m, m.Init())
275	for range 3 {
276		if row, _ := m.highlighted(); row.hostKey != "" {
277			break
278		}
279		var cmd tea.Cmd
280		m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
281		m = settle(t, m, cmd)
282	}
283	var cmd tea.Cmd
284	m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
285	if cmd != nil {
286		if _, started := cmd().(FolderNewSessionMsg); started {
287			t.Fatalf("enter started a session on a dead host")
288		}
289	}
290	if !strings.Contains(m.notice, "needs python >= 3.11") {
291		t.Fatalf("enter did not say why: %q", m.notice)
292	}
293}
294
295func TestConnectingFaceMovesUntilTheHostSettles(t *testing.T) {
296	m := NewFolderBrowser(remoteFolders("warming", ""), "/Users/dawn/proj/albedo", "")
297	m.SetSize(140, 16)
298	m = settle(t, m, m.Init())
299	m = typeQuery(t, m, "cher")
300	shows := func(frame int) bool {
301		return strings.Contains(pickerFrame(m), "connecting to chernobog… "+connectingFace("chernobog", frame))
302	}
303	if !shows(m.frame) {
304		t.Fatalf("a warming host shows no face:\n%s", pickerFrame(m))
305	}
306	before := m.frame
307	if m, _ = m.Update(connectTickMsg{Gen: m.sessionsGeneration + 1}); m.frame != before {
308		t.Fatal("a closed picker's tick moved the face")
309	}
310	var cmd tea.Cmd
311	if m, cmd = m.Update(connectTickMsg{Gen: m.sessionsGeneration}); m.frame != before+1 || cmd == nil || !shows(m.frame) {
312		t.Fatalf("the tick did not move the face on (frame %d, next tick %v)", m.frame, cmd != nil)
313	}
314	m, _ = m.Update(hostStatusMsg{Gen: m.sessionsGeneration, Host: "mayer@chernobog", Status: daemon.HostStatus{Host: "mayer@chernobog", State: "ready"}, Warmed: true})
315	if _, cmd = m.Update(connectTickMsg{Gen: m.sessionsGeneration}); cmd != nil {
316		t.Fatal("the face kept ticking after the host settled")
317	}
318}
319
320func TestPickerSaysWhenItCopiesTheKernelOver(t *testing.T) {
321	src := remoteFolders("warming", "")
322	src.probe.Step = "staging"
323	m := NewFolderBrowser(src, "/Users/dawn/proj/albedo", "")
324	m.SetSize(140, 16)
325	m = settle(t, m, m.Init())
326	m = typeQuery(t, m, "cher")
327	m, _ = m.Update(hostStatusMsg{Gen: m.sessionsGeneration, Host: "mayer@chernobog", Status: src.probe})
328	if frame := pickerFrame(m); !strings.Contains(frame, "copying the kernel to chernobog… ") {
329		t.Fatalf("a probe staging the bundle does not say so:\n%s", frame)
330	}
331}
332
333func TestPickerOffersSignInForAHostThatNeedsAPerson(t *testing.T) {
334	src := remoteFolders("needs_auth", "Permission denied (publickey)")
335	m := NewFolderBrowser(src, "/Users/dawn/proj/albedo", "")
336	m.SetSize(100, 16)
337	m = settle(t, m, m.Init())
338	m = typeQuery(t, m, "cher")
339	frame := pickerFrame(m)
340	if !strings.Contains(frame, "sign in · ctrl+l") {
341		t.Fatalf("the host row does not offer to sign in:\n%s", frame)
342	}
343	// without the daemon's control path there is nothing to open here
344	var cmd tea.Cmd
345	m, cmd = m.Update(tea.KeyPressMsg{Code: 'l', Mod: tea.ModCtrl})
346	if cmd != nil || !strings.Contains(m.notice, "ssh mayer@chernobog") {
347		t.Fatalf("ctrl+l without a control path: cmd %v, notice %q", cmd != nil, m.notice)
348	}
349}
350
351func TestFolderFooterKeepsRetryWhenSessionsFail(t *testing.T) {
352	m := NewFolderBrowser(remoteFolders("ready", ""), "/Users/dawn/proj/albedo", "")
353	m.SetSize(40, 16)
354	m.sessionsError = "boom"
355	footer := ansi.Strip(m.footer(40))
356	if !strings.Contains(footer, "ctrl+r") || strings.Contains(footer, "move") {
357		t.Fatalf("narrow footer should keep only the retry: %q", footer)
358	}
359}
360
361func TestFolderPickerCtrlDGoesBack(t *testing.T) {
362	m := NewFolderBrowser(remoteFolders("ready", ""), "/Users/dawn/proj/albedo", "")
363	m.SetSize(100, 16)
364	_, cmd := m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})
365	if cmd == nil {
366		t.Fatal("ctrl+d did nothing")
367	}
368	if _, ok := cmd().(FolderPickerCancelMsg); !ok {
369		t.Fatal("ctrl+d did not go back like esc")
370	}
371}