okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
10e0f66
main
1//go:build unix 2 3// Pinning, renaming, archiving and deleting cross the session picker, the 4// prefs file and the daemon's PATCH and DELETE. A stubbed server cannot show 5// the daemon keeping the new title or dropping the deleted session. 6package e2e 7 8import ( 9"slices" 10"strings" 11"testing" 12"time" 13 14"albedo/cli/internal/daemon" 15"albedo/cli/internal/tui" 16 17 tea"charm.land/bubbletea/v2" 18) 19 20func TestTUISessionPickerPinsRenamesArchivesAndDeletes (t * testing.T ) { 21providerRoute (t ,echoReply ) 22t .Parallel () 23d := newTUIDriver (t ) 24target := newSession (t ,t .TempDir ()) 25d .Dispatch (tui.ChatBackToSessionsMsg {}) 26// highlight puts the picker cursor on id; the actions themselves are keys. 27highlight := func (id string ) { 28t .Helper () 29i := slices .IndexFunc (d .App .SessionPicker .Filtered ,func (it tui.PickerItem )bool {return it .ID == id }) 30if i < 0 { 31t .Fatalf ("the picker does not list %s:\n%s" ,id ,d .View ()) 32 } 33d .App .SessionPicker .Cursor = i 34d .settle (d .results (d .App .SessionPicker .PreviewCmd ())) 35 } 36ctrl := func (code rune ) {d .Dispatch (tea.KeyPressMsg {Code :code ,Mod :tea .ModCtrl }) } 37 38highlight (target ) 39ctrl ('s' ) 40saved ,err := daemon .GetSession (t .Context (),conn (t ),target ) 41if err != nil || ! saved .Pinned || ! strings .Contains (d .View (),"pinned 1" ) { 42t .Fatalf ("ctrl+s did not pin %s (pinned %v, %v):\n%s" ,target ,saved .Pinned ,err ,d .View ()) 43 } 44 45ctrl ('r' ) 46for _ ,r := range "renamed-session" { 47d .Update (tea.KeyPressMsg {Code :r ,Text :string (r )}) 48 } 49d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 50if title := daemonSession (t ,target ).Title ;title != "renamed-session" || ! strings .Contains (d .View (),"renamed-session" ) { 51t .Fatalf ("the daemon kept title %q:\n%s" ,title ,d .View ()) 52 } 53 54ctrl ('a' ) 55if saved := daemonSession (t ,target );! saved .Archived { 56t .Fatal ("ctrl+a did not archive the selected session" ) 57 } 58highlight ("archive" ) 59d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 60highlight (target ) 61ctrl ('d' ) 62d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 63if slices .ContainsFunc (daemonSessions (t ),func (s daemon.Session )bool {return s .ID == target }) { 64t .Fatalf ("the daemon still lists deleted session %s" ,target ) 65 } 66} 67 68// The session list carries no validators, so renaming, archiving and 69// deleting a row whose preview never loaded still have to reach the daemon, 70// and the picker has to show each change without being reopened. 71func TestTUIChangesSessionsStraightFromTheList (t * testing.T ) { 72providerRoute (t ,echoReply ) 73t .Parallel () 74d := newTUIDriver (t ) 75archived ,renamed := newSession (t ,t .TempDir ()),newSession (t ,t .TempDir ()) 76d .Dispatch (tui.ChatBackToSessionsMsg {}) 77listed := func (id string )int { 78return slices .IndexFunc (d .App .SessionPicker .Filtered ,func (it tui.PickerItem )bool {return it .ID == id }) 79 } 80pick := func (id string ) { 81t .Helper () 82i := listed (id ) 83if i < 0 { 84t .Fatalf ("the picker does not list %s:\n%s" ,id ,d .View ()) 85 } 86d .App .SessionPicker .Cursor = i 87 } 88ctrl := func (code rune ) {d .Dispatch (tea.KeyPressMsg {Code :code ,Mod :tea .ModCtrl }) } 89 90pick (archived ) 91ctrl ('a' ) 92if ! daemonSession (t ,archived ).Archived || listed (archived )>= 0 { 93t .Fatalf ("ctrl+a did not archive the listed session:\n%s" ,d .View ()) 94 } 95 96pick (renamed ) 97ctrl ('r' ) 98for _ ,r := range "listed-rename" { 99d .Update (tea.KeyPressMsg {Code :r ,Text :string (r )}) 100 } 101d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 102if title := daemonSession (t ,renamed ).Title ;title != "listed-rename" || ! strings .Contains (d .View (),"listed-rename" ) { 103t .Fatalf ("ctrl+r did not rename the listed session (daemon has %q):\n%s" ,title ,d .View ()) 104 } 105 106pick ("archive" ) 107d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 108pick (archived ) 109ctrl ('d' ) 110d .Dispatch (tea.KeyPressMsg {Code :tea .KeyEnter }) 111if slices .ContainsFunc (daemonSessions (t ),func (s daemon.Session )bool {return s .ID == archived }) { 112t .Fatalf ("ctrl+d did not delete the archived session:\n%s" ,d .View ()) 113 } 114} 115 116// A long message reaches a preview as a content reference; the session list 117// and agents view read it in full instead of failing the preview. 118func TestSessionPreviewReadsLongMessagesInFull (t * testing.T ) { 119profile := providerRoute (t ,echoReply ) 120t .Parallel () 121id := newSession (t ,t .TempDir ()) 122long := strings .Repeat ("long preview line " ,12000 ) 123if _ ,err := daemon .NewChatClient (conn (t ),id ).Send (t .Context (),long ,nil );err != nil { 124t .Fatal (err ) 125 } 126waitIdle (t ,id ,profile ,1 ) 127preview ,err := daemon .GetSessionPreview (t .Context (),conn (t ),id ,16 ) 128if err != nil { 129t .Fatalf ("the preview failed: %v" ,err ) 130 } 131if ! slices .ContainsFunc (preview .Items ,func (item daemon.PreviewItem )bool {return item .Preview == "echo: " + long }) { 132t .Fatalf ("the long reply is missing from the preview: %d items" ,len (preview .Items )) 133 } 134} 135 136func TestTUINavigationRetainsPendingTurnsAndContinuation (t * testing.T ) { 137entered ,release := make (chan struct {}),make (chan struct {}) 138released := false 139defer func () { 140if ! released { 141close (release ) 142 } 143 }() 144profile := providerRoute (t ,func (request map [string ]any )string { 145if lastUserText (request )== "active navigation turn" { 146close (entered ) 147<- release 148 } 149return echoReply (request ) 150 }) 151t .Parallel () 152session := daemonSession (t ,newSession (t ,t .TempDir ())) 153other := daemonSession (t ,newSession (t ,t .TempDir ())) 154if _ ,err := daemon .NewChatClient (conn (t ),session .ID ).Send (t .Context (),"active navigation turn" ,nil );err != nil { 155t .Fatal (err ) 156 } 157select { 158case <- entered : 159case <- time .After (10 * time .Second ): 160t .Fatal ("active turn did not start" ) 161 } 162connection ,err := daemon .Attach (t .Context (),conn (t ).Snapshot (),nil ) 163if err != nil { 164t .Fatal (err ) 165 } 166counter := & submissionCounterTransport {next :connection .HTTPClient ().Transport ,accepted :make (chan struct {})} 167connection .HTTPClient ().Transport = counter 168t .Cleanup (connection .HTTPClient ().CloseIdleConnections ) 169driver := driveTUIWithConnection (t ,& session ,connection ) 170t .Cleanup (func () {driver .App .Chat .Close () }) 171driver .connected () 172submit := func (text string ) tui.ChatTurnSentMsg { 173t .Helper () 174driver .App .Chat .TextArea .SetValue (text ) 175var sent tui.ChatTurnSentMsg 176for _ ,message := range driver .results (driver .Update (tea.KeyPressMsg {Code :tea .KeyEnter })) { 177if admission ,ok := message .(tui.ChatTurnSentMsg );ok { 178sent = admission 179 } 180driver .Update (message ) 181 } 182if sent .Err != nil || sent .Handle == nil || ! sent .OK { 183t .Fatalf ("durable admission: %+v" ,sent ) 184 } 185return sent 186 } 187pending := submit ("waiting after navigation" ) 188continuation := submit ("." ) 189driver .Update (tui.FolderOpenSessionMsg {Session :other }) 190seen := make (map [string ]bool ) 191for _ ,message := range driver .results (driver .Update (tui.FolderOpenSessionMsg {Session :session })) { 192if resolved ,ok := message .(tui.ChatOperationResolvedMsg );ok { 193seen [resolved .Handle .ID ()]= true 194if resolved .Err != nil || ! resolved .Receipt .Pending () { 195t .Fatalf ("reattached intent: %+v" ,resolved ) 196 } 197 } 198driver .Update (message ) 199 } 200if ! seen [pending .OperationID ]|| ! seen [continuation .OperationID ]|| counter .posts .Load ()!= 2 { 201t .Fatal ("navigation lost an intent or resubmitted it" ) 202 } 203if ! strings .Contains (driver .View (),"waiting after navigation" ) { 204t .Fatal ("returning to the session lost its pending display" ) 205 } 206close (release ) 207released = true 208waitIdle (t ,session .ID ,profile ,2 ) 209driver .Update (tui.FolderOpenSessionMsg {Session :other }) 210for _ ,message := range driver .results (driver .Update (tui.FolderOpenSessionMsg {Session :session })) { 211driver .Update (message ) 212 } 213history ,err := daemon .NewChatClient (conn (t ),session .ID ).History (t .Context (),0 ,120 ) 214if err != nil { 215t .Fatal (err ) 216 } 217matched := 0 218for _ ,event := range history .Events { 219if event .Type == daemon .EventUser && event .OperationID == pending .OperationID { 220matched ++ 221 } 222event .Replayed = true 223driver .Update (tui.ChatStreamEventMsg {SessionID :session .ID ,Generation :driver .App .Chat .Generation ,Event :event }) 224 } 225receipt ,err := daemon .ResolveOperation (t .Context (),connection ,continuation .Handle ) 226if err != nil || receipt .Delivery == nil || * receipt .Delivery != "committed" || matched != 1 || counter .posts .Load ()!= 2 { 227t .Fatalf ("navigation delivery: matched=%d receipt=%+v err=%v posts=%d" ,matched ,receipt ,err ,counter .posts .Load ()) 228 } 229}