okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
4e62e9e
main
1//go:build unix 2 3// Real-daemon TUI fixture: drives an AppModel directly through Bubble Tea 4// update steps against the suite's shared daemon. 5package e2e 6 7import ( 8"context" 9"reflect" 10"slices" 11"strings" 12"sync" 13"testing" 14 15"albedo/cli/internal/daemon" 16"albedo/cli/internal/tui" 17 18 tea"charm.land/bubbletea/v2" 19"github.com/charmbracelet/x/ansi" 20) 21 22// tuiDriver drives a full tui.AppModel against the suite's real daemon, 23// stepping Bubble Tea commands synchronously in place of the event loop. 24type tuiDriver struct { 25t * testing.T 26App * tui.AppModel 27} 28 29// newTUIDriver opens a fresh daemon session in a temp workspace. 30func newTUIDriver (t * testing.T )* tuiDriver { 31t .Helper () 32session := daemonSession (t ,newSession (t ,t .TempDir ())) 33return driveTUI (t ,& session ) 34} 35 36// driveTUI boots an AppModel on session, or on the session picker when nil. 37func driveTUI (t * testing.T ,session * daemon.Session )* tuiDriver { 38t .Helper () 39return driveTUIWithConnection (t ,session ,conn (t )) 40} 41 42func driveTUIWithConnection (t * testing.T ,session * daemon.Session ,connection * daemon.Connection )* tuiDriver { 43t .Helper () 44workspace := t .TempDir () 45if session != nil { 46workspace = session .Workspace 47 } 48settings ,err := daemon .GetSettings (t .Context (),connection ) 49if err != nil { 50t .Fatal (err ) 51 } 52sessions ,err := daemon .ListSessions (t .Context (),connection ) 53if err != nil { 54t .Fatal (err ) 55 } 56d := & tuiDriver {t :t ,App :tui .NewAppModel (connection , tui.Bootstrap {Sessions :sessions ,Settings :settings },session ,workspace ,false ,nil )} 57d .Update (tea.WindowSizeMsg {Width :80 ,Height :22 }) 58return d 59} 60 61func daemonSessions (t * testing.T ) []daemon.Session { 62t .Helper () 63sessions ,err := daemon .ListSessions (context .Background (),conn (t )) 64if err != nil { 65t .Fatalf ("list sessions: %v" ,err ) 66 } 67return sessions 68} 69 70func daemonSession (t * testing.T ,id string ) daemon.Session { 71t .Helper () 72session ,err := daemon .GetSession (t .Context (),conn (t ),id ) 73if err != nil { 74t .Fatalf ("read session %s: %v" ,id ,err ) 75 } 76return session 77} 78 79// Update feeds one message to the model and returns its command. 80func (d * tuiDriver )Update (msg tea.Msg ) tea.Cmd { 81d .t .Helper () 82updated ,cmd := d .App .Update (msg ) 83d .App = updated .(* tui.AppModel ) 84return cmd 85} 86 87// Send feeds msg to the model and returns what its command produced, or nil 88// when the update returned no command. 89func (d * tuiDriver )Send (msg tea.Msg ) tea.Msg { 90d .t .Helper () 91results := d .results (d .Update (msg )) 92if len (results )> 1 { 93d .t .Fatalf ("expected one result, got %#v" ,results ) 94 } 95if len (results )== 0 { 96return nil 97 } 98return results [0 ] 99} 100 101// Key presses code and returns what its command produced. 102func (d * tuiDriver )Key (code rune ) tea.Msg { 103d .t .Helper () 104return d .Send (tea.KeyPressMsg {Code :code }) 105} 106 107// Dispatch feeds msg and every result it leads to back into the model until 108// it settles. 109func (d * tuiDriver )Dispatch (msg tea.Msg ) { 110d .t .Helper () 111d .settle (d .results (d .Update (msg ))) 112} 113 114// Type presses each rune of s as fast as a person types and then settles once: 115// the event loop runs the keys' commands side by side, so a debounce waits once 116// for the whole text instead of once per key. 117func (d * tuiDriver )Type (s string ) { 118d .t .Helper () 119var cmds []tea.Cmd 120for _ ,r := range s { 121cmds = append (cmds ,d .Update (tea.KeyPressMsg {Code :r ,Text :string (r )})) 122 } 123d .settle (d .results (tea .Batch (cmds ... ))) 124} 125 126// settle feeds queued messages, and the results they lead to, until none are 127// left; cursor blinks are dropped because nothing consumes them. 128func (d * tuiDriver )settle (queue []tea.Msg ) { 129d .t .Helper () 130for step := 0 ;len (queue )> 0 ;step ++ { 131if step == 64 { 132d .t .Fatal ("the model never settled" ) 133 } 134var msg tea.Msg 135msg ,queue = queue [0 ],queue [1 :] 136queue = append (queue ,d .results (d .Update (msg ))... ) 137 } 138} 139 140// results runs cmd, and every command batched inside it side by side as the 141// event loop does, and returns what they produced in batch order. 142func (d * tuiDriver )results (cmd tea.Cmd ) []tea.Msg { 143if cmd == nil { 144return nil 145 } 146switch msg := cmd ().(type ) { 147case nil : 148return nil 149case tea.BatchMsg : 150produced := make ([][]tea.Msg ,len (msg )) 151var wg sync.WaitGroup 152for i ,c := range msg { 153wg .Go (func () {produced [i ]= d .results (c ) }) 154 } 155wg .Wait () 156return slices .Concat (produced ... ) 157default : 158if _ ,recurring := msg .(tui.ChatStatusPollMsg );recurring { 159return nil 160 } 161if reflect .TypeOf (msg ).PkgPath ()== "charm.land/bubbles/v2/cursor" { 162return nil 163 } 164return []tea.Msg {msg } 165 } 166} 167 168// connected answers the chat's status poll once, as the event loop would 169// before the composer lets a message through. 170func (d * tuiDriver )connected () { 171d .t .Helper () 172chat := d .App .Chat 173d .Update (d .Update (tui.ChatStatusPollMsg {SessionID :chat .SessionID ,Generation :chat .Generation })()) 174if view := d .View ();strings .Contains (view ,"connecting…" ) { 175d .t .Fatalf ("the session still reads as connecting after its status answered:\n%s" ,view ) 176 } 177} 178 179// pasteImage delivers image as a clipboard paste at the composer's cursor. 180func (d * tuiDriver )pasteImage (image * daemon.ImageAttachment ) { 181chat := d .App .Chat 182d .Update (tui.ClipboardImagePastedMsg {SessionID :chat .SessionID ,Generation :chat .Generation ,Image :image }) 183} 184 185// View returns the rendered terminal contents without styling. 186func (d * tuiDriver )View ()string { 187d .t .Helper () 188content := d .App .View ().Content 189if strings .Contains (content ,"\x1b_albedo:" ) { 190d .t .Fatalf ("private row marks reached the terminal: %q" ,content ) 191 } 192return ansi .Strip (content ) 193}