okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
3b308f0
main
1//go:build unix 2 3// The manual openai-compatible provider wizard crosses the composer, the 4// daemon's sign-in and model-catalog answers, config.json on disk, and the 5// daemon's creds.json. A stubbed daemon cannot prove the real answers lead the 6// wizard to that save, or that the key it hands over is the one requests use. 7package e2e 8 9import ( 10"context" 11"os" 12"path/filepath" 13"slices" 14"strings" 15"testing" 16 17"albedo/cli/internal/config" 18"albedo/cli/internal/daemon" 19"albedo/cli/internal/tui" 20 21 tea"charm.land/bubbletea/v2" 22) 23 24func TestTUILoginAddsAManualProviderThroughTheDaemon (t * testing.T ) { 25name ,key ,model := "wanderer" ,"sk-wanderer-fixture" ,"hermit-mini" 26providerRoute (t ,echoReply ) 27// The wizard uses the suite daemon for profile persistence. 28t .Setenv ("ALBEDO_HOME" ,suite .home ) 29preserveLoginState (t ,name ) 30before ,err := daemon .ProviderProfiles (context .Background (),conn (t )) 31if err != nil { 32t .Fatalf ("suite config: %v" ,err ) 33 } 34baseURL := before .Providers [t .Name ()].BaseURL 35d := newTUIDriver (t ) 36// expect fails unless the wizard is asking this question next. 37expect := func (want tui.LoginStep ) { 38t .Helper () 39if d .App .Login .Step != want { 40t .Fatalf ("the wizard is on step %v, want %v" ,d .App .Login .Step ,want ) 41 } 42 } 43 44d .App .Chat .TextArea .SetValue ("/login " + name ) 45opened ,ok := d .Key (tea .KeyEnter ).(tui.ChatOpenLoginMsg ) 46if ! ok || opened .Name != name { 47t .Fatalf ("enter did not open /login: %#v" ,opened ) 48 } 49// Login.Init asks the daemon what can be signed in to; only its answer 50// applies the hint, which lands on the wizard's base-url question. 51d .Dispatch (opened ) 52if d .App .State != tui .AppStateLogin { 53t .Fatalf ("the daemon's sign-in answer did not route the hint: state=%v\n%s" ,d .App .State ,d .View ()) 54 } 55expect (tui .StepBaseURL ) 56 57d .App .Login .TextInput .SetValue (baseURL ) 58d .Key (tea .KeyEnter ) 59expect (tui .StepAPIKey ) 60d .App .Login .TextInput .SetValue (key ) 61d .Key (tea .KeyEnter ) 62expect (tui .StepProtocol ) 63 64// The keyboard alone moves the protocol picker off the default responses. 65d .Key (tea .KeyDown ) 66picked ,ok := d .Key (tea .KeyEnter ).(tui.PickerSelectMsg ) 67if ! ok || picked .ID != "chat_completions" { 68t .Fatalf ("enter did not pick the protocol: %#v" ,picked ) 69 } 70// The daemon may advertise endpoint defaults even without models.dev. 71// Manual entry remains available alongside every advertised model. 72d .Dispatch (picked ) 73expect (tui .StepModels ) 74for { 75selected ,ok := d .App .Login .ModelPicker .Highlighted () 76if ! ok { 77t .Fatal ("model picker has no manual-entry row" ) 78 } 79if selected .ID == "manual" { 80break 81 } 82d .Key (tea .KeyDown ) 83 } 84manual ,ok := d .Key (tea .KeyEnter ).(tui.PickerSelectMsg ) 85if ! ok || manual .ID != "manual" { 86t .Fatalf ("manual entry was not selectable: %#v" ,manual ) 87 } 88d .Dispatch (manual ) 89expect (tui .StepModel ) 90 91d .App .Login .TextInput .SetValue (model ) 92// The save lands in config.json; the app reloads it and returns to chat. 93d .Dispatch (d .Key (tea .KeyEnter )) 94if d .App .State != tui .AppStateChat || ! strings .Contains (d .View (),"› " )|| d .App .Profiles .Active != name { 95t .Fatalf ("the app did not return to chat with %q active: state=%v active=%q\n%s" ,name ,d .App .State ,d .App .Profiles .Active ,d .View ()) 96 } 97 98profiles ,err := daemon .ProviderProfiles (context .Background (),conn (t )) 99if err != nil { 100t .Fatalf ("saved config: %v" ,err ) 101 } 102got := profiles .Providers [name ] 103want := config.Settings {ProfileName :name ,Extension :"openai" ,BaseURL :baseURL ,Model :model ,Protocol :"chat_completions" ,HasKey :true } 104if got != want { 105t .Fatalf ("config.json kept %+v, want %+v" ,got ,want ) 106 } 107if profiles .Active != name { 108t .Fatalf ("config.json kept %q active, not %q" ,profiles .Active ,name ) 109 } 110// The key went to the daemon, which sends it with the profile's requests. 111id := cliSession (t ,t .TempDir ()) 112cli (t ,"send" ,id ,"which key" ) 113waitIdle (t ,id ,t .Name (),1 ) 114if requests := suite .provider .requests (t .Name ());requests [len (requests )- 1 ]["authorization" ]!= "Bearer " + key { 115t .Fatalf ("the provider saw authorization %v, not the saved key" ,requests [len (requests )- 1 ]["authorization" ]) 116 } 117} 118 119// Vertex and Bedrock authenticate from the daemon host's own credentials, so 120// the wizard asks for a project and location (Vertex) or lets the endpoint and 121// key stay blank (Bedrock) and saves a profile with no key. 122func TestTUILoginSavesCloudProviderProfiles (t * testing.T ) { 123providerRoute (t ,echoReply ) 124t .Setenv ("ALBEDO_HOME" ,suite .home ) 125preserveLoginState (t ,"vertex" ) 126preserveLoginState (t ,"bedrock" ) 127d := newTUIDriver (t ) 128expect := func (want tui.LoginStep ) { 129t .Helper () 130if d .App .Login .Step != want { 131t .Fatalf ("the wizard is on step %v, want %v\n%s" ,d .App .Login .Step ,want ,d .View ()) 132 } 133 } 134start := func (id ,name string ) { 135t .Helper () 136d .App .Chat .TextArea .SetValue ("/login" ) 137d .Dispatch (d .Key (tea .KeyEnter )) 138expect (tui .StepChoose ) 139d .Dispatch (tui.PickerSelectMsg {ID :id }) 140expect (tui .StepName ) 141d .App .Login .TextInput .SetValue (name ) 142d .Key (tea .KeyEnter ) 143 } 144finish := func (model string ) config.Settings { 145t .Helper () 146expect (tui .StepModels ) 147d .Dispatch (tui.PickerSelectMsg {ID :"manual" }) 148expect (tui .StepModel ) 149d .App .Login .TextInput .SetValue (model ) 150d .Dispatch (d .Key (tea .KeyEnter )) 151if d .App .State != tui .AppStateChat { 152t .Fatalf ("the app did not return to chat: state=%v\n%s" ,d .App .State ,d .View ()) 153 } 154profiles ,err := daemon .ProviderProfiles (context .Background (),conn (t )) 155if err != nil { 156t .Fatalf ("saved config: %v" ,err ) 157 } 158return profiles .Providers [d .App .Profiles .Active ] 159 } 160 161start ("add-vertex" ,"vertex" ) 162expect (tui .StepProject ) 163d .App .Login .TextInput .SetValue ("fixture-project" ) 164d .Key (tea .KeyEnter ) 165expect (tui .StepLocation ) 166d .App .Login .TextInput .SetValue ("europe-west4" ) 167d .Dispatch (d .Key (tea .KeyEnter )) 168got := finish ("gemini-fixture" ) 169if got .Extension != "vertex" || got .Model != "gemini-fixture" || got .HasKey || 170deref (got .Project )!= "fixture-project" || deref (got .Location )!= "europe-west4" { 171t .Fatalf ("config.json kept %+v" ,got ) 172 } 173 174// Blank answers fall back to the daemon host's AWS environment. 175start ("add-bedrock" ,"bedrock" ) 176expect (tui .StepBaseURL ) 177d .Key (tea .KeyEnter ) 178expect (tui .StepAPIKey ) 179d .Dispatch (d .Key (tea .KeyEnter )) 180got = finish ("anthropic.claude-fixture" ) 181if got .Extension != "bedrock" || got .BaseURL != "" || got .HasKey || got .Project != nil { 182t .Fatalf ("config.json kept %+v" ,got ) 183 } 184} 185 186func deref (value * string )string { 187if value == nil { 188return "" 189 } 190return * value 191} 192 193// preserveLoginState registers cleanup that deletes the temporary provider and 194// its API key before restoring the saved config. 195func preserveLoginState (t * testing.T ,profile string ) { 196t .Helper () 197configPath := filepath .Join (suite .home ,"config.json" ) 198saved ,err := os .ReadFile (configPath ) 199if err != nil { 200t .Fatal (err ) 201 } 202connection := conn (t ) 203t .Cleanup (func () { 204profiles ,readErr := daemon .ProviderProfiles (context .Background (),connection ) 205if readErr != nil { 206t .Error (readErr ) 207return 208 } 209if err := daemon .DeleteProvider (context .Background (),connection ,profile ,profiles );err != nil { 210t .Errorf ("delete temporary provider %q: %v" ,profile ,err ) 211 } 212if err := os .WriteFile (configPath ,saved ,0o600 );err != nil { 213t .Errorf ("restore login config: %v" ,err ) 214 } 215 }) 216} 217 218// An Anthropic API key rides the claude extension that otherwise signs in, 219// so the wizard must ask for the key instead of starting a sign-in, and a 220// saved key profile must stay usable with no Claude account signed in. 221func TestTUILoginSavesAnAnthropicAPIKeyProfile (t * testing.T ) { 222// Naming the profile after the sign-in provider is the hardest case. 223name ,key ,model := "claude" ,"sk-ant-fixture" ,"claude-fixture" 224providerRoute (t ,echoReply ) 225t .Setenv ("ALBEDO_HOME" ,suite .home ) 226preserveLoginState (t ,name ) 227d := newTUIDriver (t ) 228expect := func (want tui.LoginStep ) { 229t .Helper () 230if d .App .Login .Step != want { 231t .Fatalf ("the wizard is on step %v, want %v\n%s" ,d .App .Login .Step ,want ,d .View ()) 232 } 233 } 234 235d .App .Chat .TextArea .SetValue ("/login" ) 236d .Dispatch (d .Key (tea .KeyEnter )) 237expect (tui .StepChoose ) 238if ! slices .ContainsFunc (d .App .Login .SignIns ,func (s daemon.SignIn )bool {return s .Provider == "claude" }) { 239t .Fatalf ("the daemon offers no claude sign-in to route around: %+v" ,d .App .Login .SignIns ) 240 } 241d .Dispatch (tui.PickerSelectMsg {ID :"add-anthropic" }) 242expect (tui .StepName ) 243d .App .Login .TextInput .SetValue (name ) 244d .Key (tea .KeyEnter ) 245// The extension owns its endpoint, so no base url is asked. 246expect (tui .StepAPIKey ) 247d .App .Login .TextInput .SetValue (key ) 248d .Dispatch (d .Key (tea .KeyEnter )) 249expect (tui .StepModels ) 250d .Dispatch (tui.PickerSelectMsg {ID :"manual" }) 251expect (tui .StepModel ) 252d .App .Login .TextInput .SetValue (model ) 253d .Dispatch (d .Key (tea .KeyEnter )) 254if d .App .State != tui .AppStateChat || d .App .Profiles .Active != name { 255t .Fatalf ("the app did not return to chat with %q active: state=%v active=%q\n%s" ,name ,d .App .State ,d .App .Profiles .Active ,d .View ()) 256 } 257profiles ,err := daemon .ProviderProfiles (context .Background (),conn (t )) 258if err != nil { 259t .Fatalf ("saved config: %v" ,err ) 260 } 261want := config.Settings {ProfileName :name ,Extension :"claude" ,Model :model ,Protocol :"chat_completions" ,HasKey :true } 262if got := profiles .Providers [name ];got != want { 263t .Fatalf ("config.json kept %+v, want %+v" ,got ,want ) 264 } 265held ,err := daemon .GetSettings (context .Background (),conn (t )) 266if err != nil || ! slices .Contains (held .Credentials .Providers ,name ) { 267t .Fatalf ("the daemon holds no key for %q: %+v %v" ,name ,held ,err ) 268 } 269 270// Re-selecting the key profile saves at once rather than signing in. 271d .App .Chat .TextArea .SetValue ("/login " + name ) 272d .Dispatch (d .Key (tea .KeyEnter )) 273if d .App .State != tui .AppStateChat || d .App .Profiles .Active != name { 274t .Fatalf ("re-selecting %q left state=%v step=%v\n%s" ,name ,d .App .State ,d .App .Login .Step ,d .View ()) 275 } 276}