okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
10e0f66
main
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 ) { 22for _ ,c := range []struct { 23q ,dir ,segment string 24kind 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 } { 44got := parseQuery (c .q ) 45if got .kind != c .kind || got .dir != c .dir || got .segment != c .segment { 46t .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 ) { 52for _ ,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 } { 62if got := folderRequest (c .workspace ,c .typed );got != c .want { 63t .Errorf ("folderRequest(%q, %q) = %q, want %q" ,c .workspace ,c .typed ,got ,c .want ) 64 } 65 } 66} 67 68func TestHostRowsCompleteOnlyWhatTheTextAsksFor (t * testing.T ) { 69known := []daemon.KnownHost { 70 {Host :"mayer@chernobog" ,Label :"chernobog" ,Source :"recent" }, 71 {Host :"devbox" ,Label :"devbox" ,Source :"config" }, 72 } 73names := func (rows []folderRow ) (out []string ) { 74for _ ,r := range rows { 75out = append (out ,r .path + "=" + r .hostKey ) 76 } 77return out 78 } 79for _ ,c := range []struct { 80text string 81kind queryKind 82want 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 } { 93if got := strings .Join (names (hostRows (c .kind ,c .text ,known ))," " );got != c .want { 94t .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 { 102lists map [string ]daemon.FolderList 103hosts []daemon.KnownHost 104probe daemon.HostStatus 105warmed []string 106} 107 108func (f * fakeFolders )List (_ context.Context ,p string ) (daemon.FolderList ,error ) { 109if l ,ok := f .lists [p ];ok { 110return l ,nil 111 } 112return 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 ) { 116return daemon.FolderPreview {Path :p },nil 117} 118func (f * fakeFolders )Sessions (_ context.Context ) ([]daemon.Session ,error ) { 119label := "chernobog" 120return []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 ) { 126return 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 ) { 130return f .probe ,nil 131} 132func (f * fakeFolders )Warm (_ context.Context ,h string ) (daemon.HostStatus ,error ) { 133f .warmed = append (f .warmed ,h ) 134return 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 { 141t .Helper () 142return 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 { 147queue := []tea.Cmd {cmd } 148for len (queue )> 0 { 149next := queue [0 ] 150queue = queue [1 :] 151if next == nil { 152continue 153 } 154done := make (chan tea.Msg ,1 ) 155go func () {done <- next () }() 156var msg tea.Msg 157select { 158case msg = <- done : 159case <- time .After (100 * time .Millisecond ): 160continue // a tick 161 } 162if batch ,ok := msg .(tea.BatchMsg );ok { 163queue = append (queue ,batch ... ) 164continue 165 } 166if msg == nil { 167continue 168 } 169var more tea.Cmd 170m ,more = m .Update (msg ) 171queue = append (queue ,more ) 172 } 173return m 174} 175 176func typeQuery (t * testing.T ,m FolderPicker ,text string )FolderPicker { 177t .Helper () 178return typeText (m ,text ) 179} 180 181// typeText types text one key at a time, settling after each. 182func typeText (m FolderPicker ,text string )FolderPicker { 183for _ ,r := range text { 184var cmd tea.Cmd 185m ,cmd = m .Update (tea.KeyPressMsg {Code :r ,Text :string (r )}) 186m = drainPicker (m ,cmd ) 187 } 188return m 189} 190 191func remoteFolders (state ,detail string )* fakeFolders { 192return & fakeFolders { 193lists :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 }, 197hosts : []daemon.KnownHost {{Host :"mayer@chernobog" ,Label :"chernobog" ,Source :"recent" }, {Host :"devbox" ,Label :"devbox" ,Source :"config" }}, 198probe : daemon.HostStatus {Host :"mayer@chernobog" ,State :state ,Detail :detail ,Home :"/home/mayer" ,OS :"Linux" ,Arch :"aarch64" }, 199 } 200} 201 202func pickerFrame (m FolderPicker )string { 203return ansi .Strip (m .View ()) 204} 205 206func TestPickerBrowsesAHostFoldingUnderItsHome (t * testing.T ) { 207src := remoteFolders ("ready" ,"" ) 208m := NewFolderPicker (src , daemon.Session {ID :"a" ,Workspace :"/Users/dawn/proj/albedo" },nil ) 209m .SetSize (100 ,16 ) 210m = settle (t ,m ,m .Init ()) 211 212m = typeQuery (t ,m ,"chernobog:" ) 213frame := pickerFrame (m ) 214if ! strings .Contains (frame ,"in chernobog:~" )|| ! strings .Contains (frame ,"proj" )|| ! strings .Contains (frame ,"notes" ) { 215t .Fatalf ("chernobog: did not list the host's home:\n%s" ,frame ) 216 } 217// tab enters the highlighted folder in the host's own ~ 218m = settle (t ,m ,func () tea.Cmd {var c tea.Cmd ;m ,c = m .Update (tea.KeyPressMsg {Code :tea .KeyDown });return c }()) 219var cmd tea.Cmd 220m ,cmd = m .Update (tea.KeyPressMsg {Code :tea .KeyUp }) 221m = settle (t ,m ,cmd ) 222m ,cmd = m .Update (tea.KeyPressMsg {Code :tea .KeyTab }) 223m = settle (t ,m ,cmd ) 224// under the host's home, scp-style: relative to it 225if got := m .input .Value ();got != "chernobog:proj/" { 226t .Fatalf ("tab went to %q" ,got ) 227 } 228if frame := pickerFrame (m );! strings .Contains (frame ,"in chernobog:proj" )|| ! strings .Contains (frame ,"albedo" ) { 229t .Fatalf ("the host's folder did not list:\n%s" ,frame ) 230 } 231// shift+tab goes back up through the canonical path, still folded 232m ,cmd = m .Update (tea.KeyPressMsg {Code :tea .KeyTab ,Mod :tea .ModShift }) 233m = settle (t ,m ,cmd ) 234if got := m .input .Value ();got != "chernobog:~/" { 235t .Fatalf ("shift+tab went to %q" ,got ) 236 } 237// enter moves the session to the canonical location 238m = typeQuery (t ,m ,"pro" ) 239m ,cmd = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 240moved ,ok := cmd ().(FolderMovedMsg ) 241if ! ok || moved .Workspace != "mayer@chernobog:/home/mayer/proj" { 242t .Fatalf ("enter moved to %#v" ,moved ) 243 } 244// back among the recent folders, the remote one reads like the preview's rule 245m .setQuery ("" ) 246m .cursor = slices .IndexFunc (m .rows ,func (r folderRow )bool {return r .hostKey != "" }) 247if row := m .row (m .rows [m .cursor ],false ,false ,false ,60 ,time .Now ());! strings .Contains (ansi .Strip (row ),"chernobog:proj" ) { 248t .Fatalf ("a recent remote folder's where should read host:path under its home: %q" ,ansi .Strip (row )) 249 } 250if len (src .warmed )!= 1 { 251t .Fatalf ("the host was warmed %d times, want once: %v" ,len (src .warmed ),src .warmed ) 252 } 253} 254 255func TestPickerCompletesHostsAndRefusesADeadOne (t * testing.T ) { 256src := remoteFolders ("unsupported" ,"needs python >= 3.11" ) 257m := NewFolderBrowser (src ,"/Users/dawn/proj/albedo" ,"" ) 258m .SetSize (140 ,16 ) 259m = settle (t ,m ,m .Init ()) 260 261m = typeQuery (t ,m ,"@" ) 262frame := pickerFrame (m ) 263if ! strings .Contains (frame ,"chernobog" )|| ! strings .Contains (frame ,"devbox" )|| ! strings .Contains (frame ,"ssh config" ) { 264t .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 267if ! strings .Contains (frame ,"needs python >= 3.11" ) { 268t .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 272m = NewFolderBrowser (src ,"/Users/dawn/proj/albedo" ,"" ) 273m .SetSize (100 ,16 ) 274m = settle (t ,m ,m .Init ()) 275for range 3 { 276if row ,_ := m .highlighted ();row .hostKey != "" { 277break 278 } 279var cmd tea.Cmd 280m ,cmd = m .Update (tea.KeyPressMsg {Code :tea .KeyDown }) 281m = settle (t ,m ,cmd ) 282 } 283var cmd tea.Cmd 284m ,cmd = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 285if cmd != nil { 286if _ ,started := cmd ().(FolderNewSessionMsg );started { 287t .Fatalf ("enter started a session on a dead host" ) 288 } 289 } 290if ! strings .Contains (m .notice ,"needs python >= 3.11" ) { 291t .Fatalf ("enter did not say why: %q" ,m .notice ) 292 } 293} 294 295func TestConnectingFaceMovesUntilTheHostSettles (t * testing.T ) { 296m := NewFolderBrowser (remoteFolders ("warming" ,"" ),"/Users/dawn/proj/albedo" ,"" ) 297m .SetSize (140 ,16 ) 298m = settle (t ,m ,m .Init ()) 299m = typeQuery (t ,m ,"cher" ) 300shows := func (frame int )bool { 301return strings .Contains (pickerFrame (m ),"connecting to chernobog… " + connectingFace ("chernobog" ,frame )) 302 } 303if ! shows (m .frame ) { 304t .Fatalf ("a warming host shows no face:\n%s" ,pickerFrame (m )) 305 } 306before := m .frame 307if m ,_ = m .Update (connectTickMsg {Gen :m .sessionsGeneration + 1 });m .frame != before { 308t .Fatal ("a closed picker's tick moved the face" ) 309 } 310var cmd tea.Cmd 311if m ,cmd = m .Update (connectTickMsg {Gen :m .sessionsGeneration });m .frame != before + 1 || cmd == nil || ! shows (m .frame ) { 312t .Fatalf ("the tick did not move the face on (frame %d, next tick %v)" ,m .frame ,cmd != nil ) 313 } 314m ,_ = m .Update (hostStatusMsg {Gen :m .sessionsGeneration ,Host :"mayer@chernobog" ,Status : daemon.HostStatus {Host :"mayer@chernobog" ,State :"ready" },Warmed :true }) 315if _ ,cmd = m .Update (connectTickMsg {Gen :m .sessionsGeneration });cmd != nil { 316t .Fatal ("the face kept ticking after the host settled" ) 317 } 318} 319 320func TestPickerSaysWhenItCopiesTheKernelOver (t * testing.T ) { 321src := remoteFolders ("warming" ,"" ) 322src .probe .Step = "staging" 323m := NewFolderBrowser (src ,"/Users/dawn/proj/albedo" ,"" ) 324m .SetSize (140 ,16 ) 325m = settle (t ,m ,m .Init ()) 326m = typeQuery (t ,m ,"cher" ) 327m ,_ = m .Update (hostStatusMsg {Gen :m .sessionsGeneration ,Host :"mayer@chernobog" ,Status :src .probe }) 328if frame := pickerFrame (m );! strings .Contains (frame ,"copying the kernel to chernobog… " ) { 329t .Fatalf ("a probe staging the bundle does not say so:\n%s" ,frame ) 330 } 331} 332 333func TestPickerOffersSignInForAHostThatNeedsAPerson (t * testing.T ) { 334src := remoteFolders ("needs_auth" ,"Permission denied (publickey)" ) 335m := NewFolderBrowser (src ,"/Users/dawn/proj/albedo" ,"" ) 336m .SetSize (100 ,16 ) 337m = settle (t ,m ,m .Init ()) 338m = typeQuery (t ,m ,"cher" ) 339frame := pickerFrame (m ) 340if ! strings .Contains (frame ,"sign in · ctrl+l" ) { 341t .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 344var cmd tea.Cmd 345m ,cmd = m .Update (tea.KeyPressMsg {Code :'l' ,Mod :tea .ModCtrl }) 346if cmd != nil || ! strings .Contains (m .notice ,"ssh mayer@chernobog" ) { 347t .Fatalf ("ctrl+l without a control path: cmd %v, notice %q" ,cmd != nil ,m .notice ) 348 } 349} 350 351func TestFolderFooterKeepsRetryWhenSessionsFail (t * testing.T ) { 352m := NewFolderBrowser (remoteFolders ("ready" ,"" ),"/Users/dawn/proj/albedo" ,"" ) 353m .SetSize (40 ,16 ) 354m .sessionsError = "boom" 355footer := ansi .Strip (m .footer (40 )) 356if ! strings .Contains (footer ,"ctrl+r" )|| strings .Contains (footer ,"move" ) { 357t .Fatalf ("narrow footer should keep only the retry: %q" ,footer ) 358 } 359} 360 361func TestFolderPickerCtrlDGoesBack (t * testing.T ) { 362m := NewFolderBrowser (remoteFolders ("ready" ,"" ),"/Users/dawn/proj/albedo" ,"" ) 363m .SetSize (100 ,16 ) 364_ ,cmd := m .Update (tea.KeyPressMsg {Code :'d' ,Mod :tea .ModCtrl }) 365if cmd == nil { 366t .Fatal ("ctrl+d did nothing" ) 367 } 368if _ ,ok := cmd ().(FolderPickerCancelMsg );! ok { 369t .Fatal ("ctrl+d did not go back like esc" ) 370 } 371}