okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
647d18b
main
1// Extension scope toggling guards global defaults against accidental deletion and confirms inheritance. 2// Confirmation and scope navigation states live in unexported picker model fields; 3// E2E lacks a PTY harness to observe modal prompts before dispatch. 4package tui 5 6import ( 7"albedo/cli/internal/daemon" 8"albedo/cli/internal/testwire" 9"encoding/json" 10"net/http" 11"strings" 12"testing" 13 14 tea"charm.land/bubbletea/v2" 15"github.com/charmbracelet/x/ansi" 16) 17 18func TestExtensionsShowBothChoicesAndAskBeforeEachScope (t * testing.T ) { 19m := NewExtensionPickerModel (nil ,"s" ) 20m .SetSize (120 ,30 ) 21m ,_ = m .Update (extensionsLoadedMsg {Gen :m .Generation ,Extensions : []ExtensionItem { 22 {Name :"view" ,Enabled :true ,GlobalEnabled :false ,Overridden :true }, 23 {Name :"bash" ,Enabled :true ,GlobalEnabled :true }, 24 }}) 25view := ansi .Strip (m .View ()) 26if ! strings .Contains (view ,"off view" )|| ! strings .Contains (view ,"this session: on" )|| ! strings .Contains (view ,"follows global" )&& ! strings .Contains (view ,"its own choice" ) { 27t .Fatalf ("the list should show the global default and this session's own choice:\n%s" ,view ) 28 } 29 30// Enter asks about the global default, shift+enter about this session 31// only, and esc leaves either question. 32m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 33if ! m .Confirming ()|| ! strings .Contains (ansi .Strip (m .View ()),"Enable view globally?" ) { 34t .Fatalf ("enter should ask about the global default:\n%s" ,ansi .Strip (m .View ())) 35 } 36m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEsc }) 37m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter ,Mod :tea .ModShift }) 38if ! m .Confirming ()|| ! strings .Contains (ansi .Strip (m .View ()),"Disable view for this session only?" ) { 39t .Fatalf ("shift+enter should ask about this session only:\n%s" ,ansi .Strip (m .View ())) 40 } 41// Other keys do nothing while the question is open. 42m ,_ = m .Update (tea.KeyPressMsg {Code :'y' ,Text :"y" }) 43m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyDown }) 44if ! m .Confirming ()|| m .Cursor != 0 || m .Saving { 45t .Fatal ("a stray key must neither answer nor move past the question" ) 46 } 47m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEsc }) 48 49// ctrl+x drops this session's choice, and only when it has one. 50m ,_ = m .Update (tea.KeyPressMsg {Code :'x' ,Mod :tea .ModCtrl }) 51if ! m .Confirming ()|| ! strings .Contains (ansi .Strip (m .View ()),"follow the global default" ) { 52t .Fatalf ("ctrl+x should confirm dropping the session choice:\n%s" ,ansi .Strip (m .View ())) 53 } 54m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEsc }) 55m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyDown }) 56m ,_ = m .Update (tea.KeyPressMsg {Code :'x' ,Mod :tea .ModCtrl }) 57if m .Confirming () { 58t .Fatal ("ctrl+x needs a session choice to drop" ) 59 } 60} 61 62func TestExtensionsFilterByTypingAndShowDetailsWithoutEnter (t * testing.T ) { 63m := NewExtensionPickerModel (nil ,"s" ) 64m .SetSize (120 ,30 ) 65m ,_ = m .Update (extensionsLoadedMsg {Gen :m .Generation ,Extensions : []ExtensionItem { 66 {Name :"view" ,Description :"highlighted images" ,Plugins : []string {"tool" }}, 67 {Name :"bash" ,Description :"shell commands" ,Tools : []string {"run_command" }}, 68 }}) 69for _ ,r := range "bsh" { 70m ,_ = m .Update (tea.KeyPressMsg {Code :r ,Text :string (r )}) 71 } 72view := ansi .Strip (m .View ()) 73if m .Confirming ()|| strings .Contains (view ,"highlighted images" )&& ! strings .Contains (view ,"bash" )|| ! strings .Contains (view ,"run_command" ) { 74t .Fatalf ("typing should filter to bash and its details should show beside the list:\n%s" ,view ) 75 } 76if ext ,_ := m .current ();ext .Name != "bash" { 77t .Fatalf ("the filter left %q under the cursor" ,ext .Name ) 78 } 79} 80 81// A save can finish before reload fails. Controlled responses expose that 82// ordering and ensure the view never offers a stale-validator mutation retry. 83func TestExtensionEnableKeepsConfirmedSaveWhenReloadFails (t * testing.T ) { 84for _ ,screen := range []string {"picker" ,"capability" } { 85for _ ,refusal := range []string {"outcome" ,"http" } { 86t .Run (screen + "/" + refusal ,func (t * testing.T ) { 87saves ,reloads := 0 ,0 88failureNotice := "reload cannot apply" 89if refusal == "http" { 90failureNotice = "reload not confirmed:" 91 } 92session := protocolSession ("s" ,generationA ,0 ) 93resource := session ["configuration_resource" ].(map [string ]any ) 94resource ["etag" ]= "\"saved\"" 95candidate := protocolCandidate ("skills" ,"Skills" ,true ) 96candidate ["kind" ]= "extension" 97conn := commandTestConnection (t ,func (w http.ResponseWriter ,request * http.Request ) { 98var body any 99switch { 100case request .Method == http .MethodPatch : 101saves ++ 102if request .URL .RequestURI ()!= "/sessions/s?view=configuration" || request .Header .Get ("If-Match" )!= "\"observed\"" { 103t .Fatalf ("wrong desired write %s, validator %q" ,request .URL ,request .Header .Get ("If-Match" )) 104 } 105body = map [string ]any {"resource" :resource ,"session" :session ,"move" :nil } 106case request .Method == http .MethodPost : 107reloads ++ 108var reload daemon.ReloadRequest 109if request .URL .Path != "/sessions/s/reload" || json .NewDecoder (request .Body ).Decode (& reload )!= nil || reload .Target != "session" { 110t .Fatal ("enable did not issue a typed session reload" ) 111 } 112if refusal == "http" { 113w .WriteHeader (http .StatusServiceUnavailable ) 114body = map [string ]any {"type" :"about:blank" ,"title" :"Reload unavailable" ,"status" :503 ,"code" :"reload_unavailable" ,"detail" :"reload cannot apply" } 115 }else { 116body = map [string ]any {"session" :map [string ]any {"state" :"failed" ,"loaded_revision" :nil ,"restart_required" :false ,"warnings" : []any {},"failure" :map [string ]any {"code" :"composition_unavailable" ,"detail" :"reload cannot apply" }},"models" : []any {},"cache_policy" :nil } 117 } 118case request .URL .Path == "/sessions/s/catalog" : 119body = map [string ]any {"discovery" :map [string ]any {"workspace" :"/work" ,"revision" :"fresh" ,"candidates" : []any {candidate },"diagnostics" : []any {},"next" :nil },"discovery_failure" :nil ,"loaded" :map [string ]any {"revision" :nil ,"commands" : []any {},"next" :nil }} 120case request .URL .Path == "/sessions/s" : 121w .Header ().Set ("ETag" ,"\"saved\"" ) 122body = resource ["value" ] 123case request .URL .Path == "/settings" : 124body = testwire .Settings () 125default : 126t .Fatalf ("unexpected request %s %s" ,request .Method ,request .URL ) 127 } 128_ = json .NewEncoder (w ).Encode (body ) 129 }) 130if screen == "picker" { 131model := NewExtensionPickerModel (conn ,"s" ) 132model .Loading ,model .Saving = false ,true 133model .confirm .ask (scopeSession ,"skills" ,"enable" ,"Enable skills?" ) 134model .Extensions = []ExtensionItem {{Name :"skills" ,SessionETag :"\"observed\"" }} 135message := model .changeExtensionCmd ("skills" ,"session" ,true ,model .Generation )() 136model ,_ = model .Update (message ) 137if model .Saving || model .Confirming ()|| model .Error != "" || len (model .Extensions )!= 1 || ! model .Extensions [0 ].Enabled || model .Extensions [0 ].SessionETag != "\"saved\"" || ! strings .Contains (model .Notice ,"Selection saved;" )|| ! strings .Contains (model .Notice ,failureNotice ) { 138t .Fatalf ("confirmed save was lost after reload failed: %+v" ,model ) 139 } 140 }else { 141model := NewCapabilityPageModel (conn ,"s" ,"skills" ) 142model .Loading ,model .Saving ,model .SessionETag = false ,true ,"\"observed\"" 143message := model .enableExtensionCmd (model .Generation )() 144model ,command := model .Update (message ) 145if model .Saving || ! model .Loading || model .Error != "" || command == nil || ! strings .Contains (model .Notice ,"Extension enabled;" )|| ! strings .Contains (model .Notice ,failureNotice ) { 146t .Fatalf ("extension save was lost after reload failed: %+v" ,model ) 147 } 148for _ ,next := range command ().(tea.BatchMsg ) { 149message := next () 150if _ ,loaded := message .(capabilityLoadedMsg );loaded { 151model ,_ = model .Update (message ) 152 } 153 } 154if model .Loading || ! model .ExtensionEnabled || model .SessionETag != "\"saved\"" { 155t .Fatal ("failed reload left stale desired selection or validator" ) 156 } 157 } 158if saves != 1 || reloads != 1 { 159t .Fatalf ("save/reload were replayed: %d/%d" ,saves ,reloads ) 160 } 161 }) 162 } 163 } 164}