okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
39972a8
main
1// The chat header drops whole parts as the terminal narrows instead of 2// cutting one short, and a remote workspace's host is never one of them. 3// The layout depends only on the width, so the rule is checked across every 4// width here; an e2e would see one terminal size. 5package tui 6 7import ( 8"strings" 9"testing" 10"time" 11 12"albedo/cli/internal/daemon" 13"albedo/cli/internal/daemon/protocol" 14"github.com/charmbracelet/x/ansi" 15) 16 17func TestHeaderKeepsTheWorkspaceNameAndModelWhole (t * testing.T ) { 18m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 19m .AgentName ,m .Workspace ,m .Model ,m .Effort = "albedo" ,"/src/proj/tangled" ,"claude-opus-4-6" ,"high" 20m .Glances = []PageGlance { 21 {Title :"pending work" ,Rows : []PageRow {{ID :"1" ,Text :"x" }}}, 22 {Title :"open vents" ,Rows : []PageRow {{ID :"2" ,Text :"y" }}}, 23 } 24const model = "claude-opus-4-6:high" 25for width := 1 ;width <= 120 ;width ++ { 26header := ansi .Strip (m .header (width )) 27if got := ansi .StringWidth (header );got > width { 28t .Fatalf ("width %d: header %q is %d wide" ,width ,header ,got ) 29 } 30if width >= len (model )&& ! strings .Contains (header ,model ) { 31t .Fatalf ("width %d: header %q cut the model" ,width ,header ) 32 } 33if width >= len ("tangled" )+ len (model )+ 3 && ! strings .Contains (header ,"tangled" ) { 34t .Fatalf ("width %d: header %q cut the workspace name" ,width ,header ) 35 } 36 } 37if header := ansi .Strip (m .header (100 ));! strings .Contains (header ,"pending work 1 open vents 1" ) { 38t .Fatalf ("a wide header without a sidebar should count every glance: %q" ,header ) 39 } 40} 41 42func TestRemoteHeaderKeepsTheHostWholeAndFoldsOnlyThePath (t * testing.T ) { 43label := "chernobog" 44m := newTestChatModel (t ,& daemon.Session { 45ID :"s" , 46Workspace :"mayer@chernobog:/home/mayer/proj/albedo" , 47Location :& daemon.Location {Label :& label }, 48 }) 49m .AgentName ,m .Model = "albedo" ,"claude-opus-4-6" 50const model = "claude-opus-4-6" 51if header := ansi .Strip (m .header (120 ));! strings .Contains (header ,"✦ albedo on chernobog:/home/mayer/proj/albedo" ) { 52t .Fatalf ("a wide header should name the host and the whole path: %q" ,header ) 53 } 54for width := 1 ;width <= 120 ;width ++ { 55header := ansi .Strip (m .header (width )) 56if got := ansi .StringWidth (header );got > width { 57t .Fatalf ("width %d: header %q is %d wide" ,width ,header ,got ) 58 } 59if strings .Contains (header ,"mayer@" ) { 60t .Fatalf ("width %d: header %q shows the user the label left out" ,width ,header ) 61 } 62if strings .Contains (header ,"/" )&& ! strings .Contains (header ,"chernobog:" ) { 63t .Fatalf ("width %d: header %q shows the path without its host" ,width ,header ) 64 } 65if width >= len ("chernobog:albedo" )+ len (model )+ 3 && ! strings .Contains (header ,"chernobog:" ) { 66t .Fatalf ("width %d: header %q dropped the host" ,width ,header ) 67 } 68 } 69} 70 71func TestRemoteHeaderFoldsUnderTheHostsHomeAndSaysWhileConnecting (t * testing.T ) { 72label := "chernobog" 73m := newTestChatModel (t ,& daemon.Session { 74ID :"s" , 75Workspace :"mayer@chernobog:/home/mayer/proj/albedo" , 76Location :& daemon.Location {Label :& label }, 77 }) 78m .AgentName ,m .Model = "albedo" ,"claude-opus-4-6" 79m .Status = daemon.AgentStatus {Idle :true ,KernelLink :"booting" } 80if got := m .statusLine ();got != "connecting to chernobog…" { 81t .Fatalf ("a booting remote kernel reads %q" ,got ) 82 } 83m .Status .KernelStep = "staging" 84if got := m .statusLine ();got != "copying the kernel to chernobog…" { 85t .Fatalf ("a first boot on the host reads %q" ,got ) 86 } 87m .Status .KernelStep = "" 88// an idle session animates too, with the face the picker shows for the host 89m .ProgressFrame = 1 90if view := ansi .Strip (m .View ());! m .animating ()|| ! strings .Contains (view ,"connecting to chernobog… " + connectingFace ("chernobog" ,1 )) { 91t .Fatalf ("connecting should animate (animating %v):\n%s" ,m .animating (),view ) 92 } 93m .Status .KernelLink ,m .hostHome = "attached" ,"/home/mayer" 94if header := ansi .Strip (m .header (120 ));! strings .Contains (header ,"✦ albedo on chernobog:proj/albedo" ) { 95t .Fatalf ("the path should fold under the host's own home: %q" ,header ) 96 } 97if got := m .statusLine ();strings .Contains (got ,"connecting" ) { 98t .Fatalf ("an attached kernel still reads %q" ,got ) 99 } 100m .Status .KernelLink = "lost" 101if got := m .statusLine ();got != "kernel on chernobog lost · the next turn starts a fresh one" { 102t .Fatalf ("a lost kernel reads %q" ,got ) 103 } 104} 105 106func TestBackgroundJobsRenderInHeaderStatusAndSidebar (t * testing.T ) { 107m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 108m .AgentName ,m .Workspace ,m .Model = "albedo" ,"/src/proj/albedo" ,"claude-opus-4-6" 109m .Status = daemon.AgentStatus { 110Idle :true , 111KernelJobs :new (int64 (1 )), 112RunningJobs : []protocol.KernelJob { 113 {ID :"job1" ,Command :"go test ./..." }, 114 }, 115 } 116 117if got := m .statusLine ();got != "1 background job running · go test ./..." { 118t .Fatalf ("expected statusLine to show 1 running job, got %q" ,got ) 119 } 120if ! m .animating () { 121t .Fatalf ("expected model to animate when background jobs are running" ) 122 } 123 124header := ansi .Strip (m .header (120 )) 125if ! strings .Contains (header ,"background jobs 1" ) { 126t .Fatalf ("expected header to count running jobs glance, got %q" ,header ) 127 } 128 129m .Glances = []PageGlance {{Title :"open vents" ,Rows : []PageRow {{ID :"v1" ,Text :"a vent" }}}} 130m .SetSize (140 ,30 ) 131view := ansi .Strip (m .View ()) 132if ! strings .Contains (view ,"background jobs" )|| ! strings .Contains (view ,"go test ./..." ) { 133t .Fatalf ("expected sidebar to show running background job, got:\n%s" ,view ) 134 } 135 136if len (m .Glances )!= 1 || m .Glances [0 ].Title != "open vents" { 137t .Fatal ("rendering jobs changed extension glances" ) 138 } 139for width := 1 ;width <= 140 ;width ++ { 140m .SetSize (width ,30 ) 141if got := ansi .StringWidth (m .header (width ));got > width { 142t .Fatalf ("job header at width %d exceeds its bounds: %d" ,width ,got ) 143 } 144 } 145m .Glances = nil 146m .Status .KernelJobs = nil 147m .Status .RunningJobs = nil 148if m .animating ()|| m .statusLine ()!= "" || len (m .activeGlances ())!= 0 { 149t .Fatal ("unknown jobs must not claim that background work is running" ) 150 } 151 152m .Status .KernelJobs = new (int64 (2 )) 153m .Status .RunningJobs = []protocol.KernelJob { 154 {ID :"job1" ,Command :"sleep 10" }, 155 {ID :"job2" ,Command :"cargo build" }, 156 } 157if got := m .statusLine ();got != "2 background jobs running · sleep 10" { 158t .Fatalf ("expected statusLine to show 2 running jobs, got %q" ,got ) 159 } 160 161m .Status .KernelJobs = new (int64 (0 )) 162m .Status .RunningJobs = nil 163if got := m .statusLine ();got != "" { 164t .Fatalf ("expected empty statusLine when idle with no jobs, got %q" ,got ) 165 } 166} 167 168func TestBackgroundJobsRefreshWithoutStartingATurn (t * testing.T ) { 169m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 170m .SetSize (80 ,30 ) 171status := daemon.AgentStatus {Idle :true ,KernelJobs :new (int64 (1 )), 172RunningJobs : []protocol.KernelJob {{ID :"j" ,Command :"sleep 10" }}} 173m ,cmd := m .Update (ChatStatusMsg {SessionID :m .SessionID ,Generation :m .Generation , 174Revision :m .statusRevision ,Status :& status }) 175if ! m .animating ()|| cmd == nil || ! strings .Contains (m .statusLine (),"sleep 10" ) { 176t .Fatal ("idle background job did not start its status animation" ) 177 } 178status .KernelJobs ,status .RunningJobs = new (int64 (0 )),nil 179m ,_ = m .Update (ChatStatusMsg {SessionID :m .SessionID ,Generation :m .Generation , 180Revision :m .statusRevision ,Status :& status }) 181if m .animating ()|| m .statusLine ()!= "" || len (m .activeGlances ())!= 0 { 182t .Fatal ("finished jobs left idle job chrome behind" ) 183 } 184} 185 186func TestStatusPollingRestsWhileIdleAndKeepsOneLoop (t * testing.T ) { 187// The loop count and cadence are invisible over HTTP; an extra status read 188// once forked a second 750ms loop that never ended. 189m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 190idle := daemon.AgentStatus {Idle :true } 191m ,_ = m .Update (ChatStatusMsg {SessionID :m .SessionID ,Generation :m .Generation , 192Revision :m .statusRevision ,Status :& idle }) 193stale := m .statusPoll 194m ,_ = m .Update (ChatStatusMsg {SessionID :m .SessionID ,Generation :m .Generation , 195Revision :m .statusRevision ,Status :& idle }) 196if ! m .statusPollResting { 197t .Fatal ("an idle session kept the fast status poll" ) 198 } 199if _ ,cmd := m .Update (ChatStatusPollMsg {SessionID :m .SessionID ,Generation :m .Generation ,Poll :stale });cmd != nil { 200t .Fatal ("a superseded poll read status again" ) 201 } 202m ,cmd := m .Update (ChatStreamEventMsg {SessionID :m .SessionID ,Generation :m .Generation , 203Event : daemon.StreamEvent {Type :daemon .EventNote }}) 204if m .statusPollResting || cmd == nil { 205t .Fatal ("live activity did not wake the resting status poll" ) 206 } 207} 208 209func TestYoungJobsStayOutOfTheBackgroundChrome (t * testing.T ) { 210// The threshold is render logic over a started_at the daemon reports; an 211// e2e would have to wait out the grace to see either side of it. 212m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 213m .SetSize (140 ,30 ) 214m .Status = daemon.AgentStatus { 215Idle :true , 216KernelJobs :new (int64 (1 )), 217RunningJobs : []protocol.KernelJob { 218 {ID :"job1" ,Command :"rg pattern" ,StartedAt :time .Now ().UnixMilli ()}, 219 }, 220 } 221 222if m .animating ()|| m .statusLine ()!= "" || len (m .activeGlances ())!= 0 { 223t .Fatal ("a job inside its grace is not background work yet" ) 224 } 225if got := m .runningJobCommand ();got != "rg pattern" { 226t .Fatalf ("the action row still names the job a running cell is on: %q" ,got ) 227 } 228 229// one aged job shows, one live remote job the list cannot name still counts 230m .Status .KernelJobs = new (int64 (3 )) 231m .Status .RunningJobs = append (m .Status .RunningJobs , 232 protocol.KernelJob {ID :"job2" ,Command :"cargo build" ,StartedAt :time .Now ().Add (- 10 * time .Second ).UnixMilli ()}) 233if got := m .statusLine ();got != "2 background jobs running · cargo build" { 234t .Fatalf ("aged jobs count and lead the idle status line, got %q" ,got ) 235 } 236view := ansi .Strip (m .View ()) 237if strings .Contains (view ,"rg pattern" )|| ! strings .Contains (view ,"cargo build" ) { 238t .Fatalf ("the sidebar hides the young job and shows the aged one:\n%s" ,view ) 239 } 240}