// The folder picker's query grammar and host states are bug-prone logic // the e2e suite cannot reach without a real remote host: which text lists a // host's folders, which completes a host, how remote paths fold under that // host's home, and that a dead host is never picked. package tui import ( "context" "errors" "slices" "strings" "testing" "time" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" "albedo/cli/internal/daemon" ) func TestParseQuery(t *testing.T) { for _, c := range []struct { q, dir, segment string kind queryKind }{ {"", "", "", queryRecent}, {"albedo", "", "albedo", queryRecent}, {"~", "~", "", queryListing}, {"~/pro", "~/", "pro", queryListing}, {"/srv/", "/srv/", "", queryListing}, {"src/", "src/", "", queryListing}, {"mayer@cher", "", "mayer@cher", queryHosts}, {"@cher", "", "@cher", queryHosts}, {"chernobog:", "chernobog:~", "", queryListing}, {"chernobog:~", "chernobog:~", "", queryListing}, {"chernobog:pro", "chernobog:~", "pro", queryListing}, {"chernobog:~/proj/", "chernobog:~/proj/", "", queryListing}, {"chernobog:~/proj/al", "chernobog:~/proj/", "al", queryListing}, {"mayer@chernobog:/srv/", "mayer@chernobog:/srv/", "", queryListing}, {"[::1]:/tmp/x", "[::1]:/tmp/", "x", queryListing}, // a slash before the colon is a local path, as the daemon reads it {"a/b:c/", "a/b:c/", "", queryListing}, } { got := parseQuery(c.q) if got.kind != c.kind || got.dir != c.dir || got.segment != c.segment { t.Errorf("parseQuery(%q) = %+v, want kind %d dir %q segment %q", c.q, got, c.kind, c.dir, c.segment) } } } func TestFolderRequestKeepsLocationsAndJoinsRelativePaths(t *testing.T) { for _, c := range []struct{ workspace, typed, want string }{ {"/w", "chernobog:~", "chernobog:~"}, {"/w", "chernobog:~/proj/", "chernobog:~/proj"}, {"/w", "chernobog:/", "chernobog:/"}, {"/w", "chernobog:proj/", "chernobog:~/proj"}, {"/w", "~/x/", "~/x"}, {"/w", "src/", "/w/src"}, {"mayer@chernobog:/home/mayer/proj", "src/", "mayer@chernobog:/home/mayer/proj/src"}, {"mayer@chernobog:/home/mayer/proj", "../", "mayer@chernobog:/home/mayer"}, } { if got := folderRequest(c.workspace, c.typed); got != c.want { t.Errorf("folderRequest(%q, %q) = %q, want %q", c.workspace, c.typed, got, c.want) } } } func TestHostRowsCompleteOnlyWhatTheTextAsksFor(t *testing.T) { known := []daemon.KnownHost{ {Host: "mayer@chernobog", Label: "chernobog", Source: "recent"}, {Host: "devbox", Label: "devbox", Source: "config"}, } names := func(rows []folderRow) (out []string) { for _, r := range rows { out = append(out, r.path+"="+r.hostKey) } return out } for _, c := range []struct { text string kind queryKind want string }{ {"", queryRecent, ""}, {"cher", queryRecent, "chernobog:=mayer@chernobog"}, // a bare word is a prefix, never a fuzzy match, so local filtering // does not fill with hosts {"cb", queryRecent, ""}, {"@", queryHosts, "chernobog:=mayer@chernobog devbox:=devbox"}, {"@dev", queryHosts, "devbox:=devbox"}, {"root@cher", queryHosts, "root@chernobog:=root@chernobog"}, } { if got := strings.Join(names(hostRows(c.kind, c.text, known)), " "); got != c.want { t.Errorf("hostRows(%q) = %q, want %q", c.text, got, c.want) } } } // fakeFolders answers the picker the way the daemon does for one local // folder and one remote host. type fakeFolders struct { lists map[string]daemon.FolderList hosts []daemon.KnownHost probe daemon.HostStatus warmed []string } func (f *fakeFolders) List(_ context.Context, p string) (daemon.FolderList, error) { if l, ok := f.lists[p]; ok { return l, nil } return daemon.FolderList{}, errors.New("no such folder") } func (f *fakeFolders) Repo(_ context.Context, _ string) (*daemon.Repo, error) { return nil, nil } func (f *fakeFolders) Preview(_ context.Context, p string) (daemon.FolderPreview, error) { return daemon.FolderPreview{Path: p}, nil } func (f *fakeFolders) Sessions(_ context.Context) ([]daemon.Session, error) { label := "chernobog" return []daemon.Session{ {ID: "a", Workspace: "/Users/dawn/proj/albedo"}, {ID: "b", Workspace: "mayer@chernobog:/home/mayer/proj/albedo", Location: &daemon.Location{Label: &label}}, }, nil } func (f *fakeFolders) Move(_ context.Context, id, workspace string) (daemon.Session, error) { return daemon.Session{ID: id, Workspace: workspace}, nil } func (f *fakeFolders) Hosts(_ context.Context) ([]daemon.KnownHost, error) { return f.hosts, nil } func (f *fakeFolders) Host(_ context.Context, _ string) (daemon.HostStatus, error) { return f.probe, nil } func (f *fakeFolders) Warm(_ context.Context, h string) (daemon.HostStatus, error) { f.warmed = append(f.warmed, h) return f.probe, nil } func (f *fakeFolders) Local() bool { return true } // settle runs the picker's commands until it waits on nothing but timers, // which it drops: previews and polls are not what these tests are about. func settle(t *testing.T, m FolderPicker, cmd tea.Cmd) FolderPicker { t.Helper() return drainPicker(m, cmd) } // drainPicker is settle for the gallery, which has no test to report to. func drainPicker(m FolderPicker, cmd tea.Cmd) FolderPicker { queue := []tea.Cmd{cmd} for len(queue) > 0 { next := queue[0] queue = queue[1:] if next == nil { continue } done := make(chan tea.Msg, 1) go func() { done <- next() }() var msg tea.Msg select { case msg = <-done: case <-time.After(100 * time.Millisecond): continue // a tick } if batch, ok := msg.(tea.BatchMsg); ok { queue = append(queue, batch...) continue } if msg == nil { continue } var more tea.Cmd m, more = m.Update(msg) queue = append(queue, more) } return m } func typeQuery(t *testing.T, m FolderPicker, text string) FolderPicker { t.Helper() return typeText(m, text) } // typeText types text one key at a time, settling after each. func typeText(m FolderPicker, text string) FolderPicker { for _, r := range text { var cmd tea.Cmd m, cmd = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) m = drainPicker(m, cmd) } return m } func remoteFolders(state, detail string) *fakeFolders { return &fakeFolders{ lists: map[string]daemon.FolderList{ "chernobog:~": {Path: "mayer@chernobog:/home/mayer", Home: "mayer@chernobog:/home/mayer", Entries: []daemon.FolderEntry{{Name: "proj"}, {Name: "notes"}}}, "chernobog:~/proj": {Path: "mayer@chernobog:/home/mayer/proj", Home: "mayer@chernobog:/home/mayer", Entries: []daemon.FolderEntry{{Name: "albedo", VCS: "jj"}}}, }, hosts: []daemon.KnownHost{{Host: "mayer@chernobog", Label: "chernobog", Source: "recent"}, {Host: "devbox", Label: "devbox", Source: "config"}}, probe: daemon.HostStatus{Host: "mayer@chernobog", State: state, Detail: detail, Home: "/home/mayer", OS: "Linux", Arch: "aarch64"}, } } func pickerFrame(m FolderPicker) string { return ansi.Strip(m.View()) } func TestPickerBrowsesAHostFoldingUnderItsHome(t *testing.T) { src := remoteFolders("ready", "") m := NewFolderPicker(src, daemon.Session{ID: "a", Workspace: "/Users/dawn/proj/albedo"}, nil) m.SetSize(100, 16) m = settle(t, m, m.Init()) m = typeQuery(t, m, "chernobog:") frame := pickerFrame(m) if !strings.Contains(frame, "in chernobog:~") || !strings.Contains(frame, "proj") || !strings.Contains(frame, "notes") { t.Fatalf("chernobog: did not list the host's home:\n%s", frame) } // tab enters the highlighted folder in the host's own ~ m = settle(t, m, func() tea.Cmd { var c tea.Cmd; m, c = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}); return c }()) var cmd tea.Cmd m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) m = settle(t, m, cmd) m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) m = settle(t, m, cmd) // under the host's home, scp-style: relative to it if got := m.input.Value(); got != "chernobog:proj/" { t.Fatalf("tab went to %q", got) } if frame := pickerFrame(m); !strings.Contains(frame, "in chernobog:proj") || !strings.Contains(frame, "albedo") { t.Fatalf("the host's folder did not list:\n%s", frame) } // shift+tab goes back up through the canonical path, still folded m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) m = settle(t, m, cmd) if got := m.input.Value(); got != "chernobog:~/" { t.Fatalf("shift+tab went to %q", got) } // enter moves the session to the canonical location m = typeQuery(t, m, "pro") m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) moved, ok := cmd().(FolderMovedMsg) if !ok || moved.Workspace != "mayer@chernobog:/home/mayer/proj" { t.Fatalf("enter moved to %#v", moved) } // back among the recent folders, the remote one reads like the preview's rule m.setQuery("") m.cursor = slices.IndexFunc(m.rows, func(r folderRow) bool { return r.hostKey != "" }) if row := m.row(m.rows[m.cursor], false, false, false, 60, time.Now()); !strings.Contains(ansi.Strip(row), "chernobog:proj") { t.Fatalf("a recent remote folder's where should read host:path under its home: %q", ansi.Strip(row)) } if len(src.warmed) != 1 { t.Fatalf("the host was warmed %d times, want once: %v", len(src.warmed), src.warmed) } } func TestPickerCompletesHostsAndRefusesADeadOne(t *testing.T) { src := remoteFolders("unsupported", "needs python >= 3.11") m := NewFolderBrowser(src, "/Users/dawn/proj/albedo", "") m.SetSize(140, 16) m = settle(t, m, m.Init()) m = typeQuery(t, m, "@") frame := pickerFrame(m) if !strings.Contains(frame, "chernobog") || !strings.Contains(frame, "devbox") || !strings.Contains(frame, "ssh config") { t.Fatalf("@ did not list the known hosts:\n%s", frame) } // highlighting the host warmed it, and the row says why it is no good if !strings.Contains(frame, "needs python >= 3.11") { t.Fatalf("the dead host's row does not say why:\n%s", frame) } // the recent remote folder is the same host: enter refuses it m = NewFolderBrowser(src, "/Users/dawn/proj/albedo", "") m.SetSize(100, 16) m = settle(t, m, m.Init()) for range 3 { if row, _ := m.highlighted(); row.hostKey != "" { break } var cmd tea.Cmd m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) m = settle(t, m, cmd) } var cmd tea.Cmd m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if cmd != nil { if _, started := cmd().(FolderNewSessionMsg); started { t.Fatalf("enter started a session on a dead host") } } if !strings.Contains(m.notice, "needs python >= 3.11") { t.Fatalf("enter did not say why: %q", m.notice) } } func TestConnectingFaceMovesUntilTheHostSettles(t *testing.T) { m := NewFolderBrowser(remoteFolders("warming", ""), "/Users/dawn/proj/albedo", "") m.SetSize(140, 16) m = settle(t, m, m.Init()) m = typeQuery(t, m, "cher") shows := func(frame int) bool { return strings.Contains(pickerFrame(m), "connecting to chernobog… "+connectingFace("chernobog", frame)) } if !shows(m.frame) { t.Fatalf("a warming host shows no face:\n%s", pickerFrame(m)) } before := m.frame if m, _ = m.Update(connectTickMsg{Gen: m.sessionsGeneration + 1}); m.frame != before { t.Fatal("a closed picker's tick moved the face") } var cmd tea.Cmd if m, cmd = m.Update(connectTickMsg{Gen: m.sessionsGeneration}); m.frame != before+1 || cmd == nil || !shows(m.frame) { t.Fatalf("the tick did not move the face on (frame %d, next tick %v)", m.frame, cmd != nil) } m, _ = m.Update(hostStatusMsg{Gen: m.sessionsGeneration, Host: "mayer@chernobog", Status: daemon.HostStatus{Host: "mayer@chernobog", State: "ready"}, Warmed: true}) if _, cmd = m.Update(connectTickMsg{Gen: m.sessionsGeneration}); cmd != nil { t.Fatal("the face kept ticking after the host settled") } } func TestPickerSaysWhenItCopiesTheKernelOver(t *testing.T) { src := remoteFolders("warming", "") src.probe.Step = "staging" m := NewFolderBrowser(src, "/Users/dawn/proj/albedo", "") m.SetSize(140, 16) m = settle(t, m, m.Init()) m = typeQuery(t, m, "cher") m, _ = m.Update(hostStatusMsg{Gen: m.sessionsGeneration, Host: "mayer@chernobog", Status: src.probe}) if frame := pickerFrame(m); !strings.Contains(frame, "copying the kernel to chernobog… ") { t.Fatalf("a probe staging the bundle does not say so:\n%s", frame) } } func TestPickerOffersSignInForAHostThatNeedsAPerson(t *testing.T) { src := remoteFolders("needs_auth", "Permission denied (publickey)") m := NewFolderBrowser(src, "/Users/dawn/proj/albedo", "") m.SetSize(100, 16) m = settle(t, m, m.Init()) m = typeQuery(t, m, "cher") frame := pickerFrame(m) if !strings.Contains(frame, "sign in · ctrl+l") { t.Fatalf("the host row does not offer to sign in:\n%s", frame) } // without the daemon's control path there is nothing to open here var cmd tea.Cmd m, cmd = m.Update(tea.KeyPressMsg{Code: 'l', Mod: tea.ModCtrl}) if cmd != nil || !strings.Contains(m.notice, "ssh mayer@chernobog") { t.Fatalf("ctrl+l without a control path: cmd %v, notice %q", cmd != nil, m.notice) } } func TestFolderFooterKeepsRetryWhenSessionsFail(t *testing.T) { m := NewFolderBrowser(remoteFolders("ready", ""), "/Users/dawn/proj/albedo", "") m.SetSize(40, 16) m.sessionsError = "boom" footer := ansi.Strip(m.footer(40)) if !strings.Contains(footer, "ctrl+r") || strings.Contains(footer, "move") { t.Fatalf("narrow footer should keep only the retry: %q", footer) } } func TestFolderPickerCtrlDGoesBack(t *testing.T) { m := NewFolderBrowser(remoteFolders("ready", ""), "/Users/dawn/proj/albedo", "") m.SetSize(100, 16) _, cmd := m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}) if cmd == nil { t.Fatal("ctrl+d did nothing") } if _, ok := cmd().(FolderPickerCancelMsg); !ok { t.Fatal("ctrl+d did not go back like esc") } }