okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
ed54422
main
1// Delayed command delivery and discarded screen instances cannot be scheduled 2// deterministically through the terminal E2E harness. These tests exercise real 3// API commands and UI updates while controlling only response timing. 4package tui 5 6import ( 7"context" 8"encoding/json" 9"errors" 10"io" 11"net/http" 12"strings" 13"sync" 14"sync/atomic" 15"testing" 16 17"albedo/cli/internal/config" 18"albedo/cli/internal/daemon" 19 tea"charm.land/bubbletea/v2" 20"github.com/charmbracelet/x/ansi" 21) 22 23func TestReopenedModelPickerRejectsEarlierCatalogAndCancelsItsRead (t * testing.T ) { 24started ,cancelled := make (chan struct {}),make (chan struct {}) 25conn := commandTestConnection (t ,func (w http.ResponseWriter ,r * http.Request ) { 26if r .URL .Query ().Get ("endpoint" )== "https://old.example/v1" { 27close (started ) 28<- r .Context ().Done () 29close (cancelled ) 30return 31 } 32_ = json .NewEncoder (w ).Encode (map [string ]any {"items" : []any {map [string ]any { 33"id" :"fresh-model" ,"label" :"Fresh model" ,"efforts" : []any {}, 34"default_context_tokens" :nil ,"effective_context_tokens" :nil ,"max_context_tokens" :nil , 35"max_output_tokens" :nil ,"input_modalities" : []any {},"image_edge" :nil , 36"raised" :false ,"cap_key" :"fresh-model" ,"cache_policy" :map [string ]any {"ttl_seconds" :nil ,"source" :nil }, 37"source" :"provider" ,"observed_at" :nil , 38 }},"next" :nil }) 39 }) 40profiles := config.Profiles {Providers :map [string ]config.Settings {"work" : {Extension :"openai" ,BaseURL :"https://old.example/v1" }}} 41old := NewModelPickerModel (conn ,profiles ,"" ,"work" ,"" ) 42t .Cleanup (old .Close ) 43delivered := make (chan tea.Msg ,1 ) 44read := old .listCmd (old .catalogs [0 ]) 45go func () {delivered <- read () }() 46awaitCommandSignal (t ,started ) 47old .Close () 48awaitCommandSignal (t ,cancelled ) 49profiles .Providers ["work" ]= config.Settings {Extension :"openai" ,BaseURL :"https://new.example/v1" } 50current := NewModelPickerModel (conn ,profiles ,"" ,"work" ,"" ) 51t .Cleanup (current .Close ) 52current ,_ = current .Update (awaitCommandSignal (t ,delivered )) 53if ! current .catalogs [0 ].loading { 54t .Fatal ("discarded request settled the replacement picker" ) 55 } 56current ,_ = current .Update (current .listCmd (current .catalogs [0 ])()) 57if view := current .View ();! strings .Contains (view ,"fresh-model" )|| current .catalogs [0 ].failed { 58t .Fatalf ("replacement catalog not delivered: %s" ,view ) 59 } 60} 61 62type heldFolderSource struct { 63* fakeFolders 64entered ,cancelled chan struct {} 65} 66 67func (source heldFolderSource )List (ctx context.Context ,_ string ) (daemon.FolderList ,error ) { 68close (source .entered ) 69<- ctx .Done () 70close (source .cancelled ) 71return daemon.FolderList {},ctx .Err () 72} 73 74func TestReopenedFolderPickerRejectsEarlierBrowseAndStopsItsRead (t * testing.T ) { 75source := heldFolderSource {fakeFolders :& fakeFolders {},entered :make (chan struct {}),cancelled :make (chan struct {})} 76old := NewFolderPicker (source , daemon.Session {Workspace :"/workspace" },nil ) 77t .Cleanup (old .Close ) 78previousGeneration := old .sessionsGeneration 79old .setQuery ("/workspace/" ) 80request := old .fetch () 81delivered := make (chan tea.Msg ,1 ) 82go func () {delivered <- request () }() 83awaitCommandSignal (t ,source .entered ) 84old .Close () 85awaitCommandSignal (t ,source .cancelled ) 86current := NewFolderPicker (& fakeFolders {}, daemon.Session {Workspace :"/workspace" },nil ) 87t .Cleanup (current .Close ) 88current .setQuery ("/workspace/" ) 89current ,_ = current .Update (awaitCommandSignal (t ,delivered )) 90current ,_ = current .Update (folderListMsg {Gen :previousGeneration ,Path :"/workspace" ,List : daemon.FolderList {Path :"/workspace" ,Entries : []daemon.FolderEntry {{Name :"obsolete" }}}}) 91if strings .Contains (ansi .Strip (current .View ()),"obsolete" )|| current .listings ["/workspace" ]!= nil { 92t .Fatal ("discarded browse populated the replacement picker" ) 93 } 94current ,_ = current .Update (folderListMsg {Gen :current .sessionsGeneration ,Path :"/workspace" ,List : daemon.FolderList {Path :"/workspace" ,Entries : []daemon.FolderEntry {{Name :"fresh" }}}}) 95if ! strings .Contains (ansi .Strip (current .View ()),"fresh" ) { 96t .Fatal ("current browse was not delivered" ) 97 } 98} 99 100func TestClosedLoginCancelsLateStartupWithoutOpeningBrowser (t * testing.T ) { 101started ,release := make (chan struct {}),make (chan struct {}) 102var releaseOnce sync.Once 103releaseProvider := func () {releaseOnce .Do (func () {close (release ) }) } 104t .Cleanup (releaseProvider ) 105cancelled := make (chan string ,1 ) 106created := make (chan string ,1 ) 107var opened atomic.Bool 108conn := commandTestConnection (t ,func (w http.ResponseWriter ,r * http.Request ) { 109id := strings .TrimPrefix (r .URL .Path ,"/auth/logins/" ) 110if r .Method == http .MethodDelete { 111cancelled <- id 112w .WriteHeader (http .StatusNoContent ) 113return 114 } 115if r .Method != http .MethodPut { 116t .Errorf ("unexpected %s %s" ,r .Method ,r .URL ) 117return 118 } 119created <- id 120close (started ) 121<- release 122w .Header ().Set ("ETag" ,`"observed"` ) 123w .WriteHeader (http .StatusCreated ) 124_ = json .NewEncoder (w ).Encode (map [string ]any {"id" :id ,"provider" :"provider" ,"url" :"https://login.example/authorize" ,"expires_at" :"2099-01-01T00:00:00Z" ,"state" :"waiting" ,"instructions" :nil ,"progress" :"waiting" ,"accounts" : []any {},"failure" :nil }) 125 }) 126app := NewAppModel (conn ,Bootstrap {Settings : daemon.Settings {Profiles : config.Profiles {}}},nil ,"" ,false ,func (string ) {opened .Store (true ) }) 127app .State = AppStateLogin 128app .Login = NewLoginModel (conn ,"" ,app .openBrowser ) 129app .Login .Provider = "provider" 130delivered := make (chan tea.Msg ,1 ) 131command := app .Login .beginLoginCmd () 132go func () {delivered <- command () }() 133awaitCommandSignal (t ,started ) 134expectedID := awaitCommandSignal (t ,created ) 135app .Login .Close () 136app .State = AppStateSessionPicker 137releaseProvider () 138result := awaitCommandSignal (t ,delivered ).(signInStartedMsg ) 139updated ,cleanup := app .Update (result ) 140app = updated .(* AppModel ) 141if cleanup != nil { 142t .Fatal ("completed cleanup was scheduled a second time" ) 143 } 144if id := awaitCommandSignal (t ,cancelled );id != expectedID { 145t .Fatalf ("cancelled a different login: %s" ,id ) 146 } 147if opened .Load ()|| app .State != AppStateSessionPicker { 148t .Fatal ("late login opened a browser or took over the screen" ) 149 } 150} 151 152func TestLateProviderMutationKeepsUncertaintyWithoutSelectingReplacementFlow (t * testing.T ) { 153conn := daemon .NewConnection (daemon.ConnectionSnapshot {Port :1 },nil ) 154app := NewAppModel (conn ,Bootstrap {Settings : daemon.Settings {Profiles : config.Profiles {}}},nil ,"" ,false ,nil ) 155previous := NewLoginModel (conn ,"" ,nil ) 156conn .HTTPClient ().Transport = catalogTransport (func (* http.Request ) (* http.Response ,error ) {return nil ,io .ErrUnexpectedEOF }) 157// Capture the observed validator before creating the mutation command. 158previous .Profiles .ETag = `"seen"` 159command := previous .saveProviderCmd ("work" , config.Settings {Extension :"openai" ,Protocol :"responses" ,BaseURL :"https://provider.example/v1" ,Model :"model" }) 160previous .Close () 161app .Login = NewLoginModel (conn ,"" ,nil ) 162t .Cleanup (func () {app .Login .Close () }) 163app .State = AppStateLogin 164result := command ().(providerSavedMsg ) 165if _ ,ok := errors.AsType [* daemon.UncertainOutcomeError ](result .Err );! ok { 166t .Fatalf ("missing uncertainty: %v" ,result .Err ) 167 } 168updated ,followup := app .Update (result ) 169app = updated .(* AppModel ) 170if followup != nil || app .Login .Name != "" || app .State != AppStateLogin || len (app .Notices )== 0 { 171t .Fatal ("late mutation was hidden or selected the replacement login flow" ) 172 } 173if ! strings .Contains (ansi .Strip (app .content ()),"may have been accepted" ) { 174t .Fatal ("uncertain outcome was not visible in the replacement login screen" ) 175 } 176}