okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
96704f7
main
1// Stale replies from closed capability pages must not satisfy reopened pages, 2// and the confirm, filter, and detail pane are unexported screen state. 3// E2E cannot deterministically interleave replies across discarded views or 4// read the pane and question without a terminal. 5package tui 6 7import ( 8"albedo/cli/internal/config" 9"albedo/cli/internal/daemon" 10"albedo/cli/internal/testwire" 11"encoding/json" 12"io" 13"net/http" 14"strings" 15"testing" 16 17 tea"charm.land/bubbletea/v2" 18"github.com/charmbracelet/x/ansi" 19) 20 21func TestCapabilityStaleReplyFromReplacedInstanceIsRejected (t * testing.T ) { 22disabled := false 23key := "draft" 24items := []capabilityItem {{ID :"row-draft" ,Candidate : daemon.CatalogCandidate {PreferenceKey :& key ,Valid :true ,GlobalPreference :& disabled }}} 25a := NewCapabilityPageModel (nil ,"s" ,"skills" ) 26a ,_ = a .Update (capabilityLoadedMsg {Gen :a .Generation ,Items :items ,Revision :"rev-1" }) 27a ,_ = a .Update (tea.KeyPressMsg {Code :'r' ,Mod :tea .ModCtrl }) 28zombie := capabilityLoadedMsg {Gen :a .Generation ,Items :items ,Revision :"rev-1" } 29a ,_ = a .Update (tea.KeyPressMsg {Code :tea .KeyEscape }) 30 31b := NewCapabilityPageModel (nil ,"s" ,"skills" ) 32b ,_ = b .Update (capabilityLoadedMsg {Gen :b .Generation ,Items :items ,Revision :"rev-1" }) 33before := b .Generation 34b ,_ = b .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 35b ,_ = b .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 36if ! b .Saving || b .Generation == before { 37t .Fatal ("a toggle must start a save under a fresh generation" ) 38 } 39saveGen := b .Generation 40b ,_ = b .Update (capabilitySavedMsg {Gen :saveGen }) 41if b .Saving || ! b .Loading { 42t .Fatal ("a completed save must fetch acknowledged state" ) 43 } 44reloaded := capabilityLoadedMsg {Gen :b .Generation ,Items :items ,Revision :"rev-1" } 45if zombie .Gen == saveGen || zombie .Gen == reloaded .Gen { 46t .Fatal ("generations collided across page instances" ) 47 } 48b ,_ = b .Update (zombie ) 49if ! b .Loading { 50t .Fatal ("the discarded page's reply cleared the current fetch" ) 51 } 52b ,_ = b .Update (reloaded ) 53if b .Loading || b .enabledIn (b .Items [0 ],scopeGlobal )|| len (b .Items )!= 1 { 54t .Fatal ("the current page's acknowledged state was not accepted" ) 55 } 56} 57 58// The detached view owns response ordering and key routing, which daemon E2E 59// scenarios cannot observe deterministically. 60func TestCapabilityConflictRefreshesWithoutReplayingToggle (t * testing.T ) { 61var methods []string 62conn := commandTestConnection (t ,func (w http.ResponseWriter ,r * http.Request ) { 63methods = append (methods ,r .Method ) 64status := 200 65var body any 66switch { 67case len (methods )== 1 && r .Method == http .MethodPatch : 68if r .URL .RequestURI ()!= "/sessions/s?view=configuration" { 69t .Fatalf ("wrong mutation %s" ,r .URL ) 70 } 71status = 409 72body = map [string ]any {"type" :"about:blank" ,"title" :"Catalog changed" ,"status" :409 ,"code" :"catalog_changed" ,"detail" :"catalog changed" } 73case r .URL .Path == "/sessions/s/catalog" : 74body = map [string ]any {"discovery" :map [string ]any {"workspace" :"/daemon-workspace" ,"revision" :"after" ,"candidates" : []any {protocolCandidate ("row" ,"Updated skill" ,false ),protocolCandidate ("new-row" ,"New skill" ,true )},"diagnostics" : []any {},"next" :nil },"discovery_failure" :nil ,"loaded" :map [string ]any {"revision" :nil ,"commands" : []any {},"next" :nil }} 75case r .URL .Path == "/settings" : 76body = testwire .Settings () 77case r .URL .Path == "/sessions/s" : 78body = protocolSession ("s" ,generationA ,0 )["configuration_resource" ].(map [string ]any )["value" ] 79default : 80t .Fatalf ("unexpected request %s %s" ,r .Method ,r .URL ) 81 } 82w .Header ().Set ("ETag" ,"\"after\"" ) 83w .WriteHeader (status ) 84_ = json .NewEncoder (w ).Encode (body ) 85 }) 86key := "draft" 87item := capabilityItem {ID :"row" ,Title :"Original skill" ,Candidate : daemon.CatalogCandidate {ID :"row" ,PreferenceKey :& key ,Valid :true ,EffectiveEnabled :true }} 88m := NewCapabilityPageModel (conn ,"s" ,"skills" ) 89m ,_ = m .Update (capabilityLoadedMsg {Gen :m .Generation ,Items : []capabilityItem {item },Revision :"before" ,SessionETag :"\"seen\"" }) 90m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter ,Mod :tea .ModShift }) 91m ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 92m ,cmd = m .Update (cmd ()) 93if m .Saving || ! m .Loading || cmd == nil || ! m .enabledIn (m .Items [0 ],scopeSession ) { 94t .Fatal ("conflict must preserve acknowledged choices and start a read" ) 95 } 96m ,cmd = m .Update (cmd ()) 97if cmd != nil || m .Loading || m .Error != "" || m .Revision != "after" || len (m .Items )!= 2 { 98t .Fatalf ("refresh did not adopt catalog %+v" ,m ) 99 } 100first := m .Items [1 ] 101if first .ID != "row" { 102first = m .Items [0 ] 103 } 104if m .enabledIn (first ,scopeSession )|| first .Candidate .SessionOverride == nil { 105t .Fatalf ("fresh disabled row lost %+v" ,first ) 106 } 107if len (methods )!= 4 || methods [0 ]!= "PATCH" { 108t .Fatalf ("toggle replayed or missing canonical reads %v" ,methods ) 109 } 110} 111func protocolCandidate (id ,title string ,enabled bool )map [string ]any { 112return map [string ]any {"id" :id ,"kind" :"skill" ,"title" :title ,"description" :"" ,"source" :"/skills/" + id ,"resolved_source" :nil ,"preference_key" :"skill:" + id ,"valid" :true ,"eligible" :enabled ,"effective_enabled" :enabled ,"global_preference" :enabled ,"session_override" :enabled ,"shadowed_by" :nil ,"dependencies" : []any {},"quarantined" :false ,"diagnostic" :nil ,"extension" :nil } 113} 114 115type catalogTransport func (* http.Request ) (* http.Response ,error ) 116 117func (transport catalogTransport )RoundTrip (request * http.Request ) (* http.Response ,error ) { 118return transport (request ) 119} 120 121func TestCapabilityToggleSubmitsRowIdentityAndAcknowledgedRevision (t * testing.T ) { 122conn := daemon .NewConnection (daemon.ConnectionSnapshot {Port :1 },nil ) 123requests := 0 124conn .HTTPClient ().Transport = catalogTransport (func (request * http.Request ) (* http.Response ,error ) { 125requests ++ 126var body struct { 127CatalogRevision string `json:"catalog_revision"` 128Selection struct { 129Skills map [string ]* bool `json:"skills"` 130 }`json:"selection"` 131 } 132if err := json .NewDecoder (request .Body ).Decode (& body );err != nil { 133t .Fatal (err ) 134 } 135choice := body .Selection .Skills ["row-winner" ] 136if request .Method != "PATCH" || request .URL .RequestURI ()!= "/sessions/s?view=configuration" || request .Header .Get ("If-Match" )!= "\"seen\"" || body .CatalogRevision != "current" || choice == nil || * choice { 137t .Fatalf ("wrong observed selection %s %+v" ,request .URL ,body ) 138 } 139session := protocolSession ("s" ,generationA ,0 ) 140resource := session ["configuration_resource" ] 141encoded ,_ := json .Marshal (map [string ]any {"resource" :resource ,"session" :session ,"move" :nil }) 142return & http.Response {StatusCode :200 ,Header :make (http.Header ),Body :io .NopCloser (strings .NewReader (string (encoded )))},nil 143 }) 144key ,winner := "shared-name" ,"row-winner" 145items := []capabilityItem { 146 {ID :winner ,Candidate : daemon.CatalogCandidate {PreferenceKey :& key ,Valid :true ,EffectiveEnabled :true }}, 147 {ID :"row-shadowed" ,Candidate : daemon.CatalogCandidate {PreferenceKey :& key ,Valid :true ,ShadowedBy :& winner ,EffectiveEnabled :true }}, 148 {ID :"row-invalid" ,Candidate : daemon.CatalogCandidate {Valid :false }}, 149 } 150m := NewCapabilityPageModel (conn ,"s" ,"skills" ) 151m ,_ = m .Update (capabilityLoadedMsg {Gen :m .Generation ,Items :items ,Revision :"current" ,SessionETag :"\"seen\"" }) 152for _ ,index := range []int {1 ,2 } { 153m .Cursor = index 154var cmd tea.Cmd 155m ,cmd = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter ,Mod :tea .ModShift }) 156if cmd != nil || m .Saving { 157t .Fatal ("shadowed or unaddressable invalid candidate accepted a toggle" ) 158 } 159 } 160m .Cursor = 0 161m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter ,Mod :tea .ModShift }) 162m ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 163msg := cmd ().(capabilitySavedMsg ) 164if msg .Err != nil || requests != 1 || ! m .Items [0 ].Candidate .EffectiveEnabled { 165t .Fatalf ("toggle must send once without optimistic changes: %v" ,msg .Err ) 166 } 167} 168 169// The filter, the pane, and the confirm live in unexported listView and 170// confirm state; these read them through View and key presses, which E2E 171// cannot do without a terminal. 172func TestCapabilityFilterNarrowsRowsAndPaneShowsWithoutEnter (t * testing.T ) { 173m := NewCapabilityPageModel (nil ,"s" ,"skills" ) 174m .SetSize (120 ,30 ) 175m ,_ = m .Update (capabilityLoadedMsg {Gen :m .Generation ,ExtensionEnabled :true ,Items : []capabilityItem { 176 {ID :"alpha" ,Title :"alpha-review" ,Candidate : daemon.CatalogCandidate {ID :"alpha" ,Source :"/skills/alpha" ,Description :new ("checks diffs "),Valid :true }}, 177 {ID :"beta" ,Title :"beta-notes" ,Candidate : daemon.CatalogCandidate {ID :"beta" ,Source :"/skills/beta" ,Description :new ("writes notes "),Valid :true }}, 178 }}) 179for _ ,r := range "beta" { 180m ,_ = m .Update (tea.KeyPressMsg {Code :r ,Text :string (r )}) 181 } 182view := ansi .Strip (m .View ()) 183if m .Confirming ()|| strings .Contains (view ,"alpha-review" )|| ! strings .Contains (view ,"/skills/beta" )|| ! strings .Contains (view ,"eligible on reload" ) { 184t .Fatalf ("typing should narrow to beta and show its source and state in the pane:\n%s" ,view ) 185 } 186if item ,_ := m .current ();item .ID != "beta" { 187t .Fatalf ("the filter left %q under the cursor" ,item .ID ) 188 } 189} 190 191func TestCapabilityEnterAsksAndOnlyEnterOrEscAnswer (t * testing.T ) { 192key := "draft" 193m := NewCapabilityPageModel (nil ,"s" ,"skills" ) 194m .SetSize (120 ,30 ) 195m ,_ = m .Update (capabilityLoadedMsg {Gen :m .Generation ,ExtensionEnabled :true ,Revision :"rev" ,Items : []capabilityItem { 196 {ID :"one" ,Title :"one" ,Candidate : daemon.CatalogCandidate {ID :"one" ,PreferenceKey :& key ,Valid :true ,EffectiveEnabled :true }}, 197 {ID :"two" ,Title :"two" ,Candidate : daemon.CatalogCandidate {ID :"two" ,PreferenceKey :& key ,Valid :true ,EffectiveEnabled :true }}, 198 }}) 199m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 200if ! m .Confirming ()|| ! strings .Contains (ansi .Strip (m .View ()),"Disable one globally?" ) { 201t .Fatalf ("enter should ask about the global default:\n%s" ,ansi .Strip (m .View ())) 202 } 203m ,_ = m .Update (tea.KeyPressMsg {Code :'y' ,Text :"y" }) 204m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyDown }) 205if ! m .Confirming ()|| m .Cursor != 0 || m .Saving { 206t .Fatal ("a stray key must neither answer nor move past the question" ) 207 } 208m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEsc }) 209if m .Confirming ()|| m .Saving { 210t .Fatal ("esc must cancel the question without saving" ) 211 } 212m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter ,Mod :tea .ModShift }) 213if ! m .Confirming ()|| ! strings .Contains (ansi .Strip (m .View ()),"for this session only" ) { 214t .Fatalf ("shift+enter should ask about this session only:\n%s" ,ansi .Strip (m .View ())) 215 } 216m ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 217if ! m .Saving || cmd == nil { 218t .Fatal ("enter on the question must start the save" ) 219 } 220} 221 222func TestMCPPaneShowsSettingsAndKeysOpenFormAndDelete (t * testing.T ) { 223enabled := true 224m := NewCapabilityPageModel (nil ,"s" ,"mcp" ) 225m .SetSize (120 ,30 ) 226m ,_ = m .Update (capabilityLoadedMsg {Gen :m .Generation ,ExtensionEnabled :true ,Items : []capabilityItem { 227 {ID :"docs" ,Title :"docs" ,Detail :"http · https://docs.example/mcp" ,Server : config.MCPServer {Type :"http" ,URL :"https://docs.example/mcp" ,Enabled :& enabled },Secrets : daemon.MCPSecretNames {BearerToken :true }}, 228 }}) 229view := ansi .Strip (m .View ()) 230if ! strings .Contains (view ,"stored privately" )|| ! strings .Contains (view ,"bearer" )|| ! strings .Contains (view ,"extensions.json" ) { 231t .Fatalf ("the pane should show credentials and config without enter:\n%s" ,view ) 232 } 233m = ctrl (m ,'e' ) 234if m .Form == nil || m .Form .Editing != "docs" { 235t .Fatal ("ctrl+e should edit the highlighted server" ) 236 } 237if view := ansi .Strip (m .View ());! strings .Contains (view ,"edit docs" )|| strings .Contains (view ,"Search MCP servers" ) { 238t .Fatalf ("the open form replaces the list with its filter hidden:\n%s" ,view ) 239 } 240m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEsc }) 241m = ctrl (m ,'d' ) 242if ! m .Confirming ()|| ! strings .Contains (ansi .Strip (m .View ()),"stored credentials will also be removed" ) { 243t .Fatalf ("ctrl+d should ask before deleting, with its credentials warning:\n%s" ,ansi .Strip (m .View ())) 244 } 245m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEsc }) 246if m .Confirming () { 247t .Fatal ("esc should cancel the delete question" ) 248 } 249}