okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
597cb84
main
1//go:build unix 2 3// /cd crosses the composer, the folder picker, the daemon's workspace routes and 4// its workspace move. A fake folder source cannot show the daemon listing a 5// real directory, resolving a relative path, or keeping the new workspace. 6package e2e 7 8import ( 9"context" 10"errors" 11"fmt" 12"os" 13"path/filepath" 14"strings" 15"testing" 16"time" 17 18"albedo/cli/internal/daemon" 19"albedo/cli/internal/tui" 20 21 tea"charm.land/bubbletea/v2" 22) 23 24func TestTUICdMovesTheSessionThroughThePicker (t * testing.T ) { 25providerRoute (t ,echoReply ) 26t .Parallel () 27d := newTUIDriver (t ) 28root := t .TempDir () 29for _ ,dir := range []string {"picked/src" ,"other" ,".hidden" } { 30if err := os .MkdirAll (filepath .Join (root ,dir ),0o755 );err != nil { 31t .Fatal (err ) 32 } 33 } 34d .Update (tea.WindowSizeMsg {Width :120 ,Height :30 }) 35same := func (got ,want string )bool { 36g ,_ := filepath .EvalSymlinks (got ) 37w ,_ := filepath .EvalSymlinks (want ) 38return g == w 39 } 40 41d .App .Chat .TextArea .SetValue ("/cd" ) 42d .Dispatch (d .Key (tea .KeyEnter )) 43if d .App .State != tui .AppStateFolderPicker { 44t .Fatalf ("/cd did not open the picker:\n%s" ,d .View ()) 45 } 46// The daemon lists the typed folder; the last segment filters it and 47// dot folders stay hidden. 48d .Type (root + "/pi" ) 49if view := d .View ();! strings .Contains (view ,"picked" )|| strings .Contains (view ,"other" )|| strings .Contains (view ,".hidden" ) { 50t .Fatalf ("the listing was not filtered to picked:\n%s" ,view ) 51 } 52d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 53if d .App .State != tui .AppStateChat { 54t .Fatalf ("enter did not return to the chat:\n%s" ,d .View ()) 55 } 56id := d .App .ActiveSession .ID 57if got := daemonSession (t ,id ).Workspace ;! same (got ,filepath .Join (root ,"picked" )) { 58t .Fatalf ("the daemon kept workspace %q" ,got ) 59 } 60 61// /cd with a path moves at once, relative to where the session is. 62d .Dispatch (tui.ChatExecuteCommandMsg {Name :"/cd" ,Args :"../other" }) 63if got := daemonSession (t ,id ).Workspace ;! same (got ,filepath .Join (root ,"other" ))|| ! same (d .App .Chat .Workspace ,got ) { 64t .Fatalf ("/cd ../other left the daemon at %q and the chat at %q" ,got ,d .App .Chat .Workspace ) 65 } 66// The chat offers linking the folder it left, as a command to paste. 67view := d .View () 68_ ,offered ,ok := strings .Cut (view ,"/link add " ) 69if ! ok { 70t .Fatalf ("moving did not offer /link add:\n%s" ,view ) 71 } 72offered = strings .Fields (offered )[0 ] 73if ! same (offered ,filepath .Join (root ,"picked" )) { 74t .Fatalf ("the hint offers %q, not the folder the session left" ,offered ) 75 } 76d .Dispatch (tui.ChatExecuteCommandMsg {Name :"/link" ,Args :"add " + offered }) 77if d .App .State != tui .AppStatePageView || d .App .PageView .Doc == nil || d .App .PageView .Doc .Title != "Link workspace groups" || len (d .App .PageView .Doc .Rows )!= 2 || ! strings .Contains (d .View (),"complete groups will share memory and work" ) { 78t .Fatalf ("pasting the hint did not prepare the observed groups:\n%s" ,d .View ()) 79 } 80d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 81d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 82if d .App .PageView .Error != "" { 83t .Fatal (d .App .PageView .Error ) 84 } 85group ,err := daemon .LoadPage (t .Context (),conn (t ),d .App .ActiveSession .ID ,"/links" ) 86if err != nil { 87t .Fatal (err ) 88 } 89linked := false 90for _ ,member := range group .Rows { 91linked = linked || member .Text == offered 92 } 93if ! linked { 94t .Fatalf ("confirmed merge omitted old workspace: %+v" ,group .Rows ) 95 } 96} 97 98func TestTUIMissingWorkspaceSendsTheTurnFromThePickedFolder (t * testing.T ) { 99profile := providerRoute (t ,echoReply ) 100t .Parallel () 101root := t .TempDir () 102gone ,found := filepath .Join (root ,"gone" ),filepath .Join (root ,"found" ) 103for _ ,dir := range []string {gone ,found } { 104if err := os .Mkdir (dir ,0o755 );err != nil { 105t .Fatal (err ) 106 } 107 } 108session := daemonSession (t ,newSession (t ,gone )) 109d := driveTUI (t ,& session ) 110defer d .App .Chat .Close () 111d .Update (tea.WindowSizeMsg {Width :120 ,Height :30 }) 112d .App .Chat .TextArea .SetValue ("keep this draft" ) 113d .Dispatch (tui.FolderNewSessionMsg {Workspace :filepath .Join (root ,"never-created" )}) 114if d .App .State != tui .AppStateChat || d .App .ActiveSession == nil || d .App .ActiveSession .ID != session .ID || d .App .Chat .Workspace != session .Workspace || d .App .Chat .TextArea .Value ()!= "keep this draft" { 115t .Fatalf ("a rejected creation changed the unrelated active session:\n%s" ,d .View ()) 116 } 117if err := os .Remove (gone );err != nil { 118t .Fatal (err ) 119 } 120 121d .connected () 122d .App .Chat .TextArea .SetValue ("hello from nowhere" ) 123messages := d .results (d .Update (tea.KeyPressMsg {Code :tea .KeyEnter })) 124var admitted tui.ChatTurnSentMsg 125for _ ,message := range messages { 126if sent ,ok := message .(tui.ChatTurnSentMsg );ok { 127admitted = sent 128d .Update (sent ) 129 } 130 } 131if admitted .Err != nil || admitted .Handle == nil || ! admitted .OK { 132t .Fatalf ("durable admission: %+v" ,admitted ) 133 } 134var receipt daemon.InputReceipt 135deadline := time .Now ().Add (5 * time .Second ) 136for { 137var err error 138receipt ,err = daemon .ResolveOperation (t .Context (),conn (t ),admitted .Handle ) 139if err != nil { 140t .Fatal (err ) 141 } 142if receipt .BlockingDetail ()!= "" { 143break 144 } 145if time .Now ().After (deadline ) { 146t .Fatal ("missing workspace did not retain blocked pending input" ) 147 } 148time .Sleep (25 * time .Millisecond ) 149 } 150if ! receipt .Pending () { 151t .Fatalf ("blocked input was lost: %+v" ,receipt ) 152 } 153d .Update (tui.ChatOperationResolvedMsg {SessionID :session .ID ,Generation :d .App .Chat .Generation ,Handle :admitted .Handle ,Receipt :receipt }) 154if d .App .State != tui .AppStateChat || d .App .Chat .TextArea .Value ()!= "" || ! strings .Contains (d .View (),"hello from nowhere" ) { 155t .Fatalf ("blocked admission state=%v composer=%q view=%q" ,d .App .State ,d .App .Chat .TextArea .Value (),d .View ()) 156 } 157 158d .Dispatch (tui.ChatExecuteCommandMsg {Name :"/cd" }) 159d .Type (root + "/fou" ) 160moved ,ok := d .Key (tea .KeyEnter ).(tui.FolderMovedMsg ) 161if ! ok || moved .Err != nil { 162for cause := moved .Err ;cause != nil ;cause = errors .Unwrap (cause ) { 163t .Logf ("workspace move cause: %T %v" ,cause ,cause ) 164 } 165t .Fatalf ("enter did not move the session: %v (message %T)" ,moved .Err ,moved ) 166 } 167d .Update (moved ) 168waitIdle (t ,session .ID ,profile ,1 ) 169history ,err := daemon .NewChatClient (conn (t ),session .ID ).History (t .Context (),0 ,120 ) 170if err != nil { 171t .Fatal (err ) 172 } 173users := 0 174for _ ,event := range history .Events { 175if event .Type == daemon .EventUser { 176users ++ 177if event .OperationID != admitted .OperationID { 178t .Fatal ("workspace repair created another user intent" ) 179 } 180 } 181 } 182if users != 1 { 183t .Fatalf ("workspace repair delivered %d user rows" ,users ) 184 } 185if got ,_ := filepath .EvalSymlinks (daemonSession (t ,session .ID ).Workspace );got != mustEval (t ,found ) { 186t .Fatalf ("the daemon kept workspace %q" ,got ) 187 } 188} 189 190func TestTUISessionsByFolderOpenAndStartSessions (t * testing.T ) { 191providerRoute (t ,echoReply ) 192// Default-changing tests finish before parallel bodies. These creations 193// send no turns and do not assert which provider the daemon selects. 194t .Parallel () 195cache ,err := os .UserCacheDir () 196if err != nil { 197t .Fatal (err ) 198 } 199if err = os .MkdirAll (cache ,0o700 );err != nil { 200t .Fatal (err ) 201 } 202// The system temporary directory may be inside a repository. This 203// scenario needs a plain directory whose complete tree is visible. 204root ,err := os .MkdirTemp (cache ,"albedo-go-plain-workspace-" ) 205if err != nil { 206t .Fatal (err ) 207 } 208t .Cleanup (func () { 209if err := os .RemoveAll (root );err != nil { 210t .Errorf ("remove the plain workspace fixture: %v" ,err ) 211 } 212 }) 213busy ,empty ,crowded := filepath .Join (root ,"busy" ),filepath .Join (root ,"empty" ),filepath .Join (root ,"crowded" ) 214for _ ,dir := range []string {busy ,empty ,crowded } { 215if err := os .Mkdir (dir ,0o755 );err != nil { 216t .Fatal (err ) 217 } 218 } 219// A deep tree, taller than the pane, must not push the sessions off. 220for i := range 12 { 221for j := range 5 { 222if err := os .MkdirAll (filepath .Join (busy ,fmt .Sprintf ("d%02d/c%d" ,i ,j )),0o755 );err != nil { 223t .Fatal (err ) 224 } 225 } 226 } 227there := newSession (t ,busy ) 228// In the sessions view the leading slash opens the folder browser, which 229// must be on screen before the rest of the path is typed into it. 230typePath := func (d * tuiDriver ,path string ) { 231d .Dispatch (tea.KeyPressMsg {Code :'/' ,Text :"/" }) 232d .Type (strings .TrimPrefix (path ,"/" )) 233 } 234 235// ^f in the sessions view browses folders; → steps into the highlighted 236// folder's sessions and enter opens one. 237d := driveTUI (t ,nil ) 238d .Update (tea.WindowSizeMsg {Width :120 ,Height :30 }) 239d .Dispatch (tea.KeyPressMsg {Code :'f' ,Mod :tea .ModCtrl }) 240if d .App .State != tui .AppStateFolderPicker { 241t .Fatalf ("^f did not open the folder browser:\n%s" ,d .View ()) 242 } 243d .Type (root + "/bu" ) 244if view := d .View ();! strings .Contains (view ,"d00/" )|| ! strings .Contains (view ,"Untitled session" ) { 245preview ,previewErr := daemon .PreviewFolder (t .Context (),conn (t ),busy ) 246t .Logf ("daemon preview: tree=%d more=%d repository=%+v error=%v" ,len (preview .Tree ),preview .More ,preview .Repo ,previewErr ) 247listing ,listErr := daemon .ListFolders (t .Context (),conn (t ),busy ) 248t .Logf ("daemon directory: entries=%d error=%v" ,len (listing .Entries ),listErr ) 249t .Fatalf ("the preview lost its tree or its sessions:\n%s" ,view ) 250 } 251d .Dispatch (tea.KeyPressMsg {Code :tea .KeyRight }) 252opened ,ok := d .Key (tea .KeyEnter ).(tui.FolderOpenSessionMsg ) 253if ! ok || opened .Session .ID != there { 254t .Fatalf ("enter in the folder's sessions did not open %s: %#v\n%s" ,there ,opened ,d .View ()) 255 } 256d .Update (opened ) 257if d .App .State != tui .AppStateChat || d .App .ActiveSession .ID != there { 258t .Fatalf ("the picked session is not on screen:\n%s" ,d .View ()) 259 } 260 261// Under a short tree the sessions fill the pane instead of a third of it. 262for range 12 { 263newSession (t ,crowded ) 264 } 265d = driveTUI (t ,nil ) 266d .Update (tea.WindowSizeMsg {Width :120 ,Height :30 }) 267typePath (d ,root + "/cr" ) 268if view := d .View ();strings .Count (view ,"Untitled session" )!= 12 { 269t .Fatalf ("the preview cut the sessions short:\n%s" ,view ) 270 } 271 272// A path typed into an empty search browses too, and enter on a folder 273// starts a session there. 274d = driveTUI (t ,nil ) 275d .Update (tea.WindowSizeMsg {Width :120 ,Height :30 }) 276typePath (d ,root + "/em" ) 277if d .App .State != tui .AppStateFolderPicker { 278t .Fatalf ("typing a path did not open the folder browser:\n%s" ,d .View ()) 279 } 280started ,ok := d .Key (tea .KeyEnter ).(tui.FolderNewSessionMsg ) 281if ! ok { 282t .Fatalf ("enter on a folder did not start a session: %#v" ,started ) 283 } 284d .Update (d .Send (started )) 285if got := d .App .ActiveSession ;d .App .State != tui .AppStateChat || got == nil || mustEval (t ,got .Workspace )!= mustEval (t ,empty ) { 286t .Fatalf ("the new session is not in %s:\n%s" ,empty ,d .View ()) 287 } 288} 289 290func mustEval (t * testing.T ,p string )string { 291t .Helper () 292resolved ,err := filepath .EvalSymlinks (p ) 293if err != nil { 294t .Fatal (err ) 295 } 296return resolved 297} 298 299// A host is picked like a folder: scp syntax lists its home through the 300// daemon's ssh, the rows fold under that host's ~, enter starts a session 301// whose kernel boots there, and a host ssh cannot reach is never picked. 302// 303// The TUI starts the session on the active profile, so this runs alone. 304func TestTUIStartsASessionOnAHostPickedLikeAFolder (t * testing.T ) { 305profile := providerRoute (t ,echoReply ) 306picked := filepath .Join (suite .remoteHome ,"proj" ,fmt .Sprintf ("picked-%d" ,os .Getpid ())) 307if err := os .MkdirAll (picked ,0o755 );err != nil { 308t .Fatal (err ) 309 } 310d := driveTUI (t ,nil ) 311d .Update (tea.WindowSizeMsg {Width :120 ,Height :30 }) 312d .Dispatch (tea.KeyPressMsg {Code :'f' ,Mod :tea .ModCtrl }) 313d .Type ("gohost:" ) 314if view := d .View ();! strings .Contains (view ,"in gohost:~" )|| ! strings .Contains (view ,"proj" ) { 315t .Fatalf ("gohost: did not list the host's home:\n%s" ,view ) 316 } 317d .Type ("proj/" + filepath .Base (picked )[:6 ]) 318if view := d .View ();! strings .Contains (view ,"in gohost:proj" )|| ! strings .Contains (view ,filepath .Base (picked )) { 319t .Fatalf ("the host's folder did not list:\n%s" ,view ) 320 } 321started ,ok := d .Key (tea .KeyEnter ).(tui.FolderNewSessionMsg ) 322if ! ok || started .Workspace != "gohost:" + picked { 323t .Fatalf ("enter did not start a session in gohost:%s: %#v" ,picked ,started ) 324 } 325d .Update (d .Send (started )) 326session := d .App .ActiveSession 327if d .App .State != tui .AppStateChat || session == nil || session .Workspace != "gohost:" + picked { 328t .Fatalf ("the new session is not on gohost:\n%s" ,d .View ()) 329 } 330// the daemon's parsed location reaches the client, so the header shows the host 331if l := session .Location ;l == nil || l .Host == nil || * l .Host != "gohost" || l .Path != picked { 332t .Fatalf ("the session's location did not decode: %+v" ,l ) 333 } 334 335// its kernel boots on the host, and the status says it is attached 336if _ ,err := daemon .NewChatClient (conn (t ),session .ID ).Send (context .Background (),"hello there" ,nil );err != nil { 337t .Fatal (err ) 338 } 339waitIdle (t ,session .ID ,profile ,1 ) 340status ,err := daemon .NewChatClient (conn (t ),session .ID ).GetStatus (context .Background ()) 341if err != nil || status .KernelLink != "attached" { 342t .Fatalf ("after a turn the kernel status is %+v (%v)" ,status ,err ) 343 } 344 345// a host ssh cannot reach says why and is never picked 346d = driveTUI (t ,nil ) 347d .Update (tea.WindowSizeMsg {Width :120 ,Height :30 }) 348d .Dispatch (tea.KeyPressMsg {Code :'f' ,Mod :tea .ModCtrl }) 349d .Type ("nohost:" ) 350if view := d .View ();! strings .Contains (view ,"connect to host nohost" ) { 351t .Fatalf ("an unreachable host does not say why:\n%s" ,view ) 352 } 353if msg ,started := d .Key (tea .KeyEnter ).(tui.FolderNewSessionMsg );started { 354t .Fatalf ("enter started a session on an unreachable host: %#v" ,msg ) 355 } 356} 357 358// A turn at a host that needs a person to sign in is kept, waiting on the 359// daemon's next try, and the chat offers to open the daemon's ssh master 360// here so ssh can ask. 361func TestTUIOffersSignInForATurnWaitingOnItsHost (t * testing.T ) { 362providerRoute (t ,echoReply ) 363t .Parallel () 364session := daemonSession (t ,newSession (t ,"lockedhost:/srv/app" )) 365d := driveTUI (t ,& session ) 366d .Update (tea.WindowSizeMsg {Width :120 ,Height :30 }) 367d .connected () 368d .App .Chat .TextArea .SetValue ("hello behind a lock" ) 369var admitted tui.ChatTurnSentMsg 370for _ ,message := range d .results (d .Update (tea.KeyPressMsg {Code :tea .KeyEnter })) { 371if sent ,ok := message .(tui.ChatTurnSentMsg );ok { 372admitted = sent 373d .Update (sent ) 374 } 375 } 376if admitted .Err != nil || admitted .Handle == nil { 377t .Fatalf ("the turn was not admitted: %+v" ,admitted ) 378 } 379var receipt daemon.InputReceipt 380for deadline := time .Now ().Add (10 * time .Second );receipt .BlockingDetail ()== "" ;time .Sleep (25 * time .Millisecond ) { 381var err error 382if receipt ,err = daemon .ResolveOperation (t .Context (),conn (t ),admitted .Handle );err != nil { 383t .Fatal (err ) 384 } 385if time .Now ().After (deadline ) { 386t .Fatalf ("the turn never waited on its host: %+v" ,receipt ) 387 } 388 } 389// The receipt's own poll answers only at the daemon's next try, so each 390// answer is taken as it comes rather than waiting for all of them. 391resolved := tui.ChatOperationResolvedMsg {SessionID :session .ID ,Generation :d .App .Chat .Generation ,Handle :admitted .Handle ,Receipt :receipt } 392answers := make (chan tea.Msg ,4 ) 393for _ ,cmd := range d .Update (resolved )().(tea.BatchMsg ) { 394go func () {answers <- cmd () }() 395 } 396offer := "Press ctrl+l to sign in to lockedhost here" 397for timeout := time .After (10 * time .Second );! strings .Contains (d .View (),offer ); { 398select { 399case answer := <- answers : 400d .Update (answer ) 401case <- timeout : 402t .Fatalf ("a turn waiting on a sign-in does not offer one:\n%s" ,d .View ()) 403 } 404 } 405if view := d .View ();! strings .Contains (view ,"Permission denied" ) { 406t .Fatalf ("the waiting turn does not say why:\n%s" ,view ) 407 } 408} 409 410// A probe is readable before its target has SSH config or session history. 411func TestHostProbeObservesUnconfiguredTargets (t * testing.T ) { 412t .Parallel () 413for _ ,target := range []string {"fresh-host" ,"ana@fresh-host" } { 414t .Run (target ,func (t * testing.T ) { 415known ,err := daemon .ListHosts (t .Context (),conn (t )) 416if err != nil { 417t .Fatal (err ) 418 } 419for _ ,host := range known { 420if host .Host == target { 421t .Fatalf ("fixture target %s is already known" ,target ) 422 } 423 } 424status ,err := daemon .GetHost (t .Context (),conn (t ),target ) 425if err != nil || status .Host != target || status .State != "unknown" { 426t .Fatalf ("unconfigured target before probe: %+v, %v" ,status ,err ) 427 } 428ctx ,cancel := context .WithTimeout (t .Context (),10 * time .Second ) 429defer cancel () 430status ,err = daemon .WarmHost (ctx ,conn (t ),target ) 431if err != nil || status .Host != target || status .State != "ready" || status .Home != suite .remoteHome { 432t .Fatalf ("unconfigured target after probe: %+v, %v" ,status ,err ) 433 } 434 }) 435 } 436} 437 438// A real probe's progress must cross the Host API and the picker adapter. 439func TestHostStagingProgressReachesThePickerAndClearsWhenSettled (t * testing.T ) { 440providerRoute (t ,echoReply ) 441session := daemonSession (t ,newSession (t ,t .TempDir ())) 442for _ ,target := range []string {"stage-ready" ,"stage-failure" } { 443t .Run (target ,func (t * testing.T ) { 444gate := filepath .Join (suite .remoteHome ,target ) 445release := func (stage string ) { 446t .Helper () 447if err := os .WriteFile (gate + ".release-" + stage ,nil ,0o600 );err != nil { 448t .Fatal (err ) 449 } 450 } 451t .Cleanup (func () {release ("connect" );release ("stage" ) }) 452ctx ,cancel := context .WithTimeout (t .Context (),15 * time .Second ) 453defer cancel () 454finished := make (chan daemon.HostStatus ,1 ) 455go func () { 456status ,err := daemon .WarmHost (ctx ,conn (t ),target ) 457if err != nil { 458status .Detail = err .Error () 459 } 460finished <- status 461 }() 462observe := func (state ,step string ) daemon.HostStatus { 463t .Helper () 464for { 465status ,err := daemon .GetHost (ctx ,conn (t ),target ) 466if err != nil { 467t .Fatal (err ) 468 } 469if status .State == state && status .Step == step { 470return status 471 } 472select { 473case <- ctx .Done (): 474t .Fatalf ("host never reached %s/%s: %+v" ,state ,step ,status ) 475case <- time .After (20 * time .Millisecond ): 476 } 477 } 478 } 479observe ("probing" ,"" ) 480release ("connect" ) 481observe ("probing" ,"staging" ) 482 483if target == "stage-ready" { 484d := driveTUI (t ,& session ) 485d .Update (tea.WindowSizeMsg {Width :120 ,Height :30 }) 486d .Dispatch (tui.ChatExecuteCommandMsg {Name :"/cd" }) 487defer d .App .FolderPicker .Close () 488answers := make (chan tea.Msg ,128 ) 489var run func (tea.Cmd ) 490run = func (command tea.Cmd ) { 491if command == nil { 492return 493 } 494go func () { 495message := command () 496if batch ,ok := message .(tea.BatchMsg );ok { 497for _ ,child := range batch { 498run (child ) 499 } 500return 501 } 502select { 503case answers <- message : 504case <- ctx .Done (): 505 } 506 }() 507 } 508for _ ,letter := range target + ":" { 509run (d .Update (tea.KeyPressMsg {Code :letter ,Text :string (letter )})) 510 } 511for ! strings .Contains (d .View (),"copying the kernel to " + target ) { 512select { 513case message := <- answers : 514run (d .Update (message )) 515case <- ctx .Done (): 516t .Fatalf ("staging never reached the picker:\n%s" ,d .View ()) 517 } 518 } 519 } 520release ("stage" ) 521state := "ready" 522if target == "stage-failure" { 523state = "unreachable" 524 } 525settled := observe (state ,"" ) 526if state == "unreachable" && ! strings .Contains (settled .Detail ,"fixture staging failed" ) { 527t .Fatalf ("staging failure was lost: %+v" ,settled ) 528 } 529select { 530case status := <- finished : 531if status .State != state || status .Step != "" { 532t .Fatalf ("probe completion retained progress: %+v" ,status ) 533 } 534case <- ctx .Done (): 535t .Fatal (ctx .Err ()) 536 } 537 }) 538 } 539}